Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<BasicWebSocketRequestContext>) {
// MARK: Detail & leaderboards

Expand All @@ -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)
Expand Down Expand Up @@ -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]) ?? [:]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
))
Expand Down
10 changes: 10 additions & 0 deletions Sources/Infrastructure/Shared/SDKFilter.swift
Original file line number Diff line number Diff line change
@@ -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<SDKValue: RawRepresentable, DomainValue: RawRepresentable>(
_ value: DomainValue?
) -> [SDKValue]? where SDKValue.RawValue == String, DomainValue.RawValue == String {
value.flatMap { SDKValue(rawValue: $0.rawValue) }.map { [$0] }
}
139 changes: 139 additions & 0 deletions Tests/ASCCommandTests/Commands/Web/GameCenterControllerTests.swift
Original file line number Diff line number Diff line change
@@ -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()
)
}
}
}
4 changes: 3 additions & 1 deletion docs/features/game-center/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading