Implemented phase 6 for the POC prototype in the Website service.
This commit is contained in:
1 parent
256d22df40
commit
f2f445827b
40 files changed
+1906
-37
No files matched your search
@@ -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&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'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"))
|
||||
|
||||
+99
-2
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user