diff --git a/CHANGELOG.md b/CHANGELOG.md index 008861b6..65e17a88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `asc asset-library`, `asset-placement-groups`, `asset-images`, `asset-videos` and `asset-placements` use the App Asset Library: upload screenshots and previews once, then place and order them on version and treatment localizations (CLI + REST). → [docs](docs/features/asset-library/README.md) - `asc subscriptions list` shows whether a subscription supports multiple seats and its markets (App Store, Apple School, Apple Business). Change them with `asc subscriptions update --multi-seat-status` and `--market-setting`, or over REST. → [docs](docs/features/iap-subscriptions/README.md) - `asc game-center score-moderations list|block|unblock`, `blocked-players list` and `players block|unblock` let you hide cheating leaderboard scores and block players. Game Center is now also reachable over REST. → [docs](docs/features/game-center/README.md) +- `asc game-center achievements create` and `leaderboards create` are now also reachable over REST (POST to the Game Center detail's achievements or leaderboards). → [docs](docs/features/game-center/README.md) - `asc age-rating` shows Korea's GRAC classification number, and `asc age-rating update --grac-rating-classification-number` sets it, also over REST. → [docs](docs/features/age-rating/README.md) ### Fixed diff --git a/Sources/ASCCommand/Commands/Web/Controllers/GameCenterController.swift b/Sources/ASCCommand/Commands/Web/Controllers/GameCenterController.swift index 0f4f87f2..e580f3d4 100644 --- a/Sources/ASCCommand/Commands/Web/Controllers/GameCenterController.swift +++ b/Sources/ASCCommand/Commands/Web/Controllers/GameCenterController.swift @@ -12,6 +12,12 @@ struct GameCenterController: Sendable { let repo: any GameCenterRepository let moderationRepo: any GameCenterModerationRepository + /// A request body missing a required field; surfaced to the client as a 400. + struct BadRequest: Error, Equatable { + let message: String + init(_ message: String) { self.message = message } + } + func addRoutes(to group: RouterGroup) { // MARK: Detail & leaderboards @@ -30,6 +36,26 @@ struct GameCenterController: Sendable { return try restFormat(try await self.repo.listAchievements(gameCenterDetailId: detailId)) } + group.post("/game-center/details/:detailId/achievements") { request, context -> Response in + guard let detailId = context.parameters.get("detailId") else { return jsonError("Missing detailId") } + let json = try await Self.jsonBody(request) + do { + return try restFormat(try await Self.createAchievement(detailId: detailId, json: json, repo: self.repo)) + } catch let error as BadRequest { + return jsonError(error.message) + } + } + + group.post("/game-center/details/:detailId/leaderboards") { request, context -> Response in + guard let detailId = context.parameters.get("detailId") else { return jsonError("Missing detailId") } + let json = try await Self.jsonBody(request) + do { + return try restFormat(try await Self.createLeaderboard(detailId: detailId, json: json, repo: self.repo)) + } catch let error as BadRequest { + return jsonError(error.message) + } + } + group.delete("/game-center/achievements/:achievementId") { _, context -> Response in guard let achievementId = context.parameters.get("achievementId") else { return jsonError("Missing achievementId") } try await self.repo.deleteAchievement(id: achievementId) @@ -79,4 +105,52 @@ struct GameCenterController: Sendable { return try restFormat(try await self.moderationRepo.updatePlayer(id: playerId, isBlocked: false)) } } + + /// Body keys mirror the `asc game-center achievements create` flags in camelCase: + /// `{"referenceName", "vendorIdentifier", "points", "showBeforeEarned"?, "repeatable"?}`. + static func createAchievement( + detailId: String, + json: [String: Any], + repo: any GameCenterRepository + ) async throws -> GameCenterAchievement { + guard let referenceName = json["referenceName"] as? String, + let vendorIdentifier = json["vendorIdentifier"] as? String, + let points = json["points"] as? Int + else { throw BadRequest("Provide referenceName, vendorIdentifier and points") } + return try await repo.createAchievement( + gameCenterDetailId: detailId, + referenceName: referenceName, + vendorIdentifier: vendorIdentifier, + points: points, + isShowBeforeEarned: json["showBeforeEarned"] as? Bool ?? false, + isRepeatable: json["repeatable"] as? Bool ?? false + ) + } + + /// Body keys mirror the `asc game-center leaderboards create` flags in camelCase: + /// `{"referenceName", "vendorIdentifier", "scoreSortType", "submissionType"?}` (default `BEST_SCORE`). + static func createLeaderboard( + detailId: String, + json: [String: Any], + repo: any GameCenterRepository + ) async throws -> GameCenterLeaderboard { + guard let referenceName = json["referenceName"] as? String, + let vendorIdentifier = json["vendorIdentifier"] as? String, + let scoreSortType = (json["scoreSortType"] as? String).flatMap({ ScoreSortType(rawValue: $0.uppercased()) }) + else { throw BadRequest("Provide referenceName, vendorIdentifier and scoreSortType (ASC or DESC)") } + guard let submissionType = LeaderboardSubmissionType(rawValue: (json["submissionType"] as? String ?? "BEST_SCORE").uppercased()) + else { throw BadRequest("submissionType must be BEST_SCORE or MOST_RECENT_SCORE") } + return try await repo.createLeaderboard( + gameCenterDetailId: detailId, + referenceName: referenceName, + vendorIdentifier: vendorIdentifier, + scoreSortType: scoreSortType, + submissionType: submissionType + ) + } + + private static func jsonBody(_ request: Request) async throws -> [String: Any] { + let body = try await request.body.collect(upTo: 64 * 1024) + return (try? JSONSerialization.jsonObject(with: body) as? [String: Any]) ?? [:] + } } diff --git a/Sources/Infrastructure/Apps/AssetLibrary/Images/SDKLibraryImageRepository.swift b/Sources/Infrastructure/Apps/AssetLibrary/Images/SDKLibraryImageRepository.swift index 1b7f1335..625b9d36 100644 --- a/Sources/Infrastructure/Apps/AssetLibrary/Images/SDKLibraryImageRepository.swift +++ b/Sources/Infrastructure/Apps/AssetLibrary/Images/SDKLibraryImageRepository.swift @@ -18,8 +18,8 @@ public struct SDKLibraryImageRepository: LibraryImageRepository, @unchecked Send category: AssetCategory? ) async throws -> [LibraryImage] { let sdk = APIEndpoint.v1.appAssetLibraries.id(libraryId).images.get(parameters: .init( - filterCategory: category.flatMap { .init(rawValue: $0.rawValue) }.map { [$0] }, - filterState: state.flatMap { .init(rawValue: $0.rawValue) }.map { [$0] }, + filterCategory: sdkFilter(category), + filterState: sdkFilter(state), filterID: imageId.map { [$0] }, limit: 200 )) diff --git a/Sources/Infrastructure/Apps/AssetLibrary/Placements/SDKAssetPlacementRepository.swift b/Sources/Infrastructure/Apps/AssetLibrary/Placements/SDKAssetPlacementRepository.swift index def8e357..3ace294d 100644 --- a/Sources/Infrastructure/Apps/AssetLibrary/Placements/SDKAssetPlacementRepository.swift +++ b/Sources/Infrastructure/Apps/AssetLibrary/Placements/SDKAssetPlacementRepository.swift @@ -117,25 +117,25 @@ public struct SDKAssetPlacementRepository: AssetPlacementRepository, @unchecked switch surface { case .appStoreVersionLocalization: let r = APIEndpoint.v1.appStoreVersionLocalizations.id(localizationId).placements.get(parameters: .init( - filterPlacementType: placementType.flatMap { .init(rawValue: $0.rawValue) }.map { [$0] }, + filterPlacementType: sdkFilter(placementType), filterPlacementGroup: groups, sort: [.placementGroupPosition], limit: 200, include: [.image, .video] )) sdk = (r.path, r.query) case .experimentTreatmentLocalization: let r = APIEndpoint.v1.appStoreVersionExperimentTreatmentLocalizations.id(localizationId).placements.get(parameters: .init( - filterPlacementType: placementType.flatMap { .init(rawValue: $0.rawValue) }.map { [$0] }, + filterPlacementType: sdkFilter(placementType), filterPlacementGroup: groups, sort: [.placementGroupPosition], limit: 200, include: [.image, .video] )) sdk = (r.path, r.query) case .customProductPageLocalization: let r = APIEndpoint.v1.appCustomProductPageLocalizations.id(localizationId).placements.get(parameters: .init( - filterPlacementType: placementType.flatMap { .init(rawValue: $0.rawValue) }.map { [$0] }, + filterPlacementType: sdkFilter(placementType), filterPlacementGroup: groups, sort: [.placementGroupPosition], limit: 200, include: [.image, .video] )) sdk = (r.path, r.query) case .eventLocalization: let r = APIEndpoint.v1.appEventLocalizations.id(localizationId).placements.get(parameters: .init( - filterPlacementType: placementType.flatMap { .init(rawValue: $0.rawValue) }.map { [$0] }, + filterPlacementType: sdkFilter(placementType), filterPlacementGroup: groups, sort: [.placementGroupPosition], limit: 200, include: [.image, .video] )) sdk = (r.path, r.query) diff --git a/Sources/Infrastructure/Apps/AssetLibrary/Videos/SDKLibraryVideoRepository.swift b/Sources/Infrastructure/Apps/AssetLibrary/Videos/SDKLibraryVideoRepository.swift index f8efb908..14c46bdd 100644 --- a/Sources/Infrastructure/Apps/AssetLibrary/Videos/SDKLibraryVideoRepository.swift +++ b/Sources/Infrastructure/Apps/AssetLibrary/Videos/SDKLibraryVideoRepository.swift @@ -18,8 +18,8 @@ public struct SDKLibraryVideoRepository: LibraryVideoRepository, @unchecked Send category: AssetCategory? ) async throws -> [LibraryVideo] { let sdk = APIEndpoint.v1.appAssetLibraries.id(libraryId).videos.get(parameters: .init( - filterCategory: category.flatMap { .init(rawValue: $0.rawValue) }.map { [$0] }, - filterState: state.flatMap { .init(rawValue: $0.rawValue) }.map { [$0] }, + filterCategory: sdkFilter(category), + filterState: sdkFilter(state), filterID: videoId.map { [$0] }, limit: 200 )) diff --git a/Sources/Infrastructure/Shared/SDKFilter.swift b/Sources/Infrastructure/Shared/SDKFilter.swift new file mode 100644 index 00000000..70cb20fc --- /dev/null +++ b/Sources/Infrastructure/Shared/SDKFilter.swift @@ -0,0 +1,10 @@ +/// Turns an optional domain enum into the one-element SDK filter array a query parameter takes, +/// or `nil` when there is no value (or the SDK has no matching case). +/// +/// The SDK type comes from the parameter being filled. Spelled as a generic function because +/// Swift 6.2 can't infer `.init(rawValue:)` through `value.flatMap { .init(…) }.map { [$0] }`. +func sdkFilter( + _ value: DomainValue? +) -> [SDKValue]? where SDKValue.RawValue == String, DomainValue.RawValue == String { + value.flatMap { SDKValue(rawValue: $0.rawValue) }.map { [$0] } +} diff --git a/Tests/ASCCommandTests/Commands/Web/GameCenterControllerTests.swift b/Tests/ASCCommandTests/Commands/Web/GameCenterControllerTests.swift new file mode 100644 index 00000000..217ab1df --- /dev/null +++ b/Tests/ASCCommandTests/Commands/Web/GameCenterControllerTests.swift @@ -0,0 +1,139 @@ +import Mockable +import Testing +@testable import ASCCommand +@testable import Domain + +@Suite +struct GameCenterControllerTests { + + /// Stubs App Store Connect so the created achievement echoes what it was sent. + private func achievementEchoingRepo() -> MockGameCenterRepository { + let mockRepo = MockGameCenterRepository() + given(mockRepo).createAchievement( + gameCenterDetailId: .any, referenceName: .any, vendorIdentifier: .any, + points: .any, isShowBeforeEarned: .any, isRepeatable: .any + ).willProduce { detailId, referenceName, vendorIdentifier, points, isShowBeforeEarned, isRepeatable in + GameCenterAchievement( + id: "ach-1", gameCenterDetailId: detailId, referenceName: referenceName, + vendorIdentifier: vendorIdentifier, points: points, + isShowBeforeEarned: isShowBeforeEarned, isRepeatable: isRepeatable, isArchived: false + ) + } + return mockRepo + } + + /// Stubs App Store Connect so the created leaderboard echoes what it was sent. + private func leaderboardEchoingRepo() -> MockGameCenterRepository { + let mockRepo = MockGameCenterRepository() + given(mockRepo).createLeaderboard( + gameCenterDetailId: .any, referenceName: .any, vendorIdentifier: .any, + scoreSortType: .any, submissionType: .any + ).willProduce { detailId, referenceName, vendorIdentifier, scoreSortType, submissionType in + GameCenterLeaderboard( + id: "lb-1", gameCenterDetailId: detailId, referenceName: referenceName, + vendorIdentifier: vendorIdentifier, scoreSortType: scoreSortType, + submissionType: submissionType, isArchived: false + ) + } + return mockRepo + } + + // MARK: - Achievements + + @Test func `should create an achievement over REST with the same fields as the CLI`() async throws { + let created = try await GameCenterController.createAchievement( + detailId: "gc-1", + json: [ + "referenceName": "First Win", "vendorIdentifier": "com.example.first-win", + "points": 10, "showBeforeEarned": true, "repeatable": true, + ], + repo: achievementEchoingRepo() + ) + + #expect(created == GameCenterAchievement( + id: "ach-1", gameCenterDetailId: "gc-1", referenceName: "First Win", + vendorIdentifier: "com.example.first-win", points: 10, + isShowBeforeEarned: true, isRepeatable: true, isArchived: false + )) + } + + @Test func `should create a hidden one-time achievement when REST leaves out showBeforeEarned and repeatable`() async throws { + let created = try await GameCenterController.createAchievement( + detailId: "gc-1", + json: ["referenceName": "First Win", "vendorIdentifier": "com.example.first-win", "points": 10], + repo: achievementEchoingRepo() + ) + + #expect(created == GameCenterAchievement( + id: "ach-1", gameCenterDetailId: "gc-1", referenceName: "First Win", + vendorIdentifier: "com.example.first-win", points: 10, + isShowBeforeEarned: false, isRepeatable: false, isArchived: false + )) + } + + @Test func `should refuse a REST achievement without its points`() async throws { + await #expect(throws: GameCenterController.BadRequest("Provide referenceName, vendorIdentifier and points")) { + _ = try await GameCenterController.createAchievement( + detailId: "gc-1", + json: ["referenceName": "First Win", "vendorIdentifier": "com.example.first-win"], + repo: MockGameCenterRepository() + ) + } + } + + // MARK: - Leaderboards + + @Test func `should create a leaderboard over REST with the same fields as the CLI`() async throws { + let created = try await GameCenterController.createLeaderboard( + detailId: "gc-1", + json: [ + "referenceName": "All Time High", "vendorIdentifier": "com.example.high-score", + "scoreSortType": "DESC", "submissionType": "MOST_RECENT_SCORE", + ], + repo: leaderboardEchoingRepo() + ) + + #expect(created == GameCenterLeaderboard( + id: "lb-1", gameCenterDetailId: "gc-1", referenceName: "All Time High", + vendorIdentifier: "com.example.high-score", scoreSortType: .desc, + submissionType: .mostRecentScore, isArchived: false + )) + } + + @Test func `should keep each player's best score when REST leaves out submissionType`() async throws { + let created = try await GameCenterController.createLeaderboard( + detailId: "gc-1", + json: ["referenceName": "All Time High", "vendorIdentifier": "com.example.high-score", "scoreSortType": "asc"], + repo: leaderboardEchoingRepo() + ) + + #expect(created == GameCenterLeaderboard( + id: "lb-1", gameCenterDetailId: "gc-1", referenceName: "All Time High", + vendorIdentifier: "com.example.high-score", scoreSortType: .asc, + submissionType: .bestScore, isArchived: false + )) + } + + @Test func `should refuse a REST leaderboard with an unknown score sort order`() async throws { + await #expect(throws: GameCenterController.BadRequest("Provide referenceName, vendorIdentifier and scoreSortType (ASC or DESC)")) { + _ = try await GameCenterController.createLeaderboard( + detailId: "gc-1", + json: ["referenceName": "All Time High", "vendorIdentifier": "com.example.high-score", "scoreSortType": "SIDEWAYS"], + repo: MockGameCenterRepository() + ) + } + } + + @Test func `should refuse a REST leaderboard with an unknown submission type`() async throws { + await #expect(throws: GameCenterController.BadRequest("submissionType must be BEST_SCORE or MOST_RECENT_SCORE")) { + _ = try await GameCenterController.createLeaderboard( + detailId: "gc-1", + json: [ + "referenceName": "All Time High", "vendorIdentifier": "com.example.high-score", + "scoreSortType": "DESC", "submissionType": "AVERAGE_SCORE", + ], + repo: MockGameCenterRepository() + ) + } + } +} diff --git a/docs/features/game-center/README.md b/docs/features/game-center/README.md index 57321373..982ed23a 100644 --- a/docs/features/game-center/README.md +++ b/docs/features/game-center/README.md @@ -123,15 +123,17 @@ asc game-center players unblock --player-id player-abc123 |---|---|---| | GET | `/apps/{appId}/game-center` | `detail get --app-id` | | GET | `/game-center/details/{detailId}/achievements` | `achievements list --detail-id` | +| POST | `/game-center/details/{detailId}/achievements` | `achievements create` | | DELETE | `/game-center/achievements/{achievementId}` | `achievements delete` | | GET | `/game-center/details/{detailId}/leaderboards` | `leaderboards list --detail-id` | +| POST | `/game-center/details/{detailId}/leaderboards` | `leaderboards create` | | DELETE | `/game-center/leaderboards/{leaderboardId}` | `leaderboards delete` | | GET | `/game-center/leaderboards/{leaderboardId}/score-moderations?blocked-only=true` | `score-moderations list [--blocked-only]` | | POST | `/game-center/score-moderations/{id}/block`, `/unblock` | `score-moderations block`, `unblock` | | GET | `/game-center/details/{detailId}/blocked-players` | `blocked-players list` | | POST | `/game-center/players/{id}/block`, `/unblock` | `players block`, `unblock` | -Creating achievements or leaderboards is CLI-only. +POST bodies use the create flags in camelCase: `{"referenceName", "vendorIdentifier", "points", "showBeforeEarned"?, "repeatable"?}` for an achievement, `{"referenceName", "vendorIdentifier", "scoreSortType", "submissionType"?}` for a leaderboard (`submissionType` defaults to `BEST_SCORE`). A missing required field returns 400. ## Gotchas