Implemented phase 5 for the POC prototype in the Website service.
This commit is contained in:
1 parent
89a7a7b8e5
commit
31d2d98d49
46 files changed
+4039
-162
No files matched your search
@@ -173,6 +173,38 @@ struct AppTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `landing page to catch up once a copy is sold`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
let sold = #"href="/listing/echo-country-3""#
|
||||
let before = try await client.execute(uri: "/", method: .get) { response in
|
||||
#expect(String(buffer: response.body).contains(sold))
|
||||
|
||||
return try #require(response.headers[.eTag])
|
||||
}
|
||||
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
|
||||
|
||||
try await client.execute(uri: "/listing/echo-country-3/buy", method: .post, headers: signedIn) { response in
|
||||
#expect(response.status == .seeOther)
|
||||
}
|
||||
|
||||
try await client.execute(uri: "/", method: .get, headers: [.ifNoneMatch: before]) { response in
|
||||
// The page changed, so the entity tag a browser holds no longer revalidates.
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.eTag] != before)
|
||||
#expect(!String(buffer: response.body).contains(sold))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `error page to be served for an unknown listing`() async throws {
|
||||
try await app(
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Crawls every internal link of a locally running Website — signed out, as a buyer, and as a seller — and reports the ones that fail.
|
||||
|
||||
swift run Website &
|
||||
python3 Tests/Browser/links.py
|
||||
|
||||
Stdlib only. Exits non-zero when a link answers 4xx or 5xx.
|
||||
"""
|
||||
import html, re, sys, urllib.error, urllib.parse, urllib.request
|
||||
|
||||
BASE = "http://localhost:8080"
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
opener = urllib.request.build_opener(NoRedirect)
|
||||
|
||||
def fetch(path, cookie=None, data=None):
|
||||
request = urllib.request.Request(BASE + path, data=data, headers={"Cookie": cookie} if cookie else {})
|
||||
try:
|
||||
response = opener.open(request)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
return response.status, response.headers, response.read().decode("utf-8", "replace")
|
||||
|
||||
def sign_in(email):
|
||||
_, headers, _ = fetch("/sign-in", data=urllib.parse.urlencode({"email": email, "return": "/"}).encode())
|
||||
return headers["Set-Cookie"].split(";")[0]
|
||||
|
||||
def crawl(name, cookie):
|
||||
seen, queue, broken = set(), ["/"], []
|
||||
while queue:
|
||||
path = queue.pop()
|
||||
if path in seen:
|
||||
continue
|
||||
seen.add(path)
|
||||
status, headers, body = fetch(path, cookie)
|
||||
if status >= 400:
|
||||
broken.append((status, path))
|
||||
continue
|
||||
if 300 <= status < 400:
|
||||
target = urllib.parse.urlsplit(headers["Location"])
|
||||
queue.append(target.path + ("?" + target.query if target.query else ""))
|
||||
continue
|
||||
for href in re.findall(r'href="(/[^"#]*)', body):
|
||||
href = html.unescape(href)
|
||||
# Assets are covered by the app's own tests; the crawl is for screens.
|
||||
if not re.match(r"/(css|js|font|uploads)/|/[^/]+\.(ico|svg|png|webmanifest|xml|txt)", href):
|
||||
queue.append(href)
|
||||
print(f"{name}: {len(seen)} links, {len(broken)} broken")
|
||||
for status, path in sorted(broken):
|
||||
print(f" {status} {path}")
|
||||
return broken
|
||||
|
||||
broken = crawl("signed out", None)
|
||||
broken += crawl("buyer", sign_in(""))
|
||||
broken += crawl("seller", sign_in("joost@kestrelrow.example"))
|
||||
sys.exit(1 if broken else 0)
|
||||
@@ -44,9 +44,12 @@ def check(name, ok, detail=""):
|
||||
# 1. An unknown email registers a passkey and creates the account, then returns to the intent.
|
||||
open_sign_in("/search")
|
||||
press("sam.okafor@example.com")
|
||||
welcomed = chrome.wait_for("location.pathname === '/welcome'")
|
||||
check("join: registers a passkey and meets the first-sign-in screen", welcomed, status() if not welcomed else "")
|
||||
chrome.evaluate("document.querySelector('#name').value = 'Sam Okafor'; document.querySelector('form').submit(); true")
|
||||
landed = chrome.wait_for("location.pathname === '/search'")
|
||||
check("join: registers a passkey and lands on the intent", landed, status() if not landed else chrome.evaluate("location.pathname"))
|
||||
check("join: the page is rendered for the new account", "Account of sam.okafor" in chrome.evaluate("document.documentElement.outerHTML"))
|
||||
check("join: a display name later, lands on the intent", landed, chrome.evaluate("location.pathname"))
|
||||
check("join: the page is rendered for the new account", "Account of Sam Okafor" in chrome.evaluate("document.documentElement.outerHTML"))
|
||||
held = credentials()
|
||||
check("join: the authenticator holds one discoverable credential", len(held) == 1 and held[0]["isResidentCredential"], f"{len(held)} held")
|
||||
|
||||
@@ -56,7 +59,7 @@ open_sign_in("/grading-guide")
|
||||
press("")
|
||||
landed = chrome.wait_for("location.pathname === '/grading-guide'")
|
||||
check("sign-in: a discoverable passkey signs in without an email", landed, status() if not landed else "")
|
||||
check("sign-in: as the same account", "Account of sam.okafor" in chrome.evaluate("document.documentElement.outerHTML"))
|
||||
check("sign-in: as the same account", "Account of Sam Okafor" in chrome.evaluate("document.documentElement.outerHTML"))
|
||||
check("sign-in: the signature counter moved on", credentials()[0]["signCount"] >= 2, credentials()[0]["signCount"])
|
||||
|
||||
# 3. By email, the ceremony names the account's credential.
|
||||
@@ -65,12 +68,27 @@ open_sign_in("/")
|
||||
press("Sam.Okafor@Example.com")
|
||||
check("sign-in: by email, normalized", chrome.wait_for("location.pathname === '/'"), status())
|
||||
|
||||
# 4. A demo account has no passkey, and none can be added to it.
|
||||
# 3b. Signed in, the account adds a second passkey, then revokes one.
|
||||
chrome.send("Page.navigate", url=f"{BASE}/account")
|
||||
assert chrome.wait_for("document.querySelector('[data-passkey=add]') && !document.querySelector('[data-passkey=add]').hidden")
|
||||
chrome.evaluate("document.querySelector('[data-passkey=add]').click(); true")
|
||||
added = chrome.wait_for("document.querySelectorAll('.rows__row--passkey').length === 2")
|
||||
check("account: adds a second passkey from its own session", added and len(credentials()) == 2, status())
|
||||
chrome.evaluate("document.querySelector('.rows__row--passkey form').submit(); true")
|
||||
check("account: revokes one, at once", chrome.wait_for("document.querySelectorAll('.rows__row--passkey').length === 1"))
|
||||
chrome.send("WebAuthn.clearCredentials", authenticatorId=authenticator)
|
||||
chrome.evaluate("fetch('/sign-out', {method: 'POST'}).then(() => true)")
|
||||
chrome.send("Page.navigate", url=f"{BASE}/")
|
||||
chrome.wait_for("document.readyState === 'complete'")
|
||||
anonymous = chrome.evaluate("fetch('/sign-in/passkey/options', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{\"add\": true}'}).then(r => r.status)")
|
||||
check("refusal: nobody adds a passkey signed out", anonymous == 401, anonymous)
|
||||
held_before = 0
|
||||
|
||||
# 4. A demo account has no passkey, and none can be added to it.
|
||||
open_sign_in("/")
|
||||
press("mara.v@example.com")
|
||||
refused = chrome.wait_for("(document.querySelector('[data-passkey-status]')?.textContent ?? '').includes('no passkey')")
|
||||
check("refusal: an existing account is never handed a new passkey", refused and len(credentials()) == 1, status())
|
||||
check("refusal: an existing account is never handed a new passkey", refused and len(credentials()) == held_before, status())
|
||||
check("refusal: nobody was signed in", chrome.evaluate("location.pathname") == "/sign-in")
|
||||
|
||||
sys.exit(0 if all(results) else 1)
|
||||
+2
-2
@@ -40,11 +40,11 @@ struct CatalogueComponentsTests {
|
||||
// MARK: ListingCard tests
|
||||
|
||||
@Test
|
||||
func `links a card to its listing and gates saving behind the sign-in`() {
|
||||
func `links a card to its listing, and saving it to a want for it`() {
|
||||
let html = ListingCard(.stub()).render()
|
||||
|
||||
#expect(html.contains(#"href="/listing/nightward-1">Nightward #1</a>"#))
|
||||
#expect(html.contains(#"href="/sign-in?return=/listing/nightward-1""#))
|
||||
#expect(html.contains(#"href="/wants/new?listing=nightward-1""#))
|
||||
#expect(html.contains("€240"))
|
||||
#expect(html.contains("CGC 9.8"))
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ struct SiteFooterTests {
|
||||
#expect(html.contains(">\(title)</h2>"))
|
||||
}
|
||||
|
||||
for path in [String.Route.series, .Route.openShop, .Route.gradingGuide, .Route.signIn, .Route.recover, .Route.terms] {
|
||||
for path in [String.Route.series, .Route.openShop, .Route.gradingGuide, .Route.signIn, .Route.wants, .Route.account] {
|
||||
#expect(html.contains(#"href="\#(path)""#))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,13 +35,23 @@ struct SiteHeaderTests {
|
||||
#expect(html.contains(#"href="/sell""#))
|
||||
#expect(html.contains(">MV<"))
|
||||
#expect(html.contains(">Mara<"))
|
||||
#expect(html.contains(#"action="/sign-out" method="post""#))
|
||||
// Signing out lives on the account screen.
|
||||
#expect(!html.contains("/sign-out"))
|
||||
#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""#))
|
||||
func `leads a seller to their shop, and anyone else to opening one`() {
|
||||
let buyer = SiteHeader(viewer: .init(displayName: "Mara")).render()
|
||||
|
||||
#expect(!buyer.contains(#"href="/sell""#))
|
||||
#expect(buyer.contains(#"href="/sell/open-a-shop""#))
|
||||
#expect(buyer.contains(">Open a shop<"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `counts one new match in the singular`() {
|
||||
#expect(SiteHeader(viewer: .init(displayName: "Mara", newWantMatches: 1)).render().contains(#"aria-label="Want list, 1 new match""#))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -105,7 +105,8 @@ struct CataloguePagesTests {
|
||||
#expect(html.contains(action))
|
||||
}
|
||||
|
||||
#expect(html.components(separatedBy: #"href="/sign-in?return=/listing/nightward-1""#).count - 1 >= 5)
|
||||
// Buy, offer, message, and the note's own link. Saving goes through the want list, which gates itself.
|
||||
#expect(html.components(separatedBy: #"href="/sign-in?return=/listing/nightward-1""#).count - 1 == 4)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -134,7 +135,8 @@ struct CataloguePagesTests {
|
||||
#expect(html.contains(#"href="/messages/new?listing=nightward-1""#))
|
||||
#expect(html.contains(#"aria-label="Account of Mara Visser""#))
|
||||
#expect(!html.contains("gate-note"))
|
||||
#expect(html.contains(#"disabled title="Not part of the prototype">Save"#))
|
||||
#expect(html.contains(#"href="/wants/new?listing=nightward-1""#))
|
||||
#expect(html.contains(#"href="/series/nightward?issue=1#copies""#))
|
||||
}
|
||||
|
||||
// MARK: ShopPage tests
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Persistence
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"Account and collector pages",
|
||||
.tags(.page)
|
||||
)
|
||||
struct CollectorPagesTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
private static let nightward = Series(slug: "nightward", name: "Nightward", publisher: "Meridian Comics", years: "2019–2021", issueCount: 4, blurb: "A short run.")
|
||||
|
||||
// MARK: OpenShopPage tests
|
||||
|
||||
@Test
|
||||
func `open-a-shop form starts as a private seller, and gives back what was typed with its problems`() {
|
||||
let empty = OpenShopPage().render()
|
||||
let sent = OpenShopPage(
|
||||
form: ShopFormTests.form(["kind": "business", "about": "Spare <copies>"]),
|
||||
problems: [.kvk: "Enter your 8-digit KvK number."]
|
||||
).render()
|
||||
|
||||
#expect(empty.contains(#"action="/sell/open-a-shop" method="post""#))
|
||||
#expect(empty.contains(#"name="kind" value="private" checked"#))
|
||||
#expect(!empty.contains(#"role="alert""#))
|
||||
#expect(sent.contains("One thing to fix before opening."))
|
||||
#expect(sent.contains(#"name="kind" value="business" checked"#))
|
||||
#expect(sent.contains(#"name="name" value="Ana's Longbox""#) || sent.contains(#"name="name" value="Ana's Longbox""#))
|
||||
#expect(sent.contains(#"id="kvk-error">Enter your 8-digit KvK number."#))
|
||||
#expect(sent.contains("Spare <copies>"))
|
||||
#expect(!sent.contains("style="))
|
||||
}
|
||||
|
||||
// MARK: WelcomePage and AccountPage tests
|
||||
|
||||
@Test
|
||||
func `welcome screen is bare, asks for a name, and carries the intent on`() {
|
||||
let html = WelcomePage(displayName: "ana.devries", returnPath: "/listing/nightward-1").render()
|
||||
|
||||
#expect(html.contains("You're in.") || html.contains("You're in."))
|
||||
#expect(html.contains(#"action="/welcome" method="post""#))
|
||||
#expect(html.contains(#"name="name" value="ana.devries""#))
|
||||
#expect(html.contains(#"name="return" value="/listing/nightward-1""#))
|
||||
#expect(html.contains(#"content="noindex""#))
|
||||
#expect(!html.contains("site-header"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `account screen lists the passkeys with a way to revoke each, and to add one`() {
|
||||
let passkey = Passkey(credentialID: "cred-1", publicKey: [], signCount: 0, user: .mara)
|
||||
let html = AccountPage(user: .mara, passkeys: [(passkey, .stub)], viewer: .init(.mara), now: .stub).render()
|
||||
let bare = AccountPage(user: .mara, passkeys: [], now: .stub).render()
|
||||
|
||||
#expect(html.contains(#"action="/account/name" method="post""#))
|
||||
#expect(html.contains(#"name="name" value="Mara Visser""#))
|
||||
#expect(html.contains("Passkey 1"))
|
||||
#expect(html.contains("created today, 09:14"))
|
||||
#expect(html.contains(#"name="credential" value="cred-1""#))
|
||||
#expect(html.contains(#"hidden data-passkey="add""#))
|
||||
#expect(html.contains("/js/passkey.js"))
|
||||
#expect(html.contains(#"href="/sell/open-a-shop">Open a shop"#))
|
||||
#expect(bare.contains("No passkey yet"))
|
||||
}
|
||||
|
||||
// MARK: WantsPage tests
|
||||
|
||||
@Test
|
||||
func `want list shows each want's limits, its cheapest match, and what is new`() {
|
||||
let want = Want(
|
||||
id: .init(uuidString: "00000000-0000-0000-0000-0000000000EE") ?? .init(),
|
||||
title: "Nightward #1", minGrade: 8, maxPriceCents: 30_000, slabbedOnly: true, alerts: true,
|
||||
matches: [.stub(slug: "a", priceCents: 24_000), .stub(slug: "b", priceCents: 21_000)],
|
||||
newMatchCount: 1
|
||||
)
|
||||
let html = WantsPage(wants: [want]).render()
|
||||
|
||||
#expect(html.contains("1 want · 1 new matches"))
|
||||
#expect(html.contains("slabbed only · grade ≥ 8.0 · max €300 · ships from EU only"))
|
||||
#expect(html.contains(#"href="/listing/b">From €210"#))
|
||||
#expect(html.contains(#"class="wants__new">1 new"#))
|
||||
#expect(html.contains(#"role="switch" aria-checked="true""#))
|
||||
#expect(html.contains(#"action="/wants/00000000-0000-0000-0000-0000000000ee/remove""#))
|
||||
#expect(html.contains(#"href="/search?q=Nightward%20%231&grade-min=8.0&price-max=300&grading=slabbed""#))
|
||||
#expect(html.contains(#"href="/wants/new""#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `want list fills its form from what is being saved`() {
|
||||
let html = WantsPage(wants: [], draft: .init(title: "Nightward #2", minGrade: 9.6, maxPriceCents: 8_500)).render()
|
||||
|
||||
#expect(html.contains(#"name="title" value="Nightward #2""#))
|
||||
#expect(html.contains(#"<option value="9.6" selected>"#))
|
||||
#expect(html.contains(#"name="max-price" value="85""#))
|
||||
#expect(!html.contains("Nothing on your list yet"))
|
||||
}
|
||||
|
||||
// MARK: SeriesPage tests
|
||||
|
||||
@Test
|
||||
func `series shows every issue of the run, the gaps one tap from an alert`() {
|
||||
let html = SeriesPage(
|
||||
series: Self.nightward,
|
||||
listings: [.stub(keyNotes: "First appearance"), .stub(slug: "cheap", priceCents: 9_000)]
|
||||
).render()
|
||||
|
||||
#expect(html.contains("All 4 issues"))
|
||||
#expect(html.contains("2 copies listed across CCN right now."))
|
||||
#expect(html.contains(#"href="/series/nightward?issue=1#copies">2 listed · from €90"#))
|
||||
#expect(html.components(separatedBy: "series__issue--gap").count - 1 == 3)
|
||||
// Signed out, an alert leads through the sign-in and back.
|
||||
#expect(html.contains(#"href="/sign-in?return=/series/nightward">None listed · alert me"#))
|
||||
#expect(html.contains("#1 · First appearance"))
|
||||
#expect(!html.contains(#"id="copies""#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `series lists an issue's copies cheapest delivered first, ready to compare`() {
|
||||
let html = SeriesPage(
|
||||
series: Self.nightward,
|
||||
listings: [.stub(), .stub(slug: "cheap", priceCents: 9_000, defects: ["Foxing / staining"])],
|
||||
issue: 1,
|
||||
viewer: .init(displayName: "Mara")
|
||||
).render()
|
||||
let cheap = try? #require(html.range(of: #"value="cheap""#))
|
||||
let dear = try? #require(html.range(of: #"value="nightward-1""#))
|
||||
|
||||
#expect(html.contains("Copies of #1 — 2 listed"))
|
||||
#expect(html.contains(#"action="/compare" method="get""#))
|
||||
#expect(html.contains("Foxing / staining"))
|
||||
#expect(html.contains(#"href="/wants/new?title=Nightward%20%232">None listed · alert me"#))
|
||||
|
||||
if let cheap, let dear {
|
||||
#expect(cheap.lowerBound < dear.lowerBound)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `series known only from its listings shows the issues listed, without gaps`() {
|
||||
let html = SeriesPage(
|
||||
series: .init(slug: "nightward", name: "Nightward", publisher: "Meridian Comics", years: "2019", issueCount: 0, blurb: ""),
|
||||
listings: [.stub()]
|
||||
).render()
|
||||
|
||||
#expect(html.contains("Issues listed"))
|
||||
#expect(!html.contains("series__issue--gap"))
|
||||
}
|
||||
|
||||
// MARK: ComparePage tests
|
||||
|
||||
@Test
|
||||
func `comparison orders copies by what they cost delivered, and flags a customs border`() {
|
||||
let abroad = Shop(
|
||||
slug: "longbox", name: "Longbox Atlantic", city: "Bristol", country: "GB", sinceYear: 2024, isVerified: false,
|
||||
rating: 4.6, ratedSales: 58, soldCount: 71, handlingDays: 4, shipsNote: "", returnsNote: "returns by agreement", about: ""
|
||||
)
|
||||
let html = ComparePage(copies: [.stub(), .stub(slug: "abroad", shop: abroad, priceCents: 15_000)]).render()
|
||||
let first = try? #require(html.range(of: "€156"))
|
||||
let second = try? #require(html.range(of: "€246"))
|
||||
|
||||
#expect(html.contains("customs may apply"))
|
||||
#expect(html.components(separatedBy: "customs may apply").count - 1 == 1)
|
||||
#expect(html.contains(#"href="/sign-in?return=/listing/abroad">Buy now"#))
|
||||
#expect(html.contains("58 · 4.6 · returns by agreement"))
|
||||
|
||||
if let first, let second {
|
||||
#expect(first.lowerBound < second.lowerBound)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `comparison of nothing says where to pick copies`() {
|
||||
#expect(ComparePage(copies: []).render().contains("Nothing to compare"))
|
||||
}
|
||||
|
||||
// MARK: Shared tests
|
||||
|
||||
@Test
|
||||
func `no account or collector page renders an inline style`() {
|
||||
let pages = [
|
||||
OpenShopPage().render(),
|
||||
WelcomePage(displayName: "ana", returnPath: "/").render(),
|
||||
AccountPage(user: .mara, passkeys: [], now: .stub).render(),
|
||||
WantsPage(wants: [], draft: .init()).render(),
|
||||
SeriesPage(series: Self.nightward, listings: [.stub()], issue: 1).render(),
|
||||
SeriesIndexPage(entries: [(Self.nightward, [.stub()])]).render(),
|
||||
ComparePage(copies: [.stub()]).render(),
|
||||
]
|
||||
|
||||
for html in pages {
|
||||
#expect(!html.contains("style="))
|
||||
#expect(!html.contains("<style"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Persistence
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"ShopForm type",
|
||||
.tags(.type)
|
||||
)
|
||||
struct ShopFormTests {
|
||||
|
||||
// MARK: Methods tests
|
||||
|
||||
@Test
|
||||
func `describes a private seller's shop`() throws {
|
||||
let draft = try Self.form().validated().get()
|
||||
|
||||
#expect(draft.name == "Ana's Longbox")
|
||||
#expect(draft.handle == "anas-longbox")
|
||||
#expect(draft.city == "Leiden")
|
||||
#expect(draft.country == "NL")
|
||||
#expect(draft.kind == .privateSeller)
|
||||
#expect(draft.handlingDays == 2)
|
||||
#expect(draft.returnsNote == "14-day returns")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `asks a business for its registration, as Dutch consumer law does`() {
|
||||
#expect(Self.problems(["kind": "business"]) == [.kvk, .legalName])
|
||||
#expect(Self.problems(["kind": "business", "kvk": "1234", "legal-name": "Ana BV"]) == [.kvk])
|
||||
#expect(Self.problems(["kind": "business", "kvk": "12345678", "legal-name": "Ana BV"]).isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `names every field that is missing or malformed`() {
|
||||
#expect(Self.problems(["name": "Ab", "handle": "Bad Handle!", "city": " ", "country": "US", "kind": "charity"]) == [
|
||||
.name, .handle, .city, .country, .kind,
|
||||
])
|
||||
#expect(Self.problems(["handle": "-leading"]) == [.handle])
|
||||
#expect(Self.problems(["handle": "ab"]) == [.handle])
|
||||
#expect(Self.problems(["returns": "Never", "handling": "9"]) == [.returns, .handling])
|
||||
#expect(Self.problems(["declaration": ""]) == [.declaration])
|
||||
#expect(Self.problems(["about": String(repeating: "a", count: 601)]) == [.about])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `lowercases the handle it is given`() throws {
|
||||
#expect(try Self.form(["handle": "Anas-Longbox"]).validated().get().handle == "anas-longbox")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
extension ShopFormTests {
|
||||
|
||||
/// A valid form for a private seller, with the given fields replaced.
|
||||
static func form(
|
||||
_ overrides: [String: String] = [:]
|
||||
) -> ShopForm {
|
||||
.init(values: [
|
||||
"name": "Ana's Longbox", "handle": "anas-longbox", "city": "Leiden", "country": "NL",
|
||||
"about": "Spare copies.", "kind": "private", "returns": "14-day returns", "handling": "2", "declaration": "1",
|
||||
].merging(overrides) { $1 })
|
||||
}
|
||||
|
||||
/// The fields a form with the given overrides has problems with.
|
||||
static func problems(
|
||||
_ overrides: [String: String]
|
||||
) -> Set<ShopForm.Field> {
|
||||
guard case .failure(let problems) = form(overrides).validated() else {
|
||||
return []
|
||||
}
|
||||
|
||||
return Set(problems.fields.keys)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -94,6 +94,7 @@ private extension StaticFileTests {
|
||||
/// Every file paired with the path it is served at, one per extension, in ``StaticFile/all`` order.
|
||||
static let paths: [(File, [String])] = [
|
||||
(.appleTouchIcon, ["apple-touch-icon.png"]),
|
||||
(.collecting, ["css/collecting.css"]),
|
||||
(.favicon, ["favicon.ico"]),
|
||||
(.fontAnton, ["font/anton-400.woff2"]),
|
||||
(.fontArchivo, ["font/archivo-variable.woff2"]),
|
||||
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Logging
|
||||
import NIOCore
|
||||
import Persistence
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"AccountController and CollectingController controllers",
|
||||
.tags(.controller)
|
||||
)
|
||||
struct AccountAndCollectingControllerTests {
|
||||
|
||||
// MARK: Story tests
|
||||
|
||||
@Test
|
||||
func `takes a stranger from joining to a shop of their own, with no seeded account involved`() async throws {
|
||||
try await withApp { client in
|
||||
// Joining leads to the first-sign-in screen, which carries the intent on.
|
||||
let (cookie, welcome) = try await client.execute(
|
||||
uri: "/sign-in",
|
||||
method: .post,
|
||||
headers: Self.form,
|
||||
body: ByteBuffer(string: "email=ana%40example.com&return=/sell/open-a-shop")
|
||||
) { response in
|
||||
(String(try #require(response.headers[.setCookie]).prefix { $0 != ";" }), try #require(response.headers[.location]))
|
||||
}
|
||||
|
||||
#expect(welcome == "/welcome?return=/sell/open-a-shop")
|
||||
|
||||
let tooShort = try await post(client, "/welcome", "name=A&return=/sell/open-a-shop", as: cookie, expecting: .unprocessableContent)
|
||||
let named = try await post(client, "/welcome", "name=Ana+de+Vries&return=/sell/open-a-shop", as: cookie)
|
||||
|
||||
#expect(tooShort == nil)
|
||||
#expect(named == "/sell/open-a-shop")
|
||||
|
||||
try await client.execute(uri: "/sell/open-a-shop", method: .get, headers: [.cookie: cookie]) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(body.contains(#"aria-label="Account of Ana de Vries""#))
|
||||
#expect(body.contains(">Open a shop<"))
|
||||
}
|
||||
|
||||
let shop = "name=Ana%27s+Longbox&handle=anas-longbox&city=Leiden&country=NL&kind=private&returns=14-day+returns&handling=2&declaration=1"
|
||||
let taken = try await post(client, "/sell/open-a-shop", shop.replacingOccurrences(of: "anas-longbox", with: "kestrel-row-comics"), as: cookie, expecting: .unprocessableContent)
|
||||
let opened = try await post(client, "/sell/open-a-shop", shop, as: cookie)
|
||||
|
||||
#expect(taken == nil)
|
||||
// Straight on to the first listing.
|
||||
#expect(opened == "/sell/new")
|
||||
|
||||
try await client.execute(uri: "/shop/anas-longbox", method: .get) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(String(buffer: response.body).contains("Ana's Longbox") || String(buffer: response.body).contains("Ana's Longbox"))
|
||||
}
|
||||
|
||||
// One shop per account: the form now leads to the dashboard.
|
||||
try await client.execute(uri: "/sell/open-a-shop", method: .get, headers: [.cookie: cookie]) { response in
|
||||
#expect(response.headers[.location] == "/sell")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Account tests
|
||||
|
||||
@Test
|
||||
func `renames the account in the session it is signed in with`() async throws {
|
||||
try await withApp { client in
|
||||
let mara = try await signIn(client)
|
||||
let location = try await post(client, "/account/name", "name=Mara+V.", as: mara)
|
||||
|
||||
#expect(location == "/account")
|
||||
|
||||
try await client.execute(uri: "/account", method: .get, headers: [.cookie: mara]) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(body.contains(#"aria-label="Account of Mara V.""#))
|
||||
#expect(body.contains("No passkey yet"))
|
||||
}
|
||||
|
||||
let blank = try await post(client, "/account/name", "name=+", as: mara, expecting: .unprocessableContent)
|
||||
|
||||
#expect(blank == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: ["/account", "/welcome", "/wants", "/wants/new", "/sell/open-a-shop"])
|
||||
func `keeps an account's screens to a signed-in account`(
|
||||
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]?.hasPrefix("/sign-in") == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Wants tests
|
||||
|
||||
@Test
|
||||
func `rings the header's bell for a new match, until the want list is looked at`() async throws {
|
||||
try await withApp { client in
|
||||
let mara = try await signIn(client)
|
||||
|
||||
_ = try await post(client, "/wants", "title=Nightward+%231&min-grade=9.0&max-price=300", as: mara)
|
||||
|
||||
try await client.execute(uri: "/search", method: .get, headers: [.cookie: mara]) { response in
|
||||
// The slabbed 9.8 and the raw NM, within the EU; the 9.4 ships from the UK.
|
||||
#expect(String(buffer: response.body).contains(#"aria-label="Want list, 2 new matches""#))
|
||||
}
|
||||
|
||||
let id = try await client.execute(uri: "/wants", method: .get, headers: [.cookie: mara]) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(body.contains(#"class="wants__new">2 new"#))
|
||||
#expect(body.contains("grade ≥ 9.0 · max €300 · ships from EU only"))
|
||||
|
||||
return String(try #require(body.firstMatch(of: /\/wants\/([0-9a-f-]{36})\/remove/)).output.1)
|
||||
}
|
||||
|
||||
try await client.execute(uri: "/search", method: .get, headers: [.cookie: mara]) { response in
|
||||
#expect(String(buffer: response.body).contains(#"aria-label="Want list""#))
|
||||
}
|
||||
|
||||
// Another account can neither silence nor remove it.
|
||||
let sam = try await signIn(client, email: "sam@example.com")
|
||||
|
||||
_ = try await post(client, "/wants/\(id)/remove", "", as: sam)
|
||||
|
||||
try await client.execute(uri: "/wants", method: .get, headers: [.cookie: mara]) { response in
|
||||
#expect(String(buffer: response.body).contains("Nightward #1"))
|
||||
}
|
||||
|
||||
_ = try await post(client, "/wants/\(id)/alerts", "", as: mara)
|
||||
_ = try await post(client, "/wants/\(id)/remove", "", as: mara)
|
||||
|
||||
try await client.execute(uri: "/wants", method: .get, headers: [.cookie: mara]) { response in
|
||||
#expect(String(buffer: response.body).contains("Nothing on your list yet"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `fills the want form from a copy being saved, or a gap in a series`() async throws {
|
||||
try await withApp { client in
|
||||
let mara = try await signIn(client)
|
||||
|
||||
try await client.execute(uri: "/wants/new?listing=nightward-2-cgc-9-6", method: .get, headers: [.cookie: mara]) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(body.contains(#"name="title" value="Nightward #2""#))
|
||||
#expect(body.contains(#"name="max-price" value="85""#))
|
||||
#expect(body.contains(#"<option value="9.6" selected>"#))
|
||||
}
|
||||
|
||||
try await client.execute(uri: "/wants/new?title=Nightward%20%2311", method: .get, headers: [.cookie: mara]) { response in
|
||||
#expect(String(buffer: response.body).contains(#"name="title" value="Nightward #11""#))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Series tests
|
||||
|
||||
@Test
|
||||
func `serves the series index, a series with its gaps, and the comparison of up to three copies`() async throws {
|
||||
try await withApp { client in
|
||||
try await client.execute(uri: "/series", method: .get) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(body.contains(#"href="/series/nightward""#))
|
||||
#expect(body.contains("24 issues"))
|
||||
}
|
||||
|
||||
try await client.execute(uri: "/series/nightward?issue=1", method: .get) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(body.contains("All 24 issues"))
|
||||
// Every copy, wherever it ships from: the series screen is the whole market.
|
||||
#expect(body.contains("Copies of #1 — 4 listed"))
|
||||
#expect(body.components(separatedBy: "series__issue--gap").count - 1 == 22)
|
||||
#expect(body.contains("Nightward: The First Year"))
|
||||
}
|
||||
|
||||
// A series sellers list, but the catalogue has no run for.
|
||||
try await client.execute(uri: "/series/hollow-comet", method: .get) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(String(buffer: response.body).contains("Collected editions"))
|
||||
}
|
||||
|
||||
try await client.execute(uri: "/series/no-such-series", method: .get) { response in
|
||||
#expect(response.status == .notFound)
|
||||
}
|
||||
|
||||
let copies = ["nightward-1-cgc-9-8", "nightward-1-cgc-9-4", "nightward-1-raw-nm", "nightward-1-2nd-print", "missing"]
|
||||
|
||||
try await client.execute(uri: "/compare?" + copies.map { "copy=\($0)" }.joined(separator: "&"), method: .get) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(body.components(separatedBy: #"compare__copy""#).count - 1 == 3)
|
||||
#expect(body.contains("€246"))
|
||||
#expect(body.contains("customs may apply"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension AccountAndCollectingControllerTests {
|
||||
|
||||
static let form: HTTPFields = [.contentType: "application/x-www-form-urlencoded"]
|
||||
|
||||
/// Posts a form as an account, returning where it leads — or `nil` when it answers with a page instead.
|
||||
func post(
|
||||
_ client: any TestClientProtocol,
|
||||
_ uri: String,
|
||||
_ body: String,
|
||||
as cookie: String,
|
||||
expecting status: HTTPResponse.Status = .seeOther
|
||||
) async throws -> String? {
|
||||
var headers = Self.form
|
||||
|
||||
headers[.cookie] = cookie
|
||||
|
||||
return try await client.execute(uri: uri, method: .post, headers: headers, body: ByteBuffer(string: body)) { response in
|
||||
#expect(response.status == status)
|
||||
|
||||
return 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: Self.form, body: ByteBuffer(string: "email=\(email)&return=/")) { response in
|
||||
String(try #require(response.headers[.setCookie]).prefix { $0 != ";" })
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the body against the session, catalogue, account, and collecting routes, 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 accounts = AccountRepository(fluent: fluent)
|
||||
let catalogue = CatalogueRepository(fluent: fluent)
|
||||
let collecting = CollectingRepository(fluent: fluent)
|
||||
let sessions = SessionStore(trade: .init(fluent: fluent), collecting: collecting)
|
||||
let router = Router(context: WebsiteRequestContext.self)
|
||||
|
||||
router.addRoutes(CatalogueController<WebsiteRequestContext>(repository: catalogue, sessions: sessions).routes)
|
||||
router.addRoutes(SessionController<WebsiteRequestContext>(sessions: sessions, accounts: accounts, catalogue: catalogue).routes)
|
||||
router.addRoutes(AccountController<WebsiteRequestContext>(accounts: accounts, catalogue: catalogue, sessions: sessions).routes)
|
||||
router.addRoutes(CollectingController<WebsiteRequestContext>(collecting: collecting, 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