2026-09-21 13:32:05 +02:00
|
|
|
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
|
2026-09-21 13:53:13 +02:00
|
|
|
#expect(String(buffer: response.body).contains("No wants yet"))
|
2026-09-21 13:32:05 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@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.
|
2026-10-25 12:07:57 +01:00
|
|
|
/// - Parameters:
|
|
|
|
|
/// - now: the clock the collecting repository judges upcoming events by; the system's by default.
|
|
|
|
|
/// - body: the requests to run against the app.
|
2026-09-21 13:32:05 +02:00
|
|
|
func withApp(
|
2026-10-25 12:07:57 +01:00
|
|
|
now: @escaping @Sendable () -> Date = { .init() },
|
2026-09-21 13:32:05 +02:00
|
|
|
_ 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)
|
2026-10-25 12:07:57 +01:00
|
|
|
let collecting = CollectingRepository(fluent: fluent, now: now)
|
2026-09-21 13:32:05 +02:00
|
|
|
let sessions = SessionStore(trade: .init(fluent: fluent), collecting: collecting)
|
|
|
|
|
let router = Router(context: WebsiteRequestContext.self)
|
|
|
|
|
|
2026-09-21 13:53:13 +02:00
|
|
|
router.addRoutes(CatalogueController<WebsiteRequestContext>(repository: catalogue, sessions: sessions, collecting: collecting).routes)
|
2026-09-21 13:32:05 +02:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
2026-09-21 13:53:13 +02:00
|
|
|
|
|
|
|
|
// 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 {
|
2026-10-25 12:07:57 +01:00
|
|
|
// Noon on 22 September 2026 in Amsterdam: every demo con is still to come.
|
|
|
|
|
try await withApp(now: { Date(timeIntervalSince1970: 1_790_071_200) }) { client in
|
2026-09-21 13:53:13 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|