Implemented phase 3 for the POC prototype in the Website service.

This commit is contained in:
javier committed 2026-09-21 12:11:25 +02:00
1 parent b1ceed7790
commit 0dc3fe6bea
29 files changed
+4089 -66

No files matched your search

+46
View File
@@ -127,6 +127,52 @@ struct AppTests {
}
}
@Test
func `buying to open a sale record for its buyer alone`() async throws {
try await app(
staticFilesPath: staticFilesPath
).test(.router) { client in
let form: HTTPFields = [.contentType: "application/x-www-form-urlencoded"]
let cookie = try await client.execute(
uri: "/sign-in",
method: .post,
headers: form,
body: ByteBuffer(string: "return=/")
) { response in
String(try #require(response.headers[.setCookie]).prefix { $0 != ";" })
}
var signedIn = form
signedIn[.cookie] = cookie
let sale = try await client.execute(
uri: "/listing/echo-country-3/buy",
method: .post,
headers: signedIn
) { response in
#expect(response.status == .seeOther)
return try #require(response.headers[.location])
}
try await client.execute(
uri: sale,
method: .get,
headers: [.cookie: cookie]
) { response in
#expect(response.status == .ok)
#expect(String(buffer: response.body).contains("Awaiting payment"))
}
try await client.execute(
uri: sale,
method: .get
) { response in
#expect(response.status == .found)
}
}
}
@Test
func `error page to be served for an unknown listing`() async throws {
try await app(
@@ -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)&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
#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&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()
}
}
@@ -13,7 +13,8 @@ extension Listing {
condition: String = "NM/M",
keyNotes: String? = nil,
priceCents: Int = 24_000,
acceptsOffers: Bool = true
acceptsOffers: Bool = true,
soldAt: Date? = nil
) -> Self {
.init(
slug: slug, shop: shop, title: title, seriesTitle: "Nightward (2019 series)", issueLabel: "#1",
@@ -23,7 +24,8 @@ extension Listing {
keyNotes: keyNotes, sellerNotes: "Clean case.", priceCents: priceCents, shippingCents: 600,
acceptsOffers: acceptsOffers,
cover: .init(tone: "navy", title: "NIGHT\nWARD", imprint: "MERIDIAN", number: "1"),
listedAt: .init(timeIntervalSince1970: 0)
listedAt: .init(timeIntervalSince1970: 0),
soldAt: soldAt
)
}
}
@@ -0,0 +1,98 @@
import Foundation
import Persistence
extension Date {
/// The moment the trade stubs are stamped at: 23 September 2026, 09:14 in Amsterdam.
static let stub = Date(timeIntervalSince1970: 1_790_147_640)
}
extension User {
/// The demo buyer.
static let mara = User(id: .init(), email: "mara.v@example.com", displayName: "Mara Visser")
/// The demo seller, owner of the stub shop.
static let joost = User(id: .init(), email: "joost@kestrelrow.example", displayName: "Joost de Wit")
}
extension Offer {
/// An offer of €215 by the demo buyer on the stub listing, open for another 47 hours unless told otherwise.
static func stub(
listing: Listing = .stub(),
amountCents: Int = 21_500,
status: Status = .open
) -> Self {
.init(
id: .init(uuidString: "00000000-0000-0000-0000-0000000000AA") ?? .init(),
conversationID: .init(uuidString: "00000000-0000-0000-0000-0000000000CC") ?? .init(),
listing: listing,
buyer: .mara,
amountCents: amountCents,
status: status,
createdAt: .stub.addingTimeInterval(-3_600),
expiresAt: .stub.addingTimeInterval(47 * 3_600 + 60)
)
}
}
extension Sale {
/// A sale of the stub listing at €215 + €6, agreed at the stub moment, as far along as told.
static func stub(
priceCents: Int = 21_500,
paid: Bool = false,
sent: Bool = false,
received: Bool = false,
buyerRating: Int? = nil,
sellerRating: Int? = nil
) -> Self {
.init(
number: 48_213,
conversationID: .init(uuidString: "00000000-0000-0000-0000-0000000000CC") ?? .init(),
listing: .stub(soldAt: .stub),
buyer: .mara,
seller: .joost,
priceCents: priceCents,
askedCents: 24_000,
shippingCents: 600,
declared: .init(grade: "CGC 9.8 · cert 4312087-002", pageQuality: "White", printing: "First printing", notes: "Clean case."),
agreedAt: .stub,
paidAt: paid ? .stub.addingTimeInterval(360) : nil,
paymentNote: paid ? "iDEAL" : nil,
sentAt: sent ? .stub.addingTimeInterval(720) : nil,
shippingNote: sent ? "DHL 3S123" : nil,
receivedAt: received ? .stub.addingTimeInterval(1_080) : nil,
buyerRating: buyerRating,
sellerRating: sellerRating
)
}
}
extension Conversation {
/// A conversation about the stub listing, seen from the given side, holding the given entries.
static func stub(
side: Side = .buying,
listing: Listing? = .stub(),
entries: [Entry] = [],
unreadCount: Int = 0,
saleNumber: Int? = nil
) -> Self {
.init(
id: .init(uuidString: "00000000-0000-0000-0000-0000000000CC") ?? .init(),
buyer: .mara,
shop: .stub(),
listing: listing,
side: side,
entries: entries,
unreadCount: unreadCount,
saleNumber: saleNumber
)
}
}
extension Conversation.Entry {
/// Something a party wrote at the stub moment.
static func text(
_ body: String,
from sender: User
) -> Self {
.init(kind: .text, sender: sender, body: body, createdAt: .stub)
}
}