Files
ccn/Services/Website/Tests/Library/Cases/Public/Controllers/AccountAndCollectingControllerTests.swift
T

380 lines
16 KiB
Swift

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&#39;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("No wants 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, judging events by `now`.
func withApp(
now: @escaping @Sendable () -> Date = { .init() },
_ body: @escaping @Sendable (any TestClientProtocol) async throws -> Void
) async throws {
let fluent = try Service(
driver: .inMemory,
logger: Logger(label: "test")
)()
defer {
try? await fluent.shutdown()
}
await PrepareDB()(for: fluent)
try await fluent.migrate()
let accounts = AccountRepository(fluent: fluent)
let catalogue = CatalogueRepository(fluent: fluent)
let collecting = CollectingRepository(fluent: fluent, now: now)
let sessions = SessionStore(trade: .init(fluent: fluent), collecting: collecting)
let router = Router(context: WebsiteRequestContext.self)
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)
try await Application(router: router).test(.router, body)
}
}
// 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 {
// 22 September 2026: every demo con is still to come.
try await withApp(now: { Date(timeIntervalSince1970: 1_790_071_200) }) { 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)
}
}
}
}