Added the catalogue models and seed to support phase 6 of the POC prototype in the Persistence package.
This commit is contained in:
1 parent
31d2d98d49
commit
256d22df40
10 files changed
+473
-4
No files matched your search
@@ -9,7 +9,7 @@ The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based da
|
|||||||
| Migrations | `PrepareDB`, the single registrar declaring every migration, in order |
|
| Migrations | `PrepareDB`, the single registrar declaring every migration, in order |
|
||||||
| Readiness | `Probe`, which reports whether the default database answers a `SELECT 1` within a deadline |
|
| Readiness | `Probe`, which reports whether the default database answers a `SELECT 1` within a deadline |
|
||||||
| Accounts | `AccountRepository`, which finds an account by email or creates it, returning a `User` snapshot, and keeps each account's `Passkey`s — one is only ever registered together with a new account; `CreateUser` seeds the prototype's demo accounts |
|
| Accounts | `AccountRepository`, which finds an account by email or creates it, returning a `User` snapshot, and keeps each account's `Passkey`s — one is only ever registered together with a new account; `CreateUser` seeds the prototype's demo accounts |
|
||||||
| Collecting | `CollectingRepository`, which keeps an account's `Want`s with the listings meeting each, and reads the `Series` catalogue |
|
| Collecting | `CollectingRepository`, which keeps an account's `Want`s with the listings meeting each, its collection of owned issues, the `Series` catalogue, and the upcoming `Event`s with the shops trading at each |
|
||||||
| Trade | `TradeRepository`, which reads and writes `Conversation`, `Offer`, and `Sale` snapshots and enforces who may do what, throwing a `TradeError` otherwise; `Sale` carries the five-step handshake's rules as pure functions |
|
| Trade | `TradeRepository`, which reads and writes `Conversation`, `Offer`, and `Sale` snapshots and enforces who may do what, throwing a `TradeError` otherwise; `Sale` carries the five-step handshake's rules as pure functions |
|
||||||
| Catalogue | `CatalogueRepository`, which opens a shop from a `ShopDraft`, publishes a seller's `ListingDraft` under a slug of its own, and returns `Shop` and `Listing` snapshots narrowed and ordered by a `ListingFilter`; `SeedCatalogue` fills it with the prototype's demo data |
|
| Catalogue | `CatalogueRepository`, which opens a shop from a `ShopDraft`, publishes a seller's `ListingDraft` under a slug of its own, and returns `Shop` and `Listing` snapshots narrowed and ordered by a `ListingFilter`; `SeedCatalogue` fills it with the prototype's demo data |
|
||||||
|
|
||||||
@@ -33,12 +33,12 @@ Sources/
|
|||||||
├── Public/
|
├── Public/
|
||||||
│ ├── Enumerations/ Driver, TLS, ShopError, TradeError
|
│ ├── Enumerations/ Driver, TLS, ShopError, TradeError
|
||||||
│ ├── Methods/ Service, PrepareDB, Probe
|
│ ├── Methods/ Service, PrepareDB, Probe
|
||||||
│ ├── Models/ Shop, ShopDraft, Listing, ListingDraft, ListingFilter, User, Passkey, Want, Series, Conversation, Offer, Sale
|
│ ├── Models/ Shop, ShopDraft, Listing, ListingDraft, ListingFilter, User, Passkey, Want, Series, Event, Conversation, Offer, Sale
|
||||||
│ ├── Repositories/ CatalogueRepository, AccountRepository, CollectingRepository, TradeRepository
|
│ ├── Repositories/ CatalogueRepository, AccountRepository, CollectingRepository, TradeRepository
|
||||||
│ └── Types/ Configuration
|
│ └── Types/ Configuration
|
||||||
└── Internal/
|
└── Internal/
|
||||||
├── Migrations/ CreateShop, CreateListing, AddTradeColumns, AddListingDeclarations, AddShopRegistration, SeedCatalogue, CreateUser, CreateTrade, CreatePasskey, CreateCollecting
|
├── Migrations/ CreateShop, CreateListing, AddTradeColumns, AddListingDeclarations, AddShopRegistration, SeedCatalogue, CreateUser, CreateTrade, CreatePasskey, CreateCollecting, CreateCommunity
|
||||||
├── Models/ ShopRecord, ListingRecord, UserRecord, PasskeyRecord, WantRecord, SeriesRecord, ConversationRecord, MessageRecord, OfferRecord, SaleRecord
|
├── Models/ ShopRecord, ListingRecord, UserRecord, PasskeyRecord, WantRecord, SeriesRecord, EventRecord, EventShopRecord, OwnedIssueRecord, ConversationRecord, MessageRecord, OfferRecord, SaleRecord
|
||||||
└── Seeds/ Seed
|
└── Seeds/ Seed
|
||||||
Tests/
|
Tests/
|
||||||
├── Cases/ the test suites, mirroring the Sources/ layout
|
├── Cases/ the test suites, mirroring the Sources/ layout
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import FluentKit
|
||||||
|
|
||||||
|
/// Creates the `events`, `event_shops`, and `owned_issues` tables, seeded with the demo cons and the shops trading at them.
|
||||||
|
struct CreateCommunity: AsyncMigration {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
func prepare(on database: Database) async throws {
|
||||||
|
try await database.schema(EventRecord.schema)
|
||||||
|
.id()
|
||||||
|
.field("slug", .string, .required)
|
||||||
|
.field("name", .string, .required)
|
||||||
|
.field("venue", .string, .required)
|
||||||
|
.field("starts_on", .datetime, .required)
|
||||||
|
.field("ends_on", .datetime, .required)
|
||||||
|
.unique(on: "slug")
|
||||||
|
.create()
|
||||||
|
try await database.schema(EventShopRecord.schema)
|
||||||
|
.id()
|
||||||
|
.field("event_id", .uuid, .required, .references(EventRecord.schema, "id", onDelete: .cascade))
|
||||||
|
.field("shop_id", .uuid, .required, .references(ShopRecord.schema, "id", onDelete: .cascade))
|
||||||
|
.unique(on: "event_id", "shop_id")
|
||||||
|
.create()
|
||||||
|
try await database.schema(OwnedIssueRecord.schema)
|
||||||
|
.id()
|
||||||
|
.field("user_id", .uuid, .required, .references(UserRecord.schema, "id", onDelete: .cascade))
|
||||||
|
.field("series_slug", .string, .required)
|
||||||
|
.field("issue", .int, .required)
|
||||||
|
.unique(on: "user_id", "series_slug", "issue")
|
||||||
|
.create()
|
||||||
|
|
||||||
|
for (event, shops) in Seed.events {
|
||||||
|
let record = EventRecord(event)
|
||||||
|
|
||||||
|
try await record.save(on: database)
|
||||||
|
|
||||||
|
for shop in try await ShopRecord.query(on: database).filter(\.$slug ~~ shops).all() {
|
||||||
|
try await record.$shops.attach(shop, on: database)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func revert(on database: Database) async throws {
|
||||||
|
for schema in [OwnedIssueRecord.schema, EventShopRecord.schema, EventRecord.schema] {
|
||||||
|
try await database.schema(schema).delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import FluentKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A FluentKit model of a single `events` row: a con where shops trade in person.
|
||||||
|
final class EventRecord: Model, @unchecked Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
static let schema = "events"
|
||||||
|
|
||||||
|
@ID(key: .id)
|
||||||
|
var id: UUID?
|
||||||
|
|
||||||
|
@Field(key: "slug")
|
||||||
|
var slug: String
|
||||||
|
|
||||||
|
@Field(key: "name")
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
@Field(key: "venue")
|
||||||
|
var venue: String
|
||||||
|
|
||||||
|
@Field(key: "starts_on")
|
||||||
|
var startsOn: Date
|
||||||
|
|
||||||
|
@Field(key: "ends_on")
|
||||||
|
var endsOn: Date
|
||||||
|
|
||||||
|
@Siblings(through: EventShopRecord.self, from: \.$event, to: \.$shop)
|
||||||
|
var shops: [ShopRecord]
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
init(
|
||||||
|
_ event: Event
|
||||||
|
) {
|
||||||
|
self.slug = event.slug
|
||||||
|
self.name = event.name
|
||||||
|
self.venue = event.venue
|
||||||
|
self.startsOn = event.startsOn
|
||||||
|
self.endsOn = event.endsOn
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A FluentKit model of a single `event_shops` row: a shop trading at an event.
|
||||||
|
final class EventShopRecord: Model, @unchecked Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
static let schema = "event_shops"
|
||||||
|
|
||||||
|
@ID(key: .id)
|
||||||
|
var id: UUID?
|
||||||
|
|
||||||
|
@Parent(key: "event_id")
|
||||||
|
var event: EventRecord
|
||||||
|
|
||||||
|
@Parent(key: "shop_id")
|
||||||
|
var shop: ShopRecord
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A FluentKit model of a single `owned_issues` row: an issue of a series in an account's collection.
|
||||||
|
final class OwnedIssueRecord: Model, @unchecked Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
static let schema = "owned_issues"
|
||||||
|
|
||||||
|
@ID(key: .id)
|
||||||
|
var id: UUID?
|
||||||
|
|
||||||
|
@Parent(key: "user_id")
|
||||||
|
var user: UserRecord
|
||||||
|
|
||||||
|
@Field(key: "series_slug")
|
||||||
|
var seriesSlug: String
|
||||||
|
|
||||||
|
@Field(key: "issue")
|
||||||
|
var issue: Int
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
init(
|
||||||
|
userID: UUID,
|
||||||
|
seriesSlug: String,
|
||||||
|
issue: Int
|
||||||
|
) {
|
||||||
|
self.$user.id = userID
|
||||||
|
self.seriesSlug = seriesSlug
|
||||||
|
self.issue = issue
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -158,6 +158,31 @@ enum Seed {
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/// The demo cons, soonest first, each with the slugs of the shops trading there.
|
||||||
|
static let events: [(event: Event, shops: [String])] = [
|
||||||
|
(.init(slug: "dutch-comic-con", name: "Dutch Comic Con", venue: "Jaarbeurs, Utrecht", startsOn: day(2026, 9, 26), endsOn: day(2026, 9, 27)),
|
||||||
|
["kestrel-row-comics", "silverfish-comics", "third-panel-supply"]),
|
||||||
|
(.init(slug: "stripdagen-haarlem", name: "Stripdagen Haarlem", venue: "Grote Markt, Haarlem", startsOn: day(2026, 10, 3), endsOn: day(2026, 10, 4)),
|
||||||
|
["silverfish-comics"]),
|
||||||
|
(.init(slug: "comic-con-amsterdam", name: "Comic Con Amsterdam", venue: "RAI, Amsterdam", startsOn: day(2026, 11, 21), endsOn: day(2026, 11, 22)),
|
||||||
|
["kestrel-row-comics", "inkwell-and-staple"]),
|
||||||
|
(.init(slug: "comic-con-brussels", name: "Comic Con Brussels", venue: "Tour & Taxis, Brussels", startsOn: day(2026, 12, 5), endsOn: day(2026, 12, 6)),
|
||||||
|
["third-panel-supply"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Noon on a day in Amsterdam, where the demo cons are dated.
|
||||||
|
static func day(
|
||||||
|
_ year: Int,
|
||||||
|
_ month: Int,
|
||||||
|
_ day: Int
|
||||||
|
) -> Date {
|
||||||
|
var calendar = Calendar(identifier: .gregorian)
|
||||||
|
|
||||||
|
calendar.timeZone = TimeZone(identifier: "Europe/Amsterdam") ?? .gmt
|
||||||
|
|
||||||
|
return calendar.date(from: .init(year: year, month: month, day: day, hour: 12)) ?? .distantPast
|
||||||
|
}
|
||||||
|
|
||||||
/// The runs the demo listings belong to.
|
/// The runs the demo listings belong to.
|
||||||
static let series: [Series] = [
|
static let series: [Series] = [
|
||||||
.init(slug: "nightward", name: "Nightward", publisher: "Meridian Comics", years: "2019–2021", issueCount: 24,
|
.init(slug: "nightward", name: "Nightward", publisher: "Meridian Comics", years: "2019–2021", issueCount: 24,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public struct PrepareDB: Sendable {
|
|||||||
CreateTrade(),
|
CreateTrade(),
|
||||||
CreatePasskey(),
|
CreatePasskey(),
|
||||||
CreateCollecting(),
|
CreateCollecting(),
|
||||||
|
CreateCommunity(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A `Sendable` snapshot of a con where shops trade in person, so a sale agreed on the site can be collected there.
|
||||||
|
public struct Event: Sendable, Equatable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The event's URL slug.
|
||||||
|
public let slug: String
|
||||||
|
/// The event's name.
|
||||||
|
public let name: String
|
||||||
|
/// Where it is held, e.g. `Jaarbeurs, Utrecht`.
|
||||||
|
public let venue: String
|
||||||
|
/// The first day.
|
||||||
|
public let startsOn: Date
|
||||||
|
/// The last day.
|
||||||
|
public let endsOn: Date
|
||||||
|
/// The shops trading there.
|
||||||
|
public let shops: [Shop]
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
public init(
|
||||||
|
slug: String,
|
||||||
|
name: String,
|
||||||
|
venue: String,
|
||||||
|
startsOn: Date,
|
||||||
|
endsOn: Date,
|
||||||
|
shops: [Shop] = []
|
||||||
|
) {
|
||||||
|
self.slug = slug
|
||||||
|
self.name = name
|
||||||
|
self.venue = venue
|
||||||
|
self.startsOn = startsOn
|
||||||
|
self.endsOn = endsOn
|
||||||
|
self.shops = shops
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ public struct ListingFilter: Sendable, Equatable {
|
|||||||
public var verifiedShops: Bool
|
public var verifiedShops: Bool
|
||||||
/// The slug of the one shop to match, or `nil` for all shops.
|
/// The slug of the one shop to match, or `nil` for all shops.
|
||||||
public var shop: String?
|
public var shop: String?
|
||||||
|
/// The slugs of the shops to match — those trading at an event, say — or `nil` for all shops.
|
||||||
|
public var shops: [String]?
|
||||||
/// The order of the results.
|
/// The order of the results.
|
||||||
public var sort: Sort
|
public var sort: Sort
|
||||||
|
|
||||||
@@ -51,6 +53,7 @@ public struct ListingFilter: Sendable, Equatable {
|
|||||||
acceptsOffers: Bool = false,
|
acceptsOffers: Bool = false,
|
||||||
verifiedShops: Bool = false,
|
verifiedShops: Bool = false,
|
||||||
shop: String? = nil,
|
shop: String? = nil,
|
||||||
|
shops: [String]? = nil,
|
||||||
sort: Sort = .newest
|
sort: Sort = .newest
|
||||||
) {
|
) {
|
||||||
self.query = query
|
self.query = query
|
||||||
@@ -67,6 +70,7 @@ public struct ListingFilter: Sendable, Equatable {
|
|||||||
self.acceptsOffers = acceptsOffers
|
self.acceptsOffers = acceptsOffers
|
||||||
self.verifiedShops = verifiedShops
|
self.verifiedShops = verifiedShops
|
||||||
self.shop = shop
|
self.shop = shop
|
||||||
|
self.shops = shops
|
||||||
self.sort = sort
|
self.sort = sort
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +121,10 @@ extension ListingFilter {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let shops, !shops.contains(listing.shop.slug) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if !formats.isEmpty, !formats.contains(listing.format) {
|
if !formats.isEmpty, !formats.contains(listing.format) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,27 @@ public struct AccountRepository: Sendable {
|
|||||||
).save(on: fluent.db())
|
).save(on: fluent.db())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replaces every passkey of an account with a new one: what recovering an account comes down to.
|
||||||
|
public func replacePasskeys(
|
||||||
|
of user: User,
|
||||||
|
credentialID: String,
|
||||||
|
publicKey: [UInt8],
|
||||||
|
signCount: UInt32
|
||||||
|
) async throws {
|
||||||
|
try await fluent.db().transaction { database in
|
||||||
|
try await PasskeyRecord.query(on: database)
|
||||||
|
.filter(\.$user.$id == user.id)
|
||||||
|
.delete()
|
||||||
|
try await PasskeyRecord(
|
||||||
|
userID: user.id,
|
||||||
|
credentialID: credentialID,
|
||||||
|
publicKey: publicKey,
|
||||||
|
signCount: signCount,
|
||||||
|
at: .init()
|
||||||
|
).save(on: database)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetches the passkeys registered to an account, the oldest first, with when each was created.
|
/// Fetches the passkeys registered to an account, the oldest first, with when each was created.
|
||||||
public func passkeys(
|
public func passkeys(
|
||||||
of user: User
|
of user: User
|
||||||
|
|||||||
@@ -192,3 +192,105 @@ public extension CollectingRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Collection
|
||||||
|
|
||||||
|
public extension CollectingRepository {
|
||||||
|
|
||||||
|
/// Adds issues of a series to an account's collection; the ones already in it, or outside the run, are skipped.
|
||||||
|
func own(
|
||||||
|
issues: [Int],
|
||||||
|
ofSeries slug: String,
|
||||||
|
by user: User
|
||||||
|
) async throws {
|
||||||
|
guard let series = try await series(slug: slug) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let owned = try await ownedIssues(of: user)[slug] ?? []
|
||||||
|
let run = series.issueCount > 0 ? 1...series.issueCount : 1...999
|
||||||
|
|
||||||
|
for issue in Set(issues).subtracting(owned) where run.contains(issue) {
|
||||||
|
try await OwnedIssueRecord(userID: user.id, seriesSlug: slug, issue: issue).save(on: fluent.db())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches the issues an account owns, by series slug.
|
||||||
|
func ownedIssues(
|
||||||
|
of user: User
|
||||||
|
) async throws -> [String: Set<Int>] {
|
||||||
|
try await OwnedIssueRecord.query(on: fluent.db())
|
||||||
|
.filter(\.$user.$id == user.id)
|
||||||
|
.all()
|
||||||
|
.reduce(into: [:]) { $0[$1.seriesSlug, default: []].insert($1.issue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes an issue out of an account's collection.
|
||||||
|
func disown(
|
||||||
|
issue: Int,
|
||||||
|
ofSeries slug: String,
|
||||||
|
by user: User
|
||||||
|
) async throws {
|
||||||
|
try await OwnedIssueRecord.query(on: fluent.db())
|
||||||
|
.filter(\.$user.$id == user.id)
|
||||||
|
.filter(\.$seriesSlug == slug)
|
||||||
|
.filter(\.$issue == issue)
|
||||||
|
.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Events
|
||||||
|
|
||||||
|
public extension CollectingRepository {
|
||||||
|
|
||||||
|
/// Fetches the events still to come or under way, soonest first, each with the shops trading there.
|
||||||
|
func upcomingEvents() async throws -> [Event] {
|
||||||
|
try await EventRecord.query(on: fluent.db())
|
||||||
|
.filter(\.$endsOn >= now().addingTimeInterval(-86_400))
|
||||||
|
.sort(\.$startsOn)
|
||||||
|
.with(\.$shops)
|
||||||
|
.all()
|
||||||
|
.map { record in
|
||||||
|
.init(
|
||||||
|
slug: record.slug,
|
||||||
|
name: record.name,
|
||||||
|
venue: record.venue,
|
||||||
|
startsOn: record.startsOn,
|
||||||
|
endsOn: record.endsOn,
|
||||||
|
shops: record.shops.map(\.snapshot)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the upcoming events the shop an account owns trades at.
|
||||||
|
func setEvents(
|
||||||
|
_ slugs: [String],
|
||||||
|
forShopOwnedBy user: User
|
||||||
|
) async throws {
|
||||||
|
try await fluent.db().transaction { database in
|
||||||
|
guard let shop = try await ShopRecord.query(on: database)
|
||||||
|
.filter(\.$owner.$id == user.id)
|
||||||
|
.first()
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let upcoming = try await EventRecord.query(on: database)
|
||||||
|
.filter(\.$endsOn >= now().addingTimeInterval(-86_400))
|
||||||
|
.with(\.$shops)
|
||||||
|
.all()
|
||||||
|
|
||||||
|
for event in upcoming {
|
||||||
|
let trades = try event.shops.contains { try $0.requireID() == shop.requireID() }
|
||||||
|
|
||||||
|
if slugs.contains(event.slug), !trades {
|
||||||
|
try await event.$shops.attach(shop, on: database)
|
||||||
|
} else if !slugs.contains(event.slug), trades {
|
||||||
|
try await event.$shops.detach(shop, on: database)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Persistence
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"CollectingRepository repository, collection and events",
|
||||||
|
.tags(.repository)
|
||||||
|
)
|
||||||
|
struct CommunityRepositoryTests {
|
||||||
|
|
||||||
|
// MARK: Collection tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `keeps a collection per account, within a series' run`() async throws {
|
||||||
|
try await withDatabase { fluent in
|
||||||
|
let collecting = CollectingRepository(fluent: fluent)
|
||||||
|
let accounts = AccountRepository(fluent: fluent)
|
||||||
|
let mara = try await accounts.account(email: "mara.v@example.com")
|
||||||
|
let sam = try await accounts.account(email: "sam@example.com")
|
||||||
|
|
||||||
|
// Echo Country runs six issues: #9 is outside it, and #2 is given twice.
|
||||||
|
try await collecting.own(issues: [1, 2, 2, 9, 0], ofSeries: "echo-country", by: mara)
|
||||||
|
try await collecting.own(issues: [2, 3], ofSeries: "echo-country", by: mara)
|
||||||
|
try await collecting.own(issues: [1], ofSeries: "no-such-series", by: mara)
|
||||||
|
try await collecting.disown(issue: 1, ofSeries: "echo-country", by: sam)
|
||||||
|
|
||||||
|
let owned = try await collecting.ownedIssues(of: mara)
|
||||||
|
let others = try await collecting.ownedIssues(of: sam)
|
||||||
|
|
||||||
|
try await collecting.disown(issue: 1, ofSeries: "echo-country", by: mara)
|
||||||
|
|
||||||
|
let after = try await collecting.ownedIssues(of: mara)
|
||||||
|
|
||||||
|
#expect(owned == ["echo-country": [1, 2, 3]])
|
||||||
|
#expect(others.isEmpty)
|
||||||
|
#expect(after == ["echo-country": [2, 3]])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Events tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `lists the events still to come, soonest first, with the shops trading there`() async throws {
|
||||||
|
try await withDatabase { fluent in
|
||||||
|
let clock = Clock()
|
||||||
|
let collecting = CollectingRepository(fluent: fluent, now: clock.now)
|
||||||
|
|
||||||
|
// 22 September 2026: every demo con is still to come.
|
||||||
|
clock.advance(by: Seed.day(2026, 9, 22).timeIntervalSince(clock.now()))
|
||||||
|
|
||||||
|
let all = try await collecting.upcomingEvents()
|
||||||
|
|
||||||
|
// Sunday 27 September, the con's last day: still on. Two weeks later it is over, and so is Stripdagen.
|
||||||
|
clock.advance(by: 5 * 86_400)
|
||||||
|
|
||||||
|
let lastDay = try await collecting.upcomingEvents()
|
||||||
|
|
||||||
|
clock.advance(by: 14 * 86_400)
|
||||||
|
|
||||||
|
let later = try await collecting.upcomingEvents()
|
||||||
|
|
||||||
|
#expect(all.map(\.slug) == ["dutch-comic-con", "stripdagen-haarlem", "comic-con-amsterdam", "comic-con-brussels"])
|
||||||
|
#expect(Set(all[0].shops.map(\.slug)) == ["kestrel-row-comics", "silverfish-comics", "third-panel-supply"])
|
||||||
|
#expect(lastDay.first?.slug == "dutch-comic-con")
|
||||||
|
#expect(later.first?.slug == "comic-con-amsterdam")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `lets a seller pick the events their shop trades at`() async throws {
|
||||||
|
try await withDatabase { fluent in
|
||||||
|
let clock = Clock()
|
||||||
|
let collecting = CollectingRepository(fluent: fluent, now: clock.now)
|
||||||
|
let accounts = AccountRepository(fluent: fluent)
|
||||||
|
let joost = try await accounts.account(email: "joost@kestrelrow.example")
|
||||||
|
let mara = try await accounts.account(email: "mara.v@example.com")
|
||||||
|
|
||||||
|
clock.advance(by: Seed.day(2026, 9, 22).timeIntervalSince(clock.now()))
|
||||||
|
|
||||||
|
try await collecting.setEvents(["stripdagen-haarlem", "comic-con-amsterdam", "no-such-event"], forShopOwnedBy: joost)
|
||||||
|
// An account without a shop changes nothing.
|
||||||
|
try await collecting.setEvents(["dutch-comic-con"], forShopOwnedBy: mara)
|
||||||
|
|
||||||
|
let trading = try await collecting.upcomingEvents()
|
||||||
|
.filter { $0.shops.contains { $0.slug == "kestrel-row-comics" } }
|
||||||
|
.map(\.slug)
|
||||||
|
|
||||||
|
#expect(trading == ["stripdagen-haarlem", "comic-con-amsterdam"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `narrows a search to the shops trading at an event`() {
|
||||||
|
let filter = ListingFilter(shops: ["silverfish-comics"])
|
||||||
|
|
||||||
|
#expect(Seed.listings.filter(filter.matches).map(\.shop.slug).allSatisfy { $0 == "silverfish-comics" })
|
||||||
|
#expect(Seed.listings.filter(filter.matches).count == 4)
|
||||||
|
#expect(Seed.listings.filter(ListingFilter(shops: []).matches).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Recovery tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `replaces every passkey of an account with the one it recovers with`() async throws {
|
||||||
|
try await withDatabase { fluent in
|
||||||
|
let accounts = AccountRepository(fluent: fluent)
|
||||||
|
let sam = try #require(try await accounts.join(email: "sam@example.com", credentialID: "old-1", publicKey: [1], signCount: 3))
|
||||||
|
|
||||||
|
try await accounts.addPasskey(to: sam, credentialID: "old-2", publicKey: [2], signCount: 0)
|
||||||
|
try await accounts.replacePasskeys(of: sam, credentialID: "new", publicKey: [9], signCount: 0)
|
||||||
|
|
||||||
|
let passkeys = try await accounts.passkeys(of: sam)
|
||||||
|
let lost = try await accounts.passkey(credentialID: "old-1")
|
||||||
|
|
||||||
|
#expect(passkeys.map(\.passkey.credentialID) == ["new"])
|
||||||
|
#expect(lost == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in new issue
Block a user