diff --git a/Package.resolved b/Package.resolved index 6222a14..7f493d0 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "d739504014ee5c8a0b279c240a2b0e96c5d9c509f970a6c7cd722fabaabba9cd", + "originHash" : "146fbe9062cb34b5fb31a0a4dd39b239846a1b80f91f61ba21c6f2b478afe617", "pins" : [ { "identity" : "async-http-client", "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/async-http-client.git", "state" : { - "revision" : "291438696abdd48d2a83b52465c176efbd94512b", - "version" : "1.20.1" + "revision" : "a22083713ee90808d527d0baa290c2fb13ca3096", + "version" : "1.21.1" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/console-kit.git", "state" : { - "revision" : "a31f44ebfbd15a2cc0fda705279676773ac16355", - "version" : "4.14.1" + "revision" : "9c24ac496c97cfb49c1bd5e7162008bb50abafc8", + "version" : "4.14.2" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/fluent.git", "state" : { - "revision" : "a586a5d4164f23a0ee4e02e1f467b9bbef0c9f1c", - "version" : "4.9.0" + "revision" : "d831ac5e6a514dd8aa8d5499bb04be3983be1112", + "version" : "4.10.0" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/fluent-kit.git", "state" : { - "revision" : "5f0938a3f5f1a751ff7a411117bfce4efe713526", - "version" : "1.47.2" + "revision" : "cb91bf94fceedc6756e5b022ab394f6862154c34", + "version" : "1.48.4" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/fluent-postgres-driver.git", "state" : { - "revision" : "a538fc647f82d915eb84e0a12ca9b08c513e57c4", - "version" : "2.8.0" + "revision" : "e2988a8c960196eca2891f3a0bb1caad9044e7ea", + "version" : "2.9.2" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/jwt-kit.git", "state" : { - "revision" : "e05513b5aec24f88012b6e3034115b6bc915356a", - "version" : "4.13.2" + "revision" : "c2595b9ad7f512d7f334830b4df1fed6e917946a", + "version" : "4.13.4" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/petrpavlik/MixpanelVapor.git", "state" : { - "revision" : "1413f86c8cd28fe846b0f5819316ea00d35816fd", - "version" : "0.3.0" + "revision" : "7f18c3a7b270391d2ea51ea87a56eef0d60134d2", + "version" : "1.0.0" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/multipart-kit.git", "state" : { - "revision" : "12ee56f25bd3fc4c2d09c2aa16e69de61dc786e8", - "version" : "4.6.0" + "revision" : "a31236f24bfd2ea2f520a74575881f6731d7ae68", + "version" : "4.7.0" } }, { @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Quick/Nimble.git", "state" : { - "revision" : "efe11bbca024b57115260709b5c05e01131470d0", - "version" : "13.2.1" + "revision" : "1c49fc1243018f81a7ea99cb5e0985b00096e9f4", + "version" : "13.3.0" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/postgres-kit.git", "state" : { - "revision" : "e26763a6cb8d852f7ce01b1cd5925b3d8d084801", - "version" : "2.13.1" + "revision" : "0b72fa83b1023c4b82072e4049a3db6c29781fff", + "version" : "2.13.5" } }, { @@ -132,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/postgres-nio.git", "state" : { - "revision" : "e345cbb9cf6052b37b27c0c4f976134fc01dbe15", - "version" : "1.21.1" + "revision" : "d3795844d488210b65ace34c5f003e47d812d999", + "version" : "1.21.3" } }, { @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/routing-kit.git", "state" : { - "revision" : "2a92a7eac411a82fb3a03731be5e76773ebe1b3e", - "version" : "4.9.0" + "revision" : "8c9a227476555c55837e569be71944e02a056b72", + "version" : "4.9.1" } }, { @@ -159,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/sql-kit.git", "state" : { - "revision" : "b2f128cb62a3abfbb1e3b2893ff3ee69e70f4f0f", - "version" : "3.28.0" + "revision" : "25d8170c31173c7db4ddfef473e257c3bde60783", + "version" : "3.30.0" } }, { @@ -204,8 +204,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "f0525da24dc3c6cbb2b6b338b65042bc91cbc4bb", - "version" : "3.3.0" + "revision" : "bc1c29221f6dfeb0ebbfbc98eb95cd3d4967868e", + "version" : "3.4.0" } }, { @@ -213,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-types", "state" : { - "revision" : "12358d55a3824bd5fed310b999ea8cf83a9a1a65", - "version" : "1.0.3" + "revision" : "9bee2fdb79cc740081abd8ebd80738063d632286", + "version" : "1.1.0" } }, { @@ -231,8 +231,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-metrics.git", "state" : { - "revision" : "971ba26378ab69c43737ee7ba967a896cb74c0d1", - "version" : "2.4.1" + "revision" : "ce594e71e92a1610015017f83f402894df540e51", + "version" : "2.4.4" } }, { @@ -240,8 +240,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "fc63f0cf4e55a4597407a9fc95b16a2bc44b4982", - "version" : "2.64.0" + "revision" : "359c461e5561d22c6334828806cc25d759ca7aa6", + "version" : "2.65.0" } }, { @@ -258,8 +258,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-http2.git", "state" : { - "revision" : "0904bf0feb5122b7e5c3f15db7df0eabe623dd87", - "version" : "1.30.0" + "revision" : "c6afe04165c865faaa687b42c32ed76dfcc91076", + "version" : "1.31.0" } }, { @@ -276,8 +276,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { - "revision" : "6cbe0ed2b394f21ab0d46b9f0c50c6be964968ce", - "version" : "1.20.1" + "revision" : "38ac8221dd20674682148d6451367f89c2652980", + "version" : "1.21.0" } }, { @@ -294,8 +294,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/petrpavlik/swift-sentry.git", "state" : { - "branch" : "main", - "revision" : "2bc05a18f20fcf124520f078b57a58e6c6f077c5" + "revision" : "de65bc412272b307e7d0b0f28e2d578cd1f0c811", + "version" : "1.0.0" } }, { @@ -303,8 +303,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/swift-service-lifecycle.git", "state" : { - "revision" : "d7fe0e731499a8dcce53bf4cbbc812c8e565d3a7", - "version" : "2.4.1" + "revision" : "12a031bcc1284c64d6b847f5013ffe6dcca964d0", + "version" : "2.5.0" } }, { @@ -312,8 +312,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { - "revision" : "025bcb1165deab2e20d4eaba79967ce73013f496", - "version" : "1.2.1" + "revision" : "f9266c85189c2751589a50ea5aec72799797e471", + "version" : "1.3.0" } }, { @@ -330,8 +330,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/vapor.git", "state" : { - "revision" : "11cdb29614a5c7f8c5289f3c97b3398c3d89b395", - "version" : "4.92.5" + "revision" : "12e9b41cc576165150cb236676fc94d997d3db5f", + "version" : "4.101.1" } }, { diff --git a/Package.swift b/Package.swift index a808925..f92874a 100644 --- a/Package.swift +++ b/Package.swift @@ -15,9 +15,10 @@ let package = Package( .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.7.2"), .package(url: "https://github.com/emvakar/vapor-firebase-jwt-middleware.git", branch: "master"), .package(url: "https://github.com/Quick/Nimble.git", from: "13.0.0"), - .package(url: "https://github.com/petrpavlik/swift-sentry.git", branch: "main"), - .package(url: "https://github.com/petrpavlik/MixpanelVapor.git", from: "0.0.0"), + .package(url: "https://github.com/petrpavlik/swift-sentry.git", from: "1.0.0"), + .package(url: "https://github.com/petrpavlik/MixpanelVapor.git", from: "1.0.0"), .package(url: "https://github.com/Joannis/VaporSMTPKit.git", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), ], targets: [ .executableTarget( @@ -30,12 +31,20 @@ let package = Package( .product(name: "SwiftSentry", package: "swift-sentry"), "MixpanelVapor", .product(name: "VaporSMTPKit", package: "VaporSMTPKit"), - ] + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + ], + swiftSettings: swiftSettings ), .testTarget(name: "AppTests", dependencies: [ .target(name: "App"), .product(name: "XCTVapor", package: "vapor"), .product(name: "Nimble", package: "Nimble"), - ]) + ], swiftSettings: swiftSettings) ] ) + +var swiftSettings: [SwiftSetting] { [ + .enableUpcomingFeature("DisableOutwardActorInference"), + .enableExperimentalFeature("StrictConcurrency"), +] } diff --git a/Sources/App/Controllers/OrganizationController.swift b/Sources/App/Controllers/OrganizationController.swift index 6908b08..0b7a9f6 100644 --- a/Sources/App/Controllers/OrganizationController.swift +++ b/Sources/App/Controllers/OrganizationController.swift @@ -121,6 +121,7 @@ struct OrganizationController: RouteCollection { } } + @Sendable func index(req: Request) async throws -> [OrganizationDTO] { let profile = try await req.profile try await profile.$organizations.load(on: req.db) @@ -130,11 +131,13 @@ struct OrganizationController: RouteCollection { .map({ try $0.toDTO() }) } + @Sendable func get(req: Request) async throws -> OrganizationDTO { let organization = try await req.organization(minRole: .lurker) return try organization.toDTO() } + @Sendable func create(req: Request) async throws -> OrganizationDTO { let profile = try await req.profile @@ -171,6 +174,7 @@ struct OrganizationController: RouteCollection { return try organization.toDTO() } + @Sendable func patch(req: Request) async throws -> OrganizationDTO { let organization = try await req.organization(minRole: .admin) @@ -201,6 +205,7 @@ struct OrganizationController: RouteCollection { return try organization.toDTO() } + @Sendable func delete(req: Request) async throws -> HTTPStatus { let profile = try await req.profile @@ -234,6 +239,7 @@ struct OrganizationController: RouteCollection { return .noContent } + @Sendable func putOrganizationMembership(req: Request) async throws -> OrganizationMemberDTO { let profile = try await req.profile @@ -361,6 +367,7 @@ struct OrganizationController: RouteCollection { } } + @Sendable func deleteOrganizationMembership(req: Request) async throws -> HTTPStatus { let profile = try await req.profile @@ -409,6 +416,7 @@ struct OrganizationController: RouteCollection { return .noContent } + @Sendable func listOrganizationMemberships(req: Request) async throws -> [OrganizationMemberDTO] { let organization = try await req.organization(minRole: .lurker) let organizationId = try organization.requireID() diff --git a/Sources/App/Controllers/ProfileController.swift b/Sources/App/Controllers/ProfileController.swift index 69e0e55..7b36175 100644 --- a/Sources/App/Controllers/ProfileController.swift +++ b/Sources/App/Controllers/ProfileController.swift @@ -5,9 +5,10 @@ import FirebaseJWTMiddleware extension Request { var profile: Profile { get async throws { - let token = try await self.jwtUser + let token = try await self.firebaseJwt.asyncVerify() if let profile = try await Profile.query(on: self.db).filter(\.$firebaseUserId == token.userID).first() { + profile.lastSeenAt = .now try await profile.update(on: db) return profile @@ -53,10 +54,12 @@ struct ProfileController: RouteCollection { profile.delete(use: delete) } + @Sendable func index(req: Request) async throws -> ProfileDTO { try await req.profile.toDTO() } + @Sendable func create(req: Request) async throws -> ProfileDTO { let token = try await req.firebaseJwt.asyncVerify() let avatarUrl = token.picture?.replacingOccurrences(of: "\\/", with: "") @@ -81,8 +84,10 @@ struct ProfileController: RouteCollection { try await profile.update(on: req.db) } + profile.lastSeenAt = .now try await profile.update(on: req.db) + try await identifyProfile(profile: profile, req: req) return try profile.toDTO() } else { @@ -122,12 +127,18 @@ struct ProfileController: RouteCollection { } } - await req.trackAnalyticsEvent(name: "profile_created") + let userAgent = req.headers["User-Agent"].first ?? "" + let languages = req.headers["Accept-Language"].first ?? "" + + await req.trackAnalyticsEvent(name: "profile_created", params: ["email": profile.email, "name": profile.name ?? "", "user_agent": userAgent, "languages": languages]) + + try await identifyProfile(profile: profile, req: req) return try profile.toDTO() } } + @Sendable func update(req: Request) async throws -> ProfileDTO { let profile = try await req.profile @@ -150,13 +161,56 @@ struct ProfileController: RouteCollection { try await profile.update(on: req.db) + try await identifyProfile(profile: profile, req: req) + return try profile.toDTO() } + @Sendable func delete(req: Request) async throws -> HTTPStatus { - // TODO: delete org if it's the last admin member - try await req.profile.delete(on: req.db) + let profile = try await req.profile + let profileId = try profile.requireID() + + let organizations = try await profile.$organizations.get(on: req.db) + for organization in organizations { + let adminRoles = try await organization.$organizationRoles.get(on: req.db).filter({ $0.role == .admin }) + if adminRoles.count == 1, adminRoles.first?.$profile.id == profileId { + try await organization.delete(on: req.db) + } + } + + try await unidentifyProfile(profile: profile, req: req) + try await profile.delete(on: req.db) await req.trackAnalyticsEvent(name: "profile_deleted") return .noContent } + + // MARK: Analytics + + private func identifyProfile(profile: Profile, req: Request) async throws { + var properties: [String: any Content] = [ + "$email": profile.email + ] + + if let name = profile.name { + properties["$name"] = name + } + + if let avatar = profile.avatarUrl { + properties["$avatar"] = avatar + } + + if let createdAt = profile.createdAt { + properties["$created"] = createdAt.description + } + + let profileId = try profile.requireID() + + await req.mixpanel.peopleSet(distinctId: profileId.uuidString, request: req, setParams: properties) + } + + private func unidentifyProfile(profile: Profile, req: Request) async throws { + let profileId = try profile.requireID() + await req.mixpanel.peopleDelete(distinctId: profileId.uuidString) + } } diff --git a/Sources/App/Migrations/CreateProfile.swift b/Sources/App/Migrations/CreateProfile.swift index 8932112..ea39d6a 100644 --- a/Sources/App/Migrations/CreateProfile.swift +++ b/Sources/App/Migrations/CreateProfile.swift @@ -11,6 +11,7 @@ struct CreateProfile: AsyncMigration { .field(.subscribedToNewsletterAt, .date) .field(.createdAt, .datetime) .field(.updatedAt, .datetime) + .field(.lastSeenAt, .datetime) .unique(on: .firebaseUserId) .unique(on: .email) .create() diff --git a/Sources/App/Models/FIeldKeys.swift b/Sources/App/Models/FIeldKeys.swift index b392b5e..03fbc1e 100644 --- a/Sources/App/Models/FIeldKeys.swift +++ b/Sources/App/Models/FIeldKeys.swift @@ -19,4 +19,5 @@ extension FieldKey { static let role: FieldKey = "role" static let profileId: FieldKey = "profile_id" static let organizationId: FieldKey = "organization_id" + static let lastSeenAt: FieldKey = "last_seen_at" } diff --git a/Sources/App/Models/Organization.swift b/Sources/App/Models/Organization.swift index 0bf4284..5f8e4d6 100644 --- a/Sources/App/Models/Organization.swift +++ b/Sources/App/Models/Organization.swift @@ -1,7 +1,7 @@ import Fluent import Vapor -final class Organization: Model, Content { +final class Organization: Model, Content, @unchecked Sendable { static let schema = "organizations" @ID(key: .id) diff --git a/Sources/App/Models/OrganizationInvite.swift b/Sources/App/Models/OrganizationInvite.swift index 324461f..7872c20 100644 --- a/Sources/App/Models/OrganizationInvite.swift +++ b/Sources/App/Models/OrganizationInvite.swift @@ -8,7 +8,7 @@ import Fluent import Vapor -final class OrganizationInvite: Model, Content { +final class OrganizationInvite: Model, Content, @unchecked Sendable { static let schema = "organization_invites" @ID(key: .id) diff --git a/Sources/App/Models/Profile.swift b/Sources/App/Models/Profile.swift index 51c52d5..71b6b19 100644 --- a/Sources/App/Models/Profile.swift +++ b/Sources/App/Models/Profile.swift @@ -1,7 +1,7 @@ import Fluent import Vapor -final class Profile: Model, Content { +final class Profile: Model, Content, @unchecked Sendable { static let schema = "profiles" @ID(key: .id) @@ -21,6 +21,9 @@ final class Profile: Model, Content { @OptionalField(key: .avatarUrl) var avatarUrl: String? + + @OptionalField(key: .lastSeenAt) + var lastSeenAt: Date? @Timestamp(key: .createdAt, on: .create) var createdAt: Date? diff --git a/Sources/App/Models/ProfileOrganizationRole.swift b/Sources/App/Models/ProfileOrganizationRole.swift index db7dcb4..ba63496 100644 --- a/Sources/App/Models/ProfileOrganizationRole.swift +++ b/Sources/App/Models/ProfileOrganizationRole.swift @@ -1,7 +1,7 @@ import Fluent import Vapor -final class ProfileOrganizationRole: Model { +final class ProfileOrganizationRole: Model, @unchecked Sendable { enum Role: String, Codable, Comparable { diff --git a/Sources/App/Utils/Analytics.swift b/Sources/App/Utils/Analytics.swift index 68b3136..d45e934 100644 --- a/Sources/App/Utils/Analytics.swift +++ b/Sources/App/Utils/Analytics.swift @@ -16,24 +16,8 @@ extension Request { return } - var params = params - if let profile = try? await profile { - - if let profileId = profile.id { - params["$user_id"] = profileId.uuidString - } - - params["$email"] = profile.email - - if let name = profile.name { - params["$name"] = name - } - if let avatarUrl = profile.avatarUrl { - params["$avatar"] = avatarUrl - } - } + let profile = try? await profile - // Log to a destination of your choice - await mixpanel.track(name: name, request: self, params: params) + await mixpanel.track(distinctId: profile?.id?.uuidString, name: name, request: self, params: params) } } diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index 0efcbc3..855bce3 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -41,13 +41,8 @@ public func configure(_ app: Application) async throws { if app.environment.isRelease { - if let mixpanelProjectId = Environment.process.MIXPANEL_PROJECT_ID, - let mixpanelUsername = Environment.process.MIXPANEL_USERNAME, - let mixpanelPassword = Environment.process.MIXPANEL_PASSWORD { - - app.mixpanel.configuration = .init(projectId: mixpanelProjectId, - authorization: .init(username: mixpanelUsername, - password: mixpanelPassword)) + if let mixpanelToken = Environment.process.MIXPANEL_TOKEN { + app.mixpanel.configuration = .init(token: mixpanelToken) } else { app.logger.warning("Mixpanel disabled, env variables were not provided") } diff --git a/Sources/App/entrypoint.swift b/Sources/App/entrypoint.swift index 28fad2c..450f550 100644 --- a/Sources/App/entrypoint.swift +++ b/Sources/App/entrypoint.swift @@ -1,6 +1,8 @@ import Vapor import Logging import SwiftSentry +import NIOCore +import NIOPosix @main enum Entrypoint { @@ -24,11 +26,12 @@ enum Entrypoint { return MultiplexLogHandler(logHandlers) } - let app = Application(env) - defer { - app.shutdown() - try? sentry?.shutdown() - } + let app = try await Application.make(env) + + // This attempts to install NIO as the Swift Concurrency global executor. + // You should not call any async functions before this point. + let executorTakeoverSuccess = NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor() + app.logger.debug("Running with \(executorTakeoverSuccess ? "SwiftNIO" : "standard") Swift Concurrency default executor") do { try await configure(app) @@ -37,5 +40,7 @@ enum Entrypoint { throw error } try await app.execute() + try await app.asyncShutdown() + try await sentry?.shutdown() } } diff --git a/Tests/AppTests/AppTests.swift b/Tests/AppTests/AppTests.swift index 4ce895b..da8781b 100644 --- a/Tests/AppTests/AppTests.swift +++ b/Tests/AppTests/AppTests.swift @@ -7,7 +7,7 @@ import Nimble extension Application { static func configuredAppForTests() async throws -> Application { - let app = Application(.testing) + let app = try await Application.make(.testing) try await configure(app) try await app.autoRevert() @@ -22,7 +22,7 @@ extension Application { var profile: ProfileDTO! - try test(.POST, "profile", headers: authHeader, afterResponse: { res in + try await test(.POST, "profile", headers: authHeader, afterResponse: { res async throws in expect(res.status) == .ok profile = try res.content.decode(ProfileDTO.self) }) @@ -41,7 +41,7 @@ final class AppTests: XCTestCase { override func tearDown() async throws { - app.shutdown() + try await app.asyncShutdown() app = nil } @@ -53,7 +53,7 @@ final class AppTests: XCTestCase { let firebaseToken = try await app.client.firebaseDefaultUserToken() authHeader.bearerAuthorization = .init(token: firebaseToken) - try app.test(.POST, "profile", headers: authHeader, afterResponse: { res in + try await app.test(.POST, "profile", headers: authHeader, afterResponse: { res async throws in expect(res.status) == .ok let profile = try res.content.decode(ProfileDTO.self) expect(profile.email) == Environment.get("TEST_FIREBASE_USER_EMAIL") @@ -66,7 +66,7 @@ final class AppTests: XCTestCase { await expect { try await Organization.query(on: self.app.db).count() } == 1 await expect { try await ProfileOrganizationRole.query(on: self.app.db).count() } == 1 - try app.test(.POST, "profile", headers: authHeader, afterResponse: { res in + try await app.test(.POST, "profile", headers: authHeader, afterResponse: { res async throws in expect(res.status) == .ok let profile = try res.content.decode(ProfileDTO.self) expect(profile.email) == Environment.get("TEST_FIREBASE_USER_EMAIL") @@ -77,7 +77,7 @@ final class AppTests: XCTestCase { await expect { try await Organization.query(on: self.app.db).count() } == 1 await expect { try await ProfileOrganizationRole.query(on: self.app.db).count() } == 1 - try app.test(.GET, "profile", headers: authHeader, afterResponse: { res in + try await app.test(.GET, "profile", headers: authHeader, afterResponse: { res async throws in expect(res.status) == .ok let profile = try res.content.decode(ProfileDTO.self) expect(profile.email) == Environment.get("TEST_FIREBASE_USER_EMAIL") @@ -88,7 +88,7 @@ final class AppTests: XCTestCase { var isSubscribedToNewsletter: Bool? } - try app.test(.PATCH, "profile", headers: authHeader, beforeRequest: { request in + try await app.test(.PATCH, "profile", headers: authHeader, beforeRequest: { request async throws in try request.content.encode(PatchProfileBody(isSubscribedToNewsletter: true)) }, afterResponse: { res in expect(res.status) == .ok @@ -97,13 +97,13 @@ final class AppTests: XCTestCase { expect(profile.isSubscribedToNewsletter) == true }) - try app.test(.DELETE, "profile", headers: authHeader, afterResponse: { res in + try await app.test(.DELETE, "profile", headers: authHeader, afterResponse: { res async throws in expect(res.status) == .noContent }) await expect { try await Profile.query(on: self.app.db).count() } == 0 - try app.test(.POST, "profile", headers: authHeader, afterResponse: { res in + try await app.test(.POST, "profile", headers: authHeader, afterResponse: { res async throws in expect(res.status) == .ok let profile = try res.content.decode(ProfileDTO.self) expect(profile.email) == Environment.get("TEST_FIREBASE_USER_EMAIL") @@ -121,7 +121,7 @@ final class AppTests: XCTestCase { let firebaseToken = try await app.client.firebaseDefaultUserToken() authHeader.bearerAuthorization = .init(token: firebaseToken) - try app.test(.POST, "profile", headers: authHeader, afterResponse: { res in + try await app.test(.POST, "profile", headers: authHeader, afterResponse: { res async throws in expect(res.status) == .ok let profile = try res.content.decode(ProfileDTO.self) expect(profile.email) == Environment.get("TEST_FIREBASE_USER_EMAIL") @@ -136,7 +136,7 @@ final class AppTests: XCTestCase { var organizationId: UUID! - try app.test(.POST, "organization", headers: authHeader, beforeRequest: { request in + try await app.test(.POST, "organization", headers: authHeader, beforeRequest: { request async throws in try request.content.encode(OrganizationCreateDTO(name: "Test Organization")) }, afterResponse: { res in expect(res.status) == .ok @@ -147,12 +147,12 @@ final class AppTests: XCTestCase { await expect { try await Organization.query(on: self.app.db).count() } == 2 - try app.test(.GET, "organization", headers: authHeader, afterResponse: { res in + try await app.test(.GET, "organization", headers: authHeader, afterResponse: { res async throws in expect(res.status) == .ok let organizations = try res.content.decode([OrganizationDTO].self) }) - try app.test(.PATCH, "organization/\(organizationId.uuidString)", headers: authHeader, beforeRequest: { request in + try await app.test(.PATCH, "organization/\(organizationId.uuidString)", headers: authHeader, beforeRequest: { request async throws in try request.content.encode(OrganizationCreateDTO(name: "New name")) }, afterResponse: { res in expect(res.status) == .ok @@ -169,7 +169,7 @@ final class AppTests: XCTestCase { await expect { try await Organization.query(on: self.app.db).count() } == 2 var user2Id = "" - try app.test(.POST, "profile", headers: authHeader2, afterResponse: { res in + try await app.test(.POST, "profile", headers: authHeader2, afterResponse: { res async throws in expect(res.status) == .ok let profile = try res.content.decode(ProfileDTO.self) expect(profile.email) == Environment.get("TEST_FIREBASE_USER_2_EMAIL") @@ -183,53 +183,53 @@ final class AppTests: XCTestCase { var role: OrganizationRoleDTO } - try app.test(.PUT, "organization/\(organizationId.uuidString)/members", headers: authHeader, beforeRequest: { request in + try await app.test(.PUT, "organization/\(organizationId.uuidString)/members", headers: authHeader, beforeRequest: { request in try request.content.encode(UpdateRoleDTO(email: Environment.get("TEST_FIREBASE_USER_2_EMAIL")!, role: .lurker)) - }, afterResponse: { res in + }, afterResponse: { res async throws in expect(res.status) == .ok let member = try res.content.decode(OrganizationMemberDTO.self) expect(member.role) == .lurker }) - try app.test(.PUT, "organization/\(organizationId.uuidString)/members", headers: authHeader, beforeRequest: { request in + try await app.test(.PUT, "organization/\(organizationId.uuidString)/members", headers: authHeader, beforeRequest: { request in try request.content.encode(UpdateRoleDTO(email: Environment.get("TEST_FIREBASE_USER_2_EMAIL")!, role: .editor)) - }, afterResponse: { res in + }, afterResponse: { res async throws in expect(res.status) == .ok let member = try res.content.decode(OrganizationMemberDTO.self) expect(member.role) == .editor }) - try app.test(.DELETE, "organization/\(organizationId.uuidString)/members/\(Environment.get("TEST_FIREBASE_USER_2_EMAIL")!)", headers: authHeader, afterResponse: { res in + try await app.test(.DELETE, "organization/\(organizationId.uuidString)/members/\(Environment.get("TEST_FIREBASE_USER_2_EMAIL")!)", headers: authHeader, afterResponse: { res async throws in expect(res.status) == .noContent }) - try app.test(.PUT, "organization/\(organizationId.uuidString)/members", headers: authHeader, beforeRequest: { request in + try await app.test(.PUT, "organization/\(organizationId.uuidString)/members", headers: authHeader, beforeRequest: { request in try request.content.encode(UpdateRoleDTO(email: "unregistered@example.com", role: .admin)) - }, afterResponse: { res in + }, afterResponse: { res async throws in expect(res.status) == .ok let member = try res.content.decode(OrganizationMemberDTO.self) expect(member.email) == "unregistered@example.com" expect(member.role) == .admin }) - try app.test(.DELETE, "organization/\(organizationId.uuidString)/members/unregistered@example.com", headers: authHeader, afterResponse: { res in + try await app.test(.DELETE, "organization/\(organizationId.uuidString)/members/unregistered@example.com", headers: authHeader, afterResponse: { res async throws in expect(res.status) == .noContent }) await expect { try await Organization.query(on: self.app.db).count() } == 3 - try app.test(.DELETE, "organization/\(organizationId.uuidString)", headers: authHeader, afterResponse: { res in + try await app.test(.DELETE, "organization/\(organizationId.uuidString)", headers: authHeader, afterResponse: { res async throws in expect(res.status) == .noContent }) await expect { try await Organization.query(on: self.app.db).count() } == 2 await expect { try await Profile.query(on: self.app.db).count() } == 2 - try app.test(.DELETE, "profile", headers: authHeader2, afterResponse: { res in + try await app.test(.DELETE, "profile", headers: authHeader2, afterResponse: { res async throws in expect(res.status) == .noContent }) - try app.test(.DELETE, "profile", headers: authHeader, afterResponse: { res in + try await app.test(.DELETE, "profile", headers: authHeader, afterResponse: { res async throws in expect(res.status) == .noContent })