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

This commit is contained in:
javier committed 2026-09-21 13:53:13 +02:00
1 parent 256d22df40
commit f2f445827b
40 files changed
+1906 -37

No files matched your search

+15 -1
View File
@@ -82,7 +82,21 @@ 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
# 3c. Every passkey lost, a one-time link sets up a new one — which replaces the others, and signs in.
link = chrome.evaluate("""fetch('/recover', {method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: 'email=sam.okafor%40example.com'})
.then(r => r.text()).then(html => (html.match(/\\/recover\\/[0-9a-f]+/) || [null])[0])""")
check("recover: a known email gets a one-time link", bool(link), link)
chrome.send("Page.navigate", url=f"{BASE}{link}")
assert chrome.wait_for("document.querySelector('[data-passkey=recover]') && !document.querySelector('[data-passkey=recover]').hidden")
chrome.evaluate("document.querySelector('[data-passkey=recover]').click(); true")
recovered = chrome.wait_for("location.pathname === '/account'")
check("recover: the new passkey signs in, on the account screen", recovered, status() if not recovered else "")
check("recover: it replaced every other passkey", chrome.wait_for("document.querySelectorAll('.rows__row--passkey').length === 1"))
chrome.send("Page.navigate", url=f"{BASE}{link}")
check("recover: the link works once", chrome.wait_for("document.body.textContent.includes('That link has run out')"))
chrome.evaluate("fetch('/sign-out', {method: 'POST'}).then(() => true)")
held_before = len(credentials())
# 4. A demo account has no passkey, and none can be added to it.
open_sign_in("/")
@@ -19,7 +19,7 @@ struct SiteFooterTests {
#expect(html.contains(">\(title)</h2>"))
}
for path in [String.Route.series, .Route.openShop, .Route.gradingGuide, .Route.signIn, .Route.wants, .Route.account] {
for path in [String.Route.series, .Route.openShop, .Route.gradingGuide, .Route.events, .Route.signIn, .Route.wants, .Route.collection, .Route.account, .Route.recover] {
#expect(html.contains(#"href="\#(path)""#))
}
}
@@ -81,7 +81,8 @@ struct CataloguePagesTests {
publishers: []
).render()
#expect(html.contains("No listings match"))
#expect(html.contains("Nothing for “nothing”"))
#expect(html.contains(#"href="/wants/new?title=nothing""#))
#expect(html.contains(#"href="/search?ships-from=anywhere""#))
}
@@ -96,7 +96,7 @@ struct CollectorPagesTests {
#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"))
#expect(!html.contains("No wants yet"))
}
// MARK: SeriesPage tests
@@ -0,0 +1,185 @@
import Elementary
import Foundation
import Persistence
import Testing
@testable import WebsiteLibrary
@Suite(
"Collection, events, and recovery pages",
.tags(.page)
)
struct CommunityPagesTests {
// MARK: Constants
private static let nightward = Series(slug: "nightward", name: "Nightward", publisher: "Meridian Comics", years: "2019–2021", issueCount: 4, blurb: "")
private static let con = Event(
slug: "dutch-comic-con", name: "Dutch Comic Con", venue: "Jaarbeurs, Utrecht",
startsOn: .stub.addingTimeInterval(3 * 86_400), endsOn: .stub.addingTimeInterval(4 * 86_400), shops: [.stub()]
)
// MARK: CollectionPage tests
@Test
func `collection counts what is owned, missing, and listed among the gaps`() {
let html = CollectionPage(
catalogue: [Self.nightward],
owned: ["nightward": [2, 3]],
// #1 is a gap that is listed; #4 a gap that is not.
listings: [.stub()]
).render()
#expect(html.contains("2 books · 1 series"))
#expect(html.contains(">2/4<"))
#expect(html.contains("progress--50"))
#expect(html.contains("2 missing · 1 listed now"))
#expect(html.contains(#"href="/series/nightward""#))
#expect(html.contains(#"action="/collection" method="post""#))
#expect(html.contains(#"<option value="nightward">Nightward · 4 issues"#))
}
@Test
func `collection marks a finished run, and says so when there is nothing yet`() {
let complete = CollectionPage(catalogue: [Self.nightward], owned: ["nightward": [1, 2, 3, 4]], listings: []).render()
let empty = CollectionPage(catalogue: [Self.nightward], owned: [:], listings: []).render()
#expect(complete.contains("progress--100"))
#expect(complete.contains(">Complete<"))
#expect(empty.contains("Your collection is empty"))
}
@Test(arguments: zip(
["1-6, 9, 12", "3", "5-3, x, 0, 1000", " 2 ; 2 - 4 ", "1-9999"],
[[1, 2, 3, 4, 5, 6, 9, 12], [3], [], [2, 2, 4], []]
))
func `reads the issue numbers a collector typed`(
_ text: String,
expects issues: [Int]
) {
#expect(CollectionPage.issues(in: text) == issues)
}
// MARK: SeriesPage tests
@Test
func `series shows the viewer's collection, a switch per issue, and dims what is owned`() {
let html = SeriesPage(series: Self.nightward, listings: [.stub()], owned: [1, 3], viewer: .init(displayName: "Mara")).render()
let signedOut = SeriesPage(series: Self.nightward, listings: [.stub()]).render()
#expect(html.contains("2 of 4 owned"))
#expect(html.contains("2 missing · 0 of them listed now"))
#expect(html.components(separatedBy: "series__issue--owned").count - 1 == 2)
#expect(html.contains(#"action="/series/nightward/own" method="post""#))
#expect(html.contains(#"aria-pressed="true">Owned ✓"#))
#expect(html.contains(#"aria-pressed="false">I own this"#))
#expect(!signedOut.contains("Your collection"))
#expect(!signedOut.contains("series__own"))
}
// MARK: EventsPage tests
@Test
func `events count the shops trading and the copies to pick up, and flag this weekend`() {
let later = Event(
slug: "comic-con-amsterdam", name: "Comic Con Amsterdam", venue: "RAI, Amsterdam",
startsOn: .stub.addingTimeInterval(59 * 86_400), endsOn: .stub.addingTimeInterval(60 * 86_400)
)
let html = EventsPage(
events: [Self.con, later],
listings: [.stub(), .stub(slug: "b"), .stub(slug: "c", shop: .stub(slug: "elsewhere", name: "Elsewhere"))],
now: .stub
).render()
#expect(html.contains("Sat 26 – Sun 27 Sep"))
#expect(html.contains("Sat 21 – Sun 22 Nov"))
#expect(html.components(separatedBy: "This weekend").count - 1 == 1)
#expect(html.contains("1 shop trading"))
#expect(html.contains("2 listings for pickup"))
#expect(html.contains("0 shops trading"))
#expect(html.contains(#"href="/search?pickup=dutch-comic-con&amp;ships-from=anywhere""#))
}
// MARK: Pickup and state tests
@Test
func `listing and shop say where the copy can be picked up`() {
let listing = ListingPage(listing: .stub(), related: [], pickups: [Self.con]).render()
let shop = ShopPage(shop: .stub(), inventory: [.stub()], matches: [.stub()], parameters: .init(), pickups: [Self.con]).render()
let restocking = ShopPage(shop: .stub(), inventory: [], matches: [], parameters: .init(), pickups: [Self.con]).render()
#expect(listing.contains("or pick up at Dutch Comic Con, Sat 26 – Sun 27 Sep"))
#expect(shop.contains("trading at "))
#expect(restocking.contains("Kestrel Row Comics is restocking"))
#expect(restocking.contains("the shop is trading at Dutch Comic Con"))
}
@Test
func `search offers pickup as a filter, and a pill to drop it`() {
let html = SearchPage(parameters: .init(query: "pickup=dutch-comic-con"), matches: [.stub()], publishers: []).render()
#expect(html.contains(#"name="pickup" value="dutch-comic-con" checked"#))
#expect(html.contains(#"aria-label="Remove filter: Event pickup""#))
#expect(SearchPage(parameters: .init(), matches: [], publishers: []).render().contains(#"name="pickup" value="any""#))
}
@Test
func `messages offer the ways on from an expired offer, to the buyer alone`() {
let entries = [Conversation.Entry(kind: .offer, sender: .mara, body: "", offer: .stub(status: .expired), createdAt: .stub)]
let buying = MessagesPage(conversations: [], open: .stub(entries: entries), now: .stub).render()
let selling = MessagesPage(conversations: [], open: .stub(side: .selling, entries: entries), now: .stub).render()
#expect(buying.contains("didn't answer within 48 hours") || buying.contains("didn&#39;t answer within 48 hours"))
#expect(buying.contains(">Offer again<"))
#expect(buying.contains(#"href="/listing/nightward-1/buy">Buy at €240"#))
#expect(!selling.contains("Offer again"))
}
@Test
func `seller dashboard ticks the events the shop trades at`() {
let other = Event(slug: "other", name: "Other Con", venue: "", startsOn: .stub, endsOn: .stub)
let html = SellerDashboardPage(shop: .stub(), offers: [], sales: [], listings: [], events: [Self.con, other], now: .stub).render()
#expect(html.contains(#"action="/sell/events" method="post""#))
#expect(html.contains(#"name="events" value="dutch-comic-con" checked"#))
#expect(html.contains(#"name="events" value="other">"#))
}
// MARK: RecoverPage tests
@Test
func `recovery walks from the email, to the link, to a new passkey`() {
let ask = RecoverPage(step: .ask(problem: "That doesn't look like an email address.")).render()
let sent = RecoverPage(step: .sent(link: "/recover/abc")).render()
let unknown = RecoverPage(step: .sent(link: nil)).render()
let renew = RecoverPage(step: .renew(token: "abc")).render()
let expired = RecoverPage(step: .expired).render()
#expect(ask.contains(#"action="/recover" method="post""#))
#expect(ask.contains(#"id="email-error""#))
#expect(sent.contains(#"href="/recover/abc""#))
#expect(sent.contains("Prototype: CCN sends no mail yet"))
#expect(unknown.contains("No account has that email"))
#expect(renew.contains(#"name="recover" value="abc""#))
#expect(renew.contains(#"hidden data-passkey="recover""#))
#expect(renew.contains("data-passkey-unsupported"))
#expect(renew.contains("/js/passkey.js"))
#expect(expired.contains("That link has run out"))
for html in [ask, sent, renew, expired] {
#expect(html.contains(#"content="noindex""#))
#expect(!html.contains("site-header"))
#expect(!html.contains("style="))
}
}
@Test
func `sign-in tells a browser without passkeys so, and leads to recovery`() {
let html = SignInPage(returnPath: "/").render()
#expect(html.contains("data-passkey-unsupported"))
#expect(html.contains(#"href="/recover">Lost access to your passkeys?"#))
}
}
@@ -228,7 +228,9 @@ struct TradePagesTests {
viewer: .init(displayName: "Joost", shopSlug: "kestrel-row-comics")
).render()
#expect(sold.contains("This copy is sold."))
#expect(sold.contains("This copy sold on 23 Sep."))
#expect(sold.contains("Kestrel Row Comics has 0 more copies of Nightward #1, and 0 are listed across CCN."))
#expect(sold.contains(#"href="/wants/new?title=Nightward%20%231""#))
#expect(!sold.contains("Buy now"))
#expect(own.contains("This is your listing."))
#expect(!own.contains("Make an offer"))
@@ -140,7 +140,7 @@ struct AccountAndCollectingControllerTests {
_ = 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"))
#expect(String(buffer: response.body).contains("No wants yet"))
}
}
}
@@ -266,7 +266,7 @@ private extension AccountAndCollectingControllerTests {
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(CatalogueController<WebsiteRequestContext>(repository: catalogue, sessions: sessions, collecting: collecting).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)
@@ -282,3 +282,100 @@ private extension AccountAndCollectingControllerTests {
}
}
// MARK: - Collection, events, and recovery
extension AccountAndCollectingControllerTests {
@Test
func `keeps a collection through its form and the switches on a series`() async throws {
try await withApp { client in
let mara = try await signIn(client)
_ = try await post(client, "/collection", "series=echo-country&issues=1-3%2C+9", as: mara)
_ = try await post(client, "/series/echo-country/own", "issue=5", as: mara)
_ = try await post(client, "/series/echo-country/own", "issue=1", as: mara)
try await client.execute(uri: "/collection", method: .get, headers: [.cookie: mara]) { response in
let body = String(buffer: response.body)
// #2, #3 and #5: #9 is outside the six-issue run, and #1 was switched back off.
#expect(body.contains(">3/6<"))
#expect(body.contains("3 missing · 1 listed now"))
}
try await client.execute(uri: "/series/echo-country", method: .get, headers: [.cookie: mara]) { response in
#expect(String(buffer: response.body).contains("3 of 6 owned"))
}
}
}
@Test
func `serves the events, and narrows a search to the copies to pick up at one`() async throws {
try await withApp { client in
try await client.execute(uri: "/events", method: .get) { response in
let body = String(buffer: response.body)
#expect(response.status == .ok)
#expect(body.contains("Dutch Comic Con"))
#expect(body.contains("Comic Con Brussels"))
}
// Silverfish alone trades at Stripdagen.
try await client.execute(uri: "/search?pickup=stripdagen-haarlem&ships-from=anywhere", method: .get) { response in
let body = String(buffer: response.body)
#expect(body.contains("4 listings"))
#expect(body.contains("from 1 shop"))
}
try await client.execute(uri: "/search?pickup=no-such-event", method: .get) { response in
#expect(String(buffer: response.body).contains("No listings match"))
}
try await client.execute(uri: "/listing/nightward-1-cgc-9-8", method: .get) { response in
#expect(String(buffer: response.body).contains("or pick up at Dutch Comic Con"))
}
}
}
@Test
func `hands a recovery link to an account's email alone, good until it runs out`() async throws {
try await withApp { client in
let link = try await client.execute(
uri: "/recover",
method: .post,
headers: Self.form,
body: ByteBuffer(string: "email=Mara.V%40example.com")
) { response in
let body = String(buffer: response.body)
#expect(response.status == .ok)
#expect(response.headers[.cacheControl] == "no-store")
return String(try #require(body.firstMatch(of: /\/recover\/[0-9a-f]{64}/)).output)
}
try await client.execute(uri: link, method: .get) { response in
#expect(response.status == .ok)
#expect(String(buffer: response.body).contains("Set up a new passkey"))
}
try await client.execute(uri: "/recover/0000", method: .get) { response in
#expect(response.status == .gone)
}
try await client.execute(uri: "/recover", method: .post, headers: Self.form, body: ByteBuffer(string: "email=nobody%40example.com")) { response in
let body = String(buffer: response.body)
#expect(body.contains("No account has that email"))
#expect(body.firstMatch(of: /\/recover\/[0-9a-f]{64}/) == nil)
}
try await client.execute(uri: "/recover", method: .post, headers: Self.form, body: ByteBuffer(string: "email=nope")) { response in
#expect(response.status == .unprocessableContent)
}
}
}
}
@@ -141,7 +141,11 @@ struct TradeControllerTests {
// 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."))
let body = String(buffer: response.body)
#expect(body.contains("This copy sold on "))
// Three other copies of Nightward #1 remain listed, none of them in this shop.
#expect(body.contains("has 0 more copies of Nightward #1, and 3 are listed across CCN."))
}
// A step out of turn changes nothing.
@@ -0,0 +1,55 @@
import Foundation
import Persistence
import Testing
@testable import WebsiteLibrary
@Suite(
"RecoveryLinks type",
.tags(.type)
)
struct RecoveryLinksTests {
// MARK: Methods tests
@Test
func `hands out a link that can be looked at, and used once`() async {
let links = RecoveryLinks()
let token = await links.make(for: .mara)
let peeked = await links.user(for: token)
let stillGood = await links.user(for: token)
let redeemed = await links.redeem(token)
let again = await links.redeem(token)
#expect(token.count == 64)
#expect(peeked == .mara)
#expect(stillGood == .mara)
#expect(redeemed == .mara)
#expect(again == nil)
}
@Test
func `lets a link run out after an hour, and ends an account's older link`() async {
let clock = Clock()
let links = RecoveryLinks(now: clock.now)
let older = await links.make(for: .mara)
let newer = await links.make(for: .mara)
let other = await links.make(for: .joost)
let replaced = await links.user(for: older)
clock.advance(by: 3_599)
let inTime = await links.user(for: newer)
clock.advance(by: 1)
let tooLate = await links.redeem(newer)
let othersToo = await links.user(for: other)
#expect(replaced == nil)
#expect(inTime == .mara)
#expect(tooLate == nil)
#expect(othersToo == nil)
}
}