Implemented phase 4 for the POC prototype in the Website service.
This commit is contained in:
1 parent
95e0a455e8
commit
9403931109
42 files changed
+3407
-36
No files matched your search
+239
@@ -0,0 +1,239 @@
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Logging
|
||||
import NIOCore
|
||||
import Persistence
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"ListingFormController controller",
|
||||
.tags(.controller)
|
||||
)
|
||||
struct ListingFormControllerTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
private let seller = "joost@kestrelrow.example"
|
||||
|
||||
/// The fields of a valid raw Nightward #3.
|
||||
private let fields = [
|
||||
("format", "single-issues"), ("series", "Nightward"), ("issue", "3"), ("publisher", "Meridian Comics"),
|
||||
("year", "2019"), ("printing", "1"), ("grading", "raw"), ("grade", "9.4"), ("defects", "spine-stress"),
|
||||
("notes", "Two light ticks."), ("price", "30"), ("shipping", "5"), ("offers", "1"),
|
||||
]
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `keeps the form to a shop's owner`() async throws {
|
||||
try await withApp { client, _ in
|
||||
try await client.execute(uri: "/sell/new", method: .get) { response in
|
||||
#expect(response.status == .found)
|
||||
#expect(response.headers[.location] == "/sign-in?return=/sell/new")
|
||||
}
|
||||
|
||||
let mara = try await signIn(client)
|
||||
|
||||
try await client.execute(uri: "/sell/new", method: .get, headers: [.cookie: mara]) { response in
|
||||
#expect(response.status == .forbidden)
|
||||
}
|
||||
|
||||
let joost = try await signIn(client, email: seller)
|
||||
|
||||
try await client.execute(uri: "/sell/new", method: .get, headers: [.cookie: joost]) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(String(buffer: response.body).contains("Kestrel Row Comics · seller tools"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `publishes a listing with its photos, into the shop and the search`() async throws {
|
||||
try await withApp { client, folder in
|
||||
let joost = try await signIn(client, email: seller)
|
||||
let location = try await post(client, fields, photos: [PhotoStoreTests.jpeg, PhotoStoreTests.png], as: joost) { response in
|
||||
#expect(response.status == .seeOther)
|
||||
|
||||
return try #require(response.headers[.location])
|
||||
}
|
||||
|
||||
#expect(location == "/listing/nightward-3-raw-nm")
|
||||
|
||||
try await client.execute(uri: location, method: .get) { response in
|
||||
let body = String(buffer: response.body)
|
||||
let photos = body.matches(of: /\/uploads\/[0-9a-f-]+\.(jpg|png)/).map { String($0.output.0) }
|
||||
|
||||
#expect(body.contains("Nightward #3"))
|
||||
#expect(body.contains("<dt>Declared defects</dt><dd>Spine stress / ticks</dd>"))
|
||||
#expect(Set(photos).count == 2)
|
||||
|
||||
for photo in photos {
|
||||
#expect(FileManager.default.fileExists(atPath: folder + photo))
|
||||
}
|
||||
}
|
||||
|
||||
try await client.execute(uri: "/search?q=nightward+3", method: .get) { response in
|
||||
#expect(String(buffer: response.body).contains(#"href="/listing/nightward-3-raw-nm""#))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `gives the form back with a 422, storing no photo, when a field is wrong`() async throws {
|
||||
try await withApp { client, folder in
|
||||
let joost = try await signIn(client, email: seller)
|
||||
let wrong = fields.map { $0.0 == "grade" ? ("grade", "9.4") : $0 } + [("defects", "crease")]
|
||||
|
||||
try await post(client, wrong, photos: [PhotoStoreTests.jpeg], as: joost) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .unprocessableContent)
|
||||
#expect(body.contains("caps a raw book at 7.0"))
|
||||
#expect(body.contains(#"name="series" value="Nightward""#))
|
||||
}
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: folder + "/uploads"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `turns down a file that is not an image, publishing nothing`() async throws {
|
||||
try await withApp { client, _ in
|
||||
let joost = try await signIn(client, email: seller)
|
||||
|
||||
try await post(client, fields, photos: [ByteBuffer(string: "<script>alert(1)</script>")], as: joost) { response in
|
||||
#expect(response.status == .unprocessableContent)
|
||||
#expect(String(buffer: response.body).contains("Photos have to be JPEG, PNG or WebP images."))
|
||||
}
|
||||
|
||||
try await client.execute(uri: "/listing/nightward-3-raw-nm", method: .get) { response in
|
||||
#expect(response.status == .notFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `takes no listing from a signed-out visitor, a buyer, or a body that is not multipart`() async throws {
|
||||
try await withApp { client, _ in
|
||||
try await post(client, fields, photos: [], as: nil) { response in
|
||||
#expect(response.status == .seeOther)
|
||||
#expect(response.headers[.location] == "/sign-in?return=/sell/new")
|
||||
}
|
||||
|
||||
let mara = try await signIn(client)
|
||||
|
||||
try await post(client, fields, photos: [], as: mara) { response in
|
||||
#expect(response.status == .forbidden)
|
||||
}
|
||||
|
||||
let joost = try await signIn(client, email: seller)
|
||||
|
||||
try await client.execute(
|
||||
uri: "/sell/new",
|
||||
method: .post,
|
||||
headers: [.cookie: joost, .contentType: "application/x-www-form-urlencoded"],
|
||||
body: ByteBuffer(string: "series=Nightward")
|
||||
) { response in
|
||||
#expect(response.status == .badRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension ListingFormControllerTests {
|
||||
|
||||
/// Posts the form as multipart — the fields, then the photos — as an account, or signed out.
|
||||
func post<T>(
|
||||
_ client: any TestClientProtocol,
|
||||
_ fields: [(String, String)],
|
||||
photos: [ByteBuffer],
|
||||
as cookie: String?,
|
||||
_ check: @escaping @Sendable (TestResponse) throws -> T
|
||||
) async throws -> T {
|
||||
var body = ByteBuffer()
|
||||
|
||||
for (name, value) in fields {
|
||||
body.writeString("--bnd\r\nContent-Disposition: form-data; name=\"\(name)\"\r\n\r\n\(value)\r\n")
|
||||
}
|
||||
|
||||
for (index, photo) in photos.enumerated() {
|
||||
var photo = photo
|
||||
|
||||
body.writeString("--bnd\r\nContent-Disposition: form-data; name=\"photos\"; filename=\"p\(index)\"\r\nContent-Type: image/jpeg\r\n\r\n")
|
||||
body.writeBuffer(&photo)
|
||||
body.writeString("\r\n")
|
||||
}
|
||||
|
||||
body.writeString("--bnd--\r\n")
|
||||
|
||||
var headers: HTTPFields = [.contentType: "multipart/form-data; boundary=bnd"]
|
||||
|
||||
headers[.cookie] = cookie
|
||||
|
||||
return try await client.execute(uri: "/sell/new", method: .post, headers: headers, body: body, testCallback: check)
|
||||
}
|
||||
|
||||
/// 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: [.contentType: "application/x-www-form-urlencoded"],
|
||||
body: ByteBuffer(string: "email=\(email)&return=/")
|
||||
) { response in
|
||||
String(try #require(response.headers[.setCookie]).prefix { $0 != ";" })
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the body against the session, catalogue, and listing form routes, over a seeded in-memory database and a temporary photo folder.
|
||||
func withApp(
|
||||
_ body: @escaping @Sendable (any TestClientProtocol, String) async throws -> Void
|
||||
) async throws {
|
||||
try await PhotoStoreTests.withFolder { folder in
|
||||
let fluent = try Service(
|
||||
driver: .inMemory,
|
||||
logger: Logger(label: "test")
|
||||
)()
|
||||
|
||||
do {
|
||||
await PrepareDB()(for: fluent)
|
||||
|
||||
try await fluent.migrate()
|
||||
|
||||
let catalogue = CatalogueRepository(fluent: fluent)
|
||||
let trade = TradeRepository(fluent: fluent)
|
||||
let sessions = SessionStore(trade: trade)
|
||||
let router = Router(context: WebsiteRequestContext.self)
|
||||
|
||||
router.addRoutes(CatalogueController<WebsiteRequestContext>(repository: catalogue, sessions: sessions).routes)
|
||||
router.addRoutes(SessionController<WebsiteRequestContext>(sessions: sessions, accounts: .init(fluent: fluent), catalogue: catalogue).routes)
|
||||
router.addRoutes(ListingFormController<WebsiteRequestContext>(
|
||||
catalogue: catalogue,
|
||||
trade: trade,
|
||||
sessions: sessions,
|
||||
photos: .init(staticFilesPath: folder)
|
||||
).routes)
|
||||
|
||||
try await Application(router: router).test(.router) { client in
|
||||
try await body(client, folder)
|
||||
}
|
||||
} catch {
|
||||
try? await fluent.shutdown()
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
try await fluent.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Logging
|
||||
import NIOCore
|
||||
import Persistence
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
/// The ceremonies' signatures need a WebAuthn client: `Tests/Browser/passkeys.py` runs them in Chrome. These cover what does not.
|
||||
@Suite(
|
||||
"PasskeyController controller",
|
||||
.tags(.controller)
|
||||
)
|
||||
struct PasskeyControllerTests {
|
||||
|
||||
// MARK: Options tests
|
||||
|
||||
@Test
|
||||
func `offers whichever passkey the browser holds when given no email`() async throws {
|
||||
try await withApp { client in
|
||||
try await client.execute(uri: "/sign-in/passkey/options", method: .post, headers: Self.json, body: ByteBuffer(string: "{}")) { response in
|
||||
let body = try Self.object(response)
|
||||
let options = try #require(body["options"] as? [String: Any])
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.cacheControl] == "no-store")
|
||||
#expect(body["mode"] as? String == "get")
|
||||
#expect(options["rpId"] as? String == "localhost")
|
||||
#expect((options["challenge"] as? String)?.isEmpty == false)
|
||||
#expect(options["allowCredentials"] == nil)
|
||||
#expect(response.headers[.setCookie]?.hasPrefix("ccn_ceremony=") == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `registers a passkey for an unknown email, normalized`() async throws {
|
||||
try await withApp { client in
|
||||
try await client.execute(
|
||||
uri: "/sign-in/passkey/options",
|
||||
method: .post,
|
||||
headers: Self.json,
|
||||
body: ByteBuffer(string: #"{"email": " Sam@Example.COM "}"#)
|
||||
) { response in
|
||||
let body = try Self.object(response)
|
||||
let options = try #require(body["options"] as? [String: Any])
|
||||
let user = try #require(options["user"] as? [String: Any])
|
||||
let party = try #require(options["rp"] as? [String: Any])
|
||||
|
||||
#expect(body["mode"] as? String == "create")
|
||||
#expect(user["name"] as? String == "sam@example.com")
|
||||
#expect(user["displayName"] as? String == "sam")
|
||||
#expect(party["id"] as? String == "localhost")
|
||||
#expect(party["name"] as? String == "CCN")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `issues a fresh challenge every time`() async throws {
|
||||
try await withApp { client in
|
||||
var challenges = Set<String>()
|
||||
|
||||
for _ in 1...3 {
|
||||
let challenge = try await client.execute(uri: "/sign-in/passkey/options", method: .post, headers: Self.json, body: ByteBuffer(string: "{}")) { response in
|
||||
try #require((try Self.object(response)["options"] as? [String: Any])?["challenge"] as? String)
|
||||
}
|
||||
|
||||
challenges.insert(challenge)
|
||||
}
|
||||
|
||||
#expect(challenges.count == 3)
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: zip(
|
||||
[#"{"email": "nope"}"#, #"{"email": "mara.v@example.com"}"#],
|
||||
[HTTPResponse.Status.unprocessableContent, .conflict]
|
||||
))
|
||||
func `starts no ceremony for what is not an email, or for an account without a passkey`(
|
||||
body: String,
|
||||
expects status: HTTPResponse.Status
|
||||
) async throws {
|
||||
try await withApp { client in
|
||||
try await client.execute(uri: "/sign-in/passkey/options", method: .post, headers: Self.json, body: ByteBuffer(string: body)) { response in
|
||||
let error = try Self.object(response)["error"] as? String
|
||||
|
||||
#expect(response.status == status)
|
||||
#expect(response.headers[.setCookie] == nil)
|
||||
#expect(error?.isEmpty == false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Finish tests
|
||||
|
||||
@Test
|
||||
func `signs nobody in without a ceremony, or on an answer it cannot read — and hears a ceremony out once`() async throws {
|
||||
try await withApp { client in
|
||||
try await client.execute(uri: "/sign-in/passkey/finish", method: .post, headers: Self.json, body: ByteBuffer(string: "{}")) { response in
|
||||
#expect(response.status == .badRequest)
|
||||
#expect(response.headers[.setCookie] == nil)
|
||||
}
|
||||
|
||||
let cookie = try await client.execute(uri: "/sign-in/passkey/options", method: .post, headers: Self.json, body: ByteBuffer(string: "{}")) { response in
|
||||
String(try #require(response.headers[.setCookie]).prefix { $0 != ";" })
|
||||
}
|
||||
var headers = Self.json
|
||||
|
||||
headers[.cookie] = cookie
|
||||
|
||||
try await client.execute(
|
||||
uri: "/sign-in/passkey/finish",
|
||||
method: .post,
|
||||
headers: headers,
|
||||
body: ByteBuffer(string: #"{"credential": {"id": "x"}}"#)
|
||||
) { response in
|
||||
let error = try Self.object(response)["error"] as? String
|
||||
|
||||
#expect(response.status == .badRequest)
|
||||
#expect(error == "That answer couldn't be read.")
|
||||
#expect(response.headers[values: .setCookie].allSatisfy { !$0.hasPrefix("ccn_session=") })
|
||||
}
|
||||
|
||||
try await client.execute(uri: "/sign-in/passkey/finish", method: .post, headers: headers, body: ByteBuffer(string: "{}")) { response in
|
||||
let error = try Self.object(response)["error"] as? String
|
||||
|
||||
#expect(error == "That sign-in ran out. Try again.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension PasskeyControllerTests {
|
||||
|
||||
static let json: HTTPFields = [.contentType: "application/json"]
|
||||
|
||||
/// The JSON object a response carries.
|
||||
static func object(
|
||||
_ response: TestResponse
|
||||
) throws -> [String: Any] {
|
||||
try #require(try JSONSerialization.jsonObject(with: Data(buffer: response.body)) as? [String: Any])
|
||||
}
|
||||
|
||||
/// Runs the body against the passkey routes, over a migrated, seeded in-memory database.
|
||||
func withApp(
|
||||
_ 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 router = Router(context: WebsiteRequestContext.self)
|
||||
|
||||
router.addRoutes(PasskeyController<WebsiteRequestContext>(
|
||||
origin: "http://localhost:8080",
|
||||
accounts: .init(fluent: fluent),
|
||||
sessions: .init()
|
||||
).routes)
|
||||
|
||||
try await Application(router: router).test(.router, body)
|
||||
} catch {
|
||||
try? await fluent.shutdown()
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
try await fluent.shutdown()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"PasskeyCeremonies type",
|
||||
.tags(.type)
|
||||
)
|
||||
struct PasskeyCeremoniesTests {
|
||||
|
||||
// MARK: Methods tests
|
||||
|
||||
@Test
|
||||
func `answers a ceremony once`() async {
|
||||
let ceremonies = PasskeyCeremonies()
|
||||
let token = await ceremonies.start(.registration(email: "sam@example.com"), challenge: [1, 2, 3])
|
||||
let first = await ceremonies.take(token)
|
||||
let second = await ceremonies.take(token)
|
||||
let unknown = await ceremonies.take("unknown")
|
||||
|
||||
#expect(first?.kind == .registration(email: "sam@example.com"))
|
||||
#expect(first?.challenge == [1, 2, 3])
|
||||
#expect(second == nil)
|
||||
#expect(unknown == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `lets a ceremony run out after five minutes`() async {
|
||||
let clock = Clock()
|
||||
let ceremonies = PasskeyCeremonies(now: clock.now)
|
||||
let early = await ceremonies.start(.authentication, challenge: [1])
|
||||
let late = await ceremonies.start(.authentication, challenge: [2])
|
||||
|
||||
clock.advance(by: 299)
|
||||
|
||||
let inTime = await ceremonies.take(early)
|
||||
|
||||
clock.advance(by: 1)
|
||||
|
||||
let tooLate = await ceremonies.take(late)
|
||||
|
||||
#expect(inTime != nil)
|
||||
#expect(tooLate == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `hands out unguessable tokens, in a cookie kept to the sign-in path`() async {
|
||||
let ceremonies = PasskeyCeremonies()
|
||||
let first = await ceremonies.start(.authentication, challenge: [])
|
||||
let second = await ceremonies.start(.authentication, challenge: [])
|
||||
let cookie = PasskeyCeremonies.cookie(first, isSecure: true).description
|
||||
|
||||
#expect(first.count == 64)
|
||||
#expect(first != second)
|
||||
#expect(cookie.contains("Path=/sign-in"))
|
||||
#expect(cookie.contains("HttpOnly"))
|
||||
#expect(cookie.contains("SameSite=Strict"))
|
||||
#expect(cookie.contains("Secure"))
|
||||
#expect(cookie.contains("Max-Age=300"))
|
||||
#expect(PasskeyCeremonies.cookie("", isSecure: false).description.contains("Max-Age=0"))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// A clock the tests move by hand.
|
||||
final class Clock: @unchecked Sendable {
|
||||
|
||||
private let lock = NSLock()
|
||||
private var date = Date(timeIntervalSince1970: 1_790_000_000)
|
||||
|
||||
/// The clock's reading.
|
||||
@Sendable
|
||||
func now() -> Date {
|
||||
lock.withLock { date }
|
||||
}
|
||||
|
||||
/// Moves the clock forward.
|
||||
func advance(
|
||||
by interval: TimeInterval
|
||||
) {
|
||||
lock.withLock { date.addTimeInterval(interval) }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"PhotoStore type",
|
||||
.tags(.type)
|
||||
)
|
||||
struct PhotoStoreTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
static let jpeg = ByteBuffer(bytes: [0xFF, 0xD8, 0xFF, 0xE0] + Array(repeating: UInt8(0), count: 32))
|
||||
static let png = ByteBuffer(bytes: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] + Array(repeating: UInt8(0), count: 32))
|
||||
static let webp = ByteBuffer(bytes: Array("RIFF".utf8) + [0, 0, 0, 0] + Array("WEBPVP8 ".utf8))
|
||||
|
||||
// MARK: Methods tests
|
||||
|
||||
@Test
|
||||
func `stores photos under names of its own, by the type their bytes tell`() throws {
|
||||
try Self.withFolder { folder in
|
||||
let paths = try PhotoStore(staticFilesPath: folder).save([Self.jpeg, Self.png, Self.webp])
|
||||
|
||||
#expect(paths.map { $0.split(separator: ".").last } == ["jpg", "png", "webp"])
|
||||
#expect(paths.allSatisfy { $0.hasPrefix("/uploads/") })
|
||||
#expect(Set(paths).count == 3)
|
||||
|
||||
for path in paths {
|
||||
#expect(FileManager.default.fileExists(atPath: folder + path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
ByteBuffer(string: "<script>alert(1)</script>"),
|
||||
ByteBuffer(string: "GIF89a"),
|
||||
ByteBuffer(bytes: [0xFF, 0xD8]),
|
||||
ByteBuffer(string: "RIFF0000WAVE"),
|
||||
])
|
||||
func `turns down what is not a JPEG, PNG, or WebP image, storing nothing`(
|
||||
file: ByteBuffer
|
||||
) throws {
|
||||
try Self.withFolder { folder in
|
||||
#expect(throws: PhotoStore.Refusal.notAnImage) {
|
||||
try PhotoStore(staticFilesPath: folder).save([Self.jpeg, file])
|
||||
}
|
||||
#expect(!FileManager.default.fileExists(atPath: folder + "/uploads"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `turns down too many photos, and one too large`() throws {
|
||||
try Self.withFolder { folder in
|
||||
let store = PhotoStore(staticFilesPath: folder)
|
||||
let large = ByteBuffer(bytes: [0xFF, 0xD8, 0xFF] + Array(repeating: UInt8(0), count: PhotoStore.maxBytes))
|
||||
|
||||
#expect(throws: PhotoStore.Refusal.tooMany) {
|
||||
try store.save(Array(repeating: Self.jpeg, count: PhotoStore.maxCount + 1))
|
||||
}
|
||||
#expect(throws: PhotoStore.Refusal.tooLarge) {
|
||||
try store.save([large])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `stores nothing, and makes no folder, without photos`() throws {
|
||||
try Self.withFolder { folder in
|
||||
let paths = try PhotoStore(staticFilesPath: folder).save([])
|
||||
|
||||
#expect(paths.isEmpty)
|
||||
#expect(!FileManager.default.fileExists(atPath: folder + "/uploads"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
extension PhotoStoreTests {
|
||||
|
||||
/// Runs the body against an empty temporary folder, removed after.
|
||||
static func withFolder<T>(
|
||||
_ body: (String) async throws -> T
|
||||
) async rethrows -> T {
|
||||
let folder = FileManager.default.temporaryDirectory.appendingPathComponent("ccn-photos-\(UUID().uuidString)")
|
||||
|
||||
try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: folder)
|
||||
}
|
||||
|
||||
return try await body(folder.path)
|
||||
}
|
||||
|
||||
/// Runs the body against an empty temporary folder, removed after.
|
||||
static func withFolder<T>(
|
||||
_ body: (String) throws -> T
|
||||
) rethrows -> T {
|
||||
let folder = FileManager.default.temporaryDirectory.appendingPathComponent("ccn-photos-\(UUID().uuidString)")
|
||||
|
||||
try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: folder)
|
||||
}
|
||||
|
||||
return try body(folder.path)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user