diff --git a/CHANGELOG.md b/CHANGELOG.md index dd4e1e30..4acf6dcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Product Page Optimization tests** — `asc experiments list|get|create|update|start|stop|delete`, `asc experiment-treatments list|create|update|delete`, and `asc experiment-treatment-localizations list|create|delete` manage App Store product page A/B tests (ASC API `appStoreVersionExperiments` v2). A test is app-scoped; `create` takes `--name`, `--platform` and `--traffic-proportion 1-100`; `start`/`stop` map to `PATCH { started: true|false }`. New `AppStoreVersionExperiment` (+ `AppStoreVersionExperimentState` with `isEditable`/`isPendingReview`/`isApproved`/`isFinished`, and `isRunning`/`canStart` on the model), `ExperimentTreatment` and `ExperimentTreatmentLocalization` domain types in `Domain/Apps/Experiments/`, backed by `ExperimentRepository`. Affordances are state-aware: `createTreatment`/`update`/`delete` only while editable, `start` only when approved and unstarted, `stop` only while running. `App` gains a `listExperiments` affordance for discovery. REST equivalents under `/api/v1/apps/:appId/experiments`, `/api/v1/experiments/:id[/start|/stop|/experiment-treatments]`, `/api/v1/experiment-treatments/:id[/experiment-treatment-localizations]` and `/api/v1/experiment-treatment-localizations/:id` via `ExperimentsController`. See `docs/features/product-page-optimization.md`. + --- ## [0.18.2] - 2026-07-16 diff --git a/CLAUDE.md b/CLAUDE.md index be7fa304..b463bc24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,6 +118,7 @@ App → CiProduct (XcodeCloud) → CiWorkflow → CiBuildRun AppStoreVersion → VersionReadiness AppStoreVersion → AppStoreReviewDetail CodeSigning: BundleID → Profile +App → AppStoreVersionExperiment → ExperimentTreatment → ExperimentTreatmentLocalization App → PerformanceMetric (via perfPowerMetrics) Build → PerformanceMetric (via perfPowerMetrics) Build → DiagnosticSignatureInfo → DiagnosticLogEntry @@ -140,6 +141,8 @@ Domain/ │ │ CustomerReviewRepository │ ├── Builds/ → Build, BuildUpload, BetaBuildLocalization, │ │ BuildRepository, BuildUploadRepository, BetaBuildLocalizationRepository +│ ├── Experiments/ → AppStoreVersionExperiment, AppStoreVersionExperimentState, +│ │ ExperimentTreatment, ExperimentTreatmentLocalization, ExperimentRepository │ ├── Pricing/ → PricingRepository │ ├── TestFlight/ → BetaGroup, BetaTester, TestFlightRepository │ └── Performance/ → PerformanceMetric, PerformanceMetricCategory, DiagnosticSignatureInfo, @@ -244,7 +247,7 @@ After every code change — new feature, improvement, or bug fix — update all Key skills to keep in sync: - `implement-feature/SKILL.md` — workflow + checklist - `asc-cli/references/commands.md` — command reference -- Feature-specific skills (`asc-testflight`, `asc-beta-review`, `asc-builds-upload`, `asc-code-signing`, `asc-check-readiness`, `asc-app-previews`, `asc-app-shots`, `asc-review-detail`, `asc-plugins`, etc.) +- Feature-specific skills (`asc-testflight`, `asc-beta-review`, `asc-builds-upload`, `asc-code-signing`, `asc-check-readiness`, `asc-app-previews`, `asc-app-shots`, `asc-review-detail`, `asc-plugins`, `asc-experiments`, etc.) **`CLAUDE.md`** — update when architecture patterns, file locations, or design rules change. diff --git a/README.md b/README.md index d5c32099..70b6c78a 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Now `asc iap list --app-id ` enriches each IAP with the right submission aff | **Authentication** | Multi-account credential management; named accounts, active-account switching | | **Project Init** | `asc init` pins app context to `.asc/project.json`; auto-detects from `.xcodeproj` | | **Customer Reviews** | Read customer reviews, respond to feedback, and manage review responses | +| **Product Page Optimization** | Create A/B tests of your product page (`experiments`), add up to three treatments with alternate icons, choose locales, start/stop the test | | **App Clips** | Manage App Clips, default experiences, and locale-specific card content | | **Game Center** | Manage achievements and leaderboards for your game | | **Plugins** | Install executable plugins in `~/.asc/plugins/` for custom event handlers | @@ -361,6 +362,9 @@ asc subscriptions create --group-id --name "Monthly" --product-id "com.app asc subscriptions prices set --subscription-id --territory USA --price-point-id asc promoted-purchases create --app-id --iap-id --visible --enabled + +asc experiments create --app-id --name "Icon test" --traffic-proportion 30 +asc experiment-treatments create --experiment-id --name "Blue icon" --app-icon-name AppIcon-Blue ``` | Area | Documentation | @@ -374,6 +378,7 @@ asc promoted-purchases create --app-id --iap-id --visible --enabled | Win-back offers (lapsed subscribers, eligibility rules + priority) | [iap-subscriptions/win-back-offers.md](docs/features/iap-subscriptions/win-back-offers.md) | | Review screenshots & 1024×1024 promotional images | [iap-subscriptions/review-assets.md](docs/features/iap-subscriptions/review-assets.md) | | Promoted purchases (App Store product page slots) | [docs/features/promoted-purchases.md](docs/features/promoted-purchases.md) | +| Product Page Optimization tests (experiments, treatments, treatment localizations) | [docs/features/product-page-optimization.md](docs/features/product-page-optimization.md) | | Territory availability (apps, IAPs, subscriptions) | [docs/features/iap-subscription-availability.md](docs/features/iap-subscription-availability.md) | ### Code Signing diff --git a/Sources/ASCCommand/ASC.swift b/Sources/ASCCommand/ASC.swift index 4631a484..ef3b28fb 100644 --- a/Sources/ASCCommand/ASC.swift +++ b/Sources/ASCCommand/ASC.swift @@ -43,6 +43,9 @@ struct ASC: AsyncParsableCommand { SubscriptionPromotionalOffersCommand.self, WinBackOffersCommand.self, PromotedPurchasesCommand.self, + ExperimentsCommand.self, + ExperimentTreatmentsCommand.self, + ExperimentTreatmentLocalizationsCommand.self, IAPReviewScreenshotCommand.self, IAPImagesCommand.self, SubscriptionReviewScreenshotCommand.self, diff --git a/Sources/ASCCommand/ClientProvider.swift b/Sources/ASCCommand/ClientProvider.swift index 6bc6fb84..0fd4a6c9 100644 --- a/Sources/ASCCommand/ClientProvider.swift +++ b/Sources/ASCCommand/ClientProvider.swift @@ -183,6 +183,12 @@ struct ClientProvider { return try factory.makeWinBackOfferRepository(authProvider: authProvider) } + static func makeExperimentRepository() throws -> any ExperimentRepository { + let authProvider = CompositeAuthProvider() + let factory = ClientFactory() + return try factory.makeExperimentRepository(authProvider: authProvider) + } + static func makePromotedPurchaseRepository() throws -> any PromotedPurchaseRepository { let authProvider = CompositeAuthProvider() let factory = ClientFactory() diff --git a/Sources/ASCCommand/Commands/Experiments/ExperimentTreatmentLocalizationsCreate.swift b/Sources/ASCCommand/Commands/Experiments/ExperimentTreatmentLocalizationsCreate.swift new file mode 100644 index 00000000..864bf08b --- /dev/null +++ b/Sources/ASCCommand/Commands/Experiments/ExperimentTreatmentLocalizationsCreate.swift @@ -0,0 +1,31 @@ +import ArgumentParser +import Domain + +struct ExperimentTreatmentLocalizationsCreate: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Include a locale in a treatment" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "Treatment ID") + var treatmentId: String + + @Option(name: .long, help: "Locale code (e.g. en-US)") + var locale: String + + func run() async throws { + let repo = try ClientProvider.makeExperimentRepository() + print(try await execute(repo: repo)) + } + + func execute( + repo: any ExperimentRepository, + affordanceMode: AffordanceMode = .cli + ) async throws -> String { + let created = try await repo.createTreatmentLocalization(treatmentId: treatmentId, locale: locale) + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems([created], affordanceMode: affordanceMode) + } +} diff --git a/Sources/ASCCommand/Commands/Experiments/ExperimentTreatmentLocalizationsDelete.swift b/Sources/ASCCommand/Commands/Experiments/ExperimentTreatmentLocalizationsDelete.swift new file mode 100644 index 00000000..581dda40 --- /dev/null +++ b/Sources/ASCCommand/Commands/Experiments/ExperimentTreatmentLocalizationsDelete.swift @@ -0,0 +1,23 @@ +import ArgumentParser +import Domain + +struct ExperimentTreatmentLocalizationsDelete: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Remove a locale from a treatment" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "Treatment localization ID") + var localizationId: String + + func run() async throws { + let repo = try ClientProvider.makeExperimentRepository() + try await execute(repo: repo) + } + + func execute(repo: any ExperimentRepository) async throws { + try await repo.deleteTreatmentLocalization(localizationId: localizationId) + } +} diff --git a/Sources/ASCCommand/Commands/Experiments/ExperimentTreatmentLocalizationsList.swift b/Sources/ASCCommand/Commands/Experiments/ExperimentTreatmentLocalizationsList.swift new file mode 100644 index 00000000..5b2f811c --- /dev/null +++ b/Sources/ASCCommand/Commands/Experiments/ExperimentTreatmentLocalizationsList.swift @@ -0,0 +1,31 @@ +import ArgumentParser +import Domain + +struct ExperimentTreatmentLocalizationsList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List the locales included in a treatment" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "Treatment ID") + var treatmentId: String + + @Option(name: .long, help: "Max results") + var limit: Int? + + func run() async throws { + let repo = try ClientProvider.makeExperimentRepository() + print(try await execute(repo: repo)) + } + + func execute( + repo: any ExperimentRepository, + affordanceMode: AffordanceMode = .cli + ) async throws -> String { + let items = try await repo.listTreatmentLocalizations(treatmentId: treatmentId, limit: limit) + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems(items, affordanceMode: affordanceMode) + } +} diff --git a/Sources/ASCCommand/Commands/Experiments/ExperimentTreatmentsCreate.swift b/Sources/ASCCommand/Commands/Experiments/ExperimentTreatmentsCreate.swift new file mode 100644 index 00000000..75ab37d0 --- /dev/null +++ b/Sources/ASCCommand/Commands/Experiments/ExperimentTreatmentsCreate.swift @@ -0,0 +1,34 @@ +import ArgumentParser +import Domain + +struct ExperimentTreatmentsCreate: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Add a treatment (up to three per test)" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "Experiment ID") + var experimentId: String + + @Option(name: .long, help: "Treatment name") + var name: String + + @Option(name: .long, help: "Alternate app icon asset name to test") + var appIconName: String? + + func run() async throws { + let repo = try ClientProvider.makeExperimentRepository() + print(try await execute(repo: repo)) + } + + func execute( + repo: any ExperimentRepository, + affordanceMode: AffordanceMode = .cli + ) async throws -> String { + let created = try await repo.createTreatment(experimentId: experimentId, name: name, appIconName: appIconName) + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems([created], affordanceMode: affordanceMode) + } +} diff --git a/Sources/ASCCommand/Commands/Experiments/ExperimentTreatmentsDelete.swift b/Sources/ASCCommand/Commands/Experiments/ExperimentTreatmentsDelete.swift new file mode 100644 index 00000000..efa2d5c8 --- /dev/null +++ b/Sources/ASCCommand/Commands/Experiments/ExperimentTreatmentsDelete.swift @@ -0,0 +1,23 @@ +import ArgumentParser +import Domain + +struct ExperimentTreatmentsDelete: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Delete a treatment" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "Treatment ID") + var treatmentId: String + + func run() async throws { + let repo = try ClientProvider.makeExperimentRepository() + try await execute(repo: repo) + } + + func execute(repo: any ExperimentRepository) async throws { + try await repo.deleteTreatment(treatmentId: treatmentId) + } +} diff --git a/Sources/ASCCommand/Commands/Experiments/ExperimentTreatmentsList.swift b/Sources/ASCCommand/Commands/Experiments/ExperimentTreatmentsList.swift new file mode 100644 index 00000000..90b3a52e --- /dev/null +++ b/Sources/ASCCommand/Commands/Experiments/ExperimentTreatmentsList.swift @@ -0,0 +1,31 @@ +import ArgumentParser +import Domain + +struct ExperimentTreatmentsList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List treatments of a test" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "Experiment ID") + var experimentId: String + + @Option(name: .long, help: "Max results") + var limit: Int? + + func run() async throws { + let repo = try ClientProvider.makeExperimentRepository() + print(try await execute(repo: repo)) + } + + func execute( + repo: any ExperimentRepository, + affordanceMode: AffordanceMode = .cli + ) async throws -> String { + let response = try await repo.listTreatments(experimentId: experimentId, limit: limit) + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems(response.data, affordanceMode: affordanceMode) + } +} diff --git a/Sources/ASCCommand/Commands/Experiments/ExperimentTreatmentsUpdate.swift b/Sources/ASCCommand/Commands/Experiments/ExperimentTreatmentsUpdate.swift new file mode 100644 index 00000000..0f77130f --- /dev/null +++ b/Sources/ASCCommand/Commands/Experiments/ExperimentTreatmentsUpdate.swift @@ -0,0 +1,37 @@ +import ArgumentParser +import Domain + +struct ExperimentTreatmentsUpdate: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "update", + abstract: "Rename a treatment or change its app icon" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "Treatment ID") + var treatmentId: String + + @Option(name: .long, help: "New treatment name") + var name: String? + + @Option(name: .long, help: "Alternate app icon asset name") + var appIconName: String? + + func run() async throws { + let repo = try ClientProvider.makeExperimentRepository() + print(try await execute(repo: repo)) + } + + func execute( + repo: any ExperimentRepository, + affordanceMode: AffordanceMode = .cli + ) async throws -> String { + guard name != nil || appIconName != nil else { + throw ValidationError("Provide at least one of --name or --app-icon-name") + } + let updated = try await repo.updateTreatment(treatmentId: treatmentId, name: name, appIconName: appIconName) + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems([updated], affordanceMode: affordanceMode) + } +} diff --git a/Sources/ASCCommand/Commands/Experiments/ExperimentsCommand.swift b/Sources/ASCCommand/Commands/Experiments/ExperimentsCommand.swift new file mode 100644 index 00000000..f82817a9 --- /dev/null +++ b/Sources/ASCCommand/Commands/Experiments/ExperimentsCommand.swift @@ -0,0 +1,42 @@ +import ArgumentParser + +struct ExperimentsCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "experiments", + abstract: "Manage product page optimization tests (App Store version experiments)", + subcommands: [ + ExperimentsList.self, + ExperimentsGet.self, + ExperimentsCreate.self, + ExperimentsUpdate.self, + ExperimentsStart.self, + ExperimentsStop.self, + ExperimentsDelete.self, + ] + ) +} + +struct ExperimentTreatmentsCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "experiment-treatments", + abstract: "Manage treatments (product page variants) of a product page optimization test", + subcommands: [ + ExperimentTreatmentsList.self, + ExperimentTreatmentsCreate.self, + ExperimentTreatmentsUpdate.self, + ExperimentTreatmentsDelete.self, + ] + ) +} + +struct ExperimentTreatmentLocalizationsCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "experiment-treatment-localizations", + abstract: "Manage the locales included in a treatment", + subcommands: [ + ExperimentTreatmentLocalizationsList.self, + ExperimentTreatmentLocalizationsCreate.self, + ExperimentTreatmentLocalizationsDelete.self, + ] + ) +} diff --git a/Sources/ASCCommand/Commands/Experiments/ExperimentsCreate.swift b/Sources/ASCCommand/Commands/Experiments/ExperimentsCreate.swift new file mode 100644 index 00000000..7f89996e --- /dev/null +++ b/Sources/ASCCommand/Commands/Experiments/ExperimentsCreate.swift @@ -0,0 +1,45 @@ +import ArgumentParser +import Domain + +struct ExperimentsCreate: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create a product page optimization test" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "App ID") + var appId: String + + @Option(name: .long, help: "Reference name shown in App Analytics") + var name: String + + @Option(name: .long, help: "Platform: ios, macos, tvos, visionos") + var platform: String = "ios" + + @Option(name: .long, help: "Percentage of users (1-100) shown a treatment instead of the original page") + var trafficProportion: Int + + func run() async throws { + let repo = try ClientProvider.makeExperimentRepository() + print(try await execute(repo: repo)) + } + + func execute( + repo: any ExperimentRepository, + affordanceMode: AffordanceMode = .cli + ) async throws -> String { + guard let appStorePlatform = AppStorePlatform(cliArgument: platform) else { + throw ValidationError("Unknown platform '\(platform)'. Use: ios, macos, tvos, visionos") + } + guard (1...100).contains(trafficProportion) else { + throw ValidationError("--traffic-proportion must be between 1 and 100") + } + let created = try await repo.createExperiment( + appId: appId, name: name, platform: appStorePlatform, trafficProportion: trafficProportion + ) + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems([created], affordanceMode: affordanceMode) + } +} diff --git a/Sources/ASCCommand/Commands/Experiments/ExperimentsDelete.swift b/Sources/ASCCommand/Commands/Experiments/ExperimentsDelete.swift new file mode 100644 index 00000000..b3925755 --- /dev/null +++ b/Sources/ASCCommand/Commands/Experiments/ExperimentsDelete.swift @@ -0,0 +1,23 @@ +import ArgumentParser +import Domain + +struct ExperimentsDelete: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Delete a product page optimization test" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "Experiment ID") + var experimentId: String + + func run() async throws { + let repo = try ClientProvider.makeExperimentRepository() + try await execute(repo: repo) + } + + func execute(repo: any ExperimentRepository) async throws { + try await repo.deleteExperiment(experimentId: experimentId) + } +} diff --git a/Sources/ASCCommand/Commands/Experiments/ExperimentsGet.swift b/Sources/ASCCommand/Commands/Experiments/ExperimentsGet.swift new file mode 100644 index 00000000..ee874734 --- /dev/null +++ b/Sources/ASCCommand/Commands/Experiments/ExperimentsGet.swift @@ -0,0 +1,28 @@ +import ArgumentParser +import Domain + +struct ExperimentsGet: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "get", + abstract: "Get a product page optimization test" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "Experiment ID") + var experimentId: String + + func run() async throws { + let repo = try ClientProvider.makeExperimentRepository() + print(try await execute(repo: repo)) + } + + func execute( + repo: any ExperimentRepository, + affordanceMode: AffordanceMode = .cli + ) async throws -> String { + let experiment = try await repo.getExperiment(experimentId: experimentId) + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems([experiment], affordanceMode: affordanceMode) + } +} diff --git a/Sources/ASCCommand/Commands/Experiments/ExperimentsList.swift b/Sources/ASCCommand/Commands/Experiments/ExperimentsList.swift new file mode 100644 index 00000000..f51c4f8a --- /dev/null +++ b/Sources/ASCCommand/Commands/Experiments/ExperimentsList.swift @@ -0,0 +1,42 @@ +import ArgumentParser +import Domain + +struct ExperimentsList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List product page optimization tests for an app" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "App ID") + var appId: String + + @Option(name: .long, help: "Filter by state (e.g. PREPARE_FOR_SUBMISSION, APPROVED, COMPLETED)") + var state: String? + + @Option(name: .long, help: "Max results") + var limit: Int? + + func run() async throws { + let repo = try ClientProvider.makeExperimentRepository() + print(try await execute(repo: repo)) + } + + func execute( + repo: any ExperimentRepository, + affordanceMode: AffordanceMode = .cli + ) async throws -> String { + var stateFilter: AppStoreVersionExperimentState? + if let state { + guard let parsed = AppStoreVersionExperimentState(rawValue: state.uppercased()) else { + let known = AppStoreVersionExperimentState.allCases.map(\.rawValue).joined(separator: ", ") + throw ValidationError("Unknown state '\(state)'. Use one of: \(known)") + } + stateFilter = parsed + } + let response = try await repo.listExperiments(appId: appId, state: stateFilter, limit: limit) + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems(response.data, affordanceMode: affordanceMode) + } +} diff --git a/Sources/ASCCommand/Commands/Experiments/ExperimentsStart.swift b/Sources/ASCCommand/Commands/Experiments/ExperimentsStart.swift new file mode 100644 index 00000000..c797bad0 --- /dev/null +++ b/Sources/ASCCommand/Commands/Experiments/ExperimentsStart.swift @@ -0,0 +1,30 @@ +import ArgumentParser +import Domain + +struct ExperimentsStart: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "start", + abstract: "Start an approved test so it begins serving traffic" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "Experiment ID") + var experimentId: String + + func run() async throws { + let repo = try ClientProvider.makeExperimentRepository() + print(try await execute(repo: repo)) + } + + func execute( + repo: any ExperimentRepository, + affordanceMode: AffordanceMode = .cli + ) async throws -> String { + let updated = try await repo.updateExperiment( + experimentId: experimentId, name: nil, trafficProportion: nil, isStarted: true + ) + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems([updated], affordanceMode: affordanceMode) + } +} diff --git a/Sources/ASCCommand/Commands/Experiments/ExperimentsStop.swift b/Sources/ASCCommand/Commands/Experiments/ExperimentsStop.swift new file mode 100644 index 00000000..0773ff4b --- /dev/null +++ b/Sources/ASCCommand/Commands/Experiments/ExperimentsStop.swift @@ -0,0 +1,30 @@ +import ArgumentParser +import Domain + +struct ExperimentsStop: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "stop", + abstract: "Stop a running test" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "Experiment ID") + var experimentId: String + + func run() async throws { + let repo = try ClientProvider.makeExperimentRepository() + print(try await execute(repo: repo)) + } + + func execute( + repo: any ExperimentRepository, + affordanceMode: AffordanceMode = .cli + ) async throws -> String { + let updated = try await repo.updateExperiment( + experimentId: experimentId, name: nil, trafficProportion: nil, isStarted: false + ) + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems([updated], affordanceMode: affordanceMode) + } +} diff --git a/Sources/ASCCommand/Commands/Experiments/ExperimentsUpdate.swift b/Sources/ASCCommand/Commands/Experiments/ExperimentsUpdate.swift new file mode 100644 index 00000000..357dce8d --- /dev/null +++ b/Sources/ASCCommand/Commands/Experiments/ExperimentsUpdate.swift @@ -0,0 +1,42 @@ +import ArgumentParser +import Domain + +struct ExperimentsUpdate: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "update", + abstract: "Rename a test or change its traffic proportion" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "Experiment ID") + var experimentId: String + + @Option(name: .long, help: "New reference name") + var name: String? + + @Option(name: .long, help: "New traffic proportion (1-100)") + var trafficProportion: Int? + + func run() async throws { + let repo = try ClientProvider.makeExperimentRepository() + print(try await execute(repo: repo)) + } + + func execute( + repo: any ExperimentRepository, + affordanceMode: AffordanceMode = .cli + ) async throws -> String { + guard name != nil || trafficProportion != nil else { + throw ValidationError("Provide at least one of --name or --traffic-proportion") + } + if let trafficProportion, !(1...100).contains(trafficProportion) { + throw ValidationError("--traffic-proportion must be between 1 and 100") + } + let updated = try await repo.updateExperiment( + experimentId: experimentId, name: name, trafficProportion: trafficProportion, isStarted: nil + ) + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems([updated], affordanceMode: affordanceMode) + } +} diff --git a/Sources/ASCCommand/Commands/Web/Controllers/ExperimentsController.swift b/Sources/ASCCommand/Commands/Web/Controllers/ExperimentsController.swift new file mode 100644 index 00000000..45c0bd3c --- /dev/null +++ b/Sources/ASCCommand/Commands/Web/Controllers/ExperimentsController.swift @@ -0,0 +1,165 @@ +import Domain +import Foundation +import Hummingbird +import HummingbirdWebSocket +import Infrastructure + +/// Routes for product page optimization tests (`AppStoreVersionExperiment`), +/// their treatments and treatment localizations. +/// +/// Query-param names match the CLI flags: `?state=&limit=`. +struct ExperimentsController: Sendable { + let repo: any ExperimentRepository + + func addRoutes(to group: RouterGroup) { + // MARK: Experiments + + group.get("/apps/:appId/experiments") { request, context -> Response in + guard let appId = context.parameters.get("appId") else { return jsonError("Missing appId") } + let params = request.uri.queryParameters + var state: AppStoreVersionExperimentState? + if let raw = params.get("state") { + guard let parsed = AppStoreVersionExperimentState(rawValue: raw.uppercased()) else { + return jsonError("Unknown state '\(raw)'", status: .badRequest) + } + state = parsed + } + let limit = params.get("limit").flatMap { Int($0) } + let response = try await self.repo.listExperiments(appId: appId, state: state, limit: limit) + return try restFormatPaginated(response) + } + + group.post("/apps/:appId/experiments") { request, context -> Response in + guard let appId = context.parameters.get("appId") else { return jsonError("Missing appId") } + let json = try await Self.jsonBody(request) + guard let name = json["name"] as? String, !name.isEmpty else { + return jsonError("Missing name", status: .badRequest) + } + guard let trafficProportion = json["trafficProportion"] as? Int, (1...100).contains(trafficProportion) else { + return jsonError("trafficProportion must be an integer between 1 and 100", status: .badRequest) + } + let platformRaw = (json["platform"] as? String) ?? "ios" + guard let platform = AppStorePlatform(cliArgument: platformRaw) ?? AppStorePlatform(rawValue: platformRaw) else { + return jsonError("Invalid platform '\(platformRaw)'", status: .badRequest) + } + let created = try await self.repo.createExperiment( + appId: appId, name: name, platform: platform, trafficProportion: trafficProportion + ) + return try restFormat(created) + } + + group.get("/experiments/:experimentId") { _, context -> Response in + guard let experimentId = context.parameters.get("experimentId") else { return jsonError("Missing experimentId") } + let experiment = try await self.repo.getExperiment(experimentId: experimentId) + return try restFormat(experiment) + } + + group.patch("/experiments/:experimentId") { request, context -> Response in + guard let experimentId = context.parameters.get("experimentId") else { return jsonError("Missing experimentId") } + let json = try await Self.jsonBody(request) + let name = json["name"] as? String + let trafficProportion = json["trafficProportion"] as? Int + guard name != nil || trafficProportion != nil else { + return jsonError("Provide at least one of name or trafficProportion", status: .badRequest) + } + if let trafficProportion, !(1...100).contains(trafficProportion) { + return jsonError("trafficProportion must be between 1 and 100", status: .badRequest) + } + let updated = try await self.repo.updateExperiment( + experimentId: experimentId, name: name, trafficProportion: trafficProportion, isStarted: nil + ) + return try restFormat(updated) + } + + group.post("/experiments/:experimentId/start") { _, context -> Response in + guard let experimentId = context.parameters.get("experimentId") else { return jsonError("Missing experimentId") } + let updated = try await self.repo.updateExperiment( + experimentId: experimentId, name: nil, trafficProportion: nil, isStarted: true + ) + return try restFormat(updated) + } + + group.post("/experiments/:experimentId/stop") { _, context -> Response in + guard let experimentId = context.parameters.get("experimentId") else { return jsonError("Missing experimentId") } + let updated = try await self.repo.updateExperiment( + experimentId: experimentId, name: nil, trafficProportion: nil, isStarted: false + ) + return try restFormat(updated) + } + + group.delete("/experiments/:experimentId") { _, context -> Response in + guard let experimentId = context.parameters.get("experimentId") else { return jsonError("Missing experimentId") } + try await self.repo.deleteExperiment(experimentId: experimentId) + return restResponse("{\"deleted\":true}") + } + + // MARK: Treatments + + group.get("/experiments/:experimentId/experiment-treatments") { request, context -> Response in + guard let experimentId = context.parameters.get("experimentId") else { return jsonError("Missing experimentId") } + let limit = request.uri.queryParameters.get("limit").flatMap { Int($0) } + let response = try await self.repo.listTreatments(experimentId: experimentId, limit: limit) + return try restFormatPaginated(response) + } + + group.post("/experiments/:experimentId/experiment-treatments") { request, context -> Response in + guard let experimentId = context.parameters.get("experimentId") else { return jsonError("Missing experimentId") } + let json = try await Self.jsonBody(request) + guard let name = json["name"] as? String, !name.isEmpty else { + return jsonError("Missing name", status: .badRequest) + } + let created = try await self.repo.createTreatment( + experimentId: experimentId, name: name, appIconName: json["appIconName"] as? String + ) + return try restFormat(created) + } + + group.patch("/experiment-treatments/:treatmentId") { request, context -> Response in + guard let treatmentId = context.parameters.get("treatmentId") else { return jsonError("Missing treatmentId") } + let json = try await Self.jsonBody(request) + let name = json["name"] as? String + let appIconName = json["appIconName"] as? String + guard name != nil || appIconName != nil else { + return jsonError("Provide at least one of name or appIconName", status: .badRequest) + } + let updated = try await self.repo.updateTreatment(treatmentId: treatmentId, name: name, appIconName: appIconName) + return try restFormat(updated) + } + + group.delete("/experiment-treatments/:treatmentId") { _, context -> Response in + guard let treatmentId = context.parameters.get("treatmentId") else { return jsonError("Missing treatmentId") } + try await self.repo.deleteTreatment(treatmentId: treatmentId) + return restResponse("{\"deleted\":true}") + } + + // MARK: Treatment localizations + + group.get("/experiment-treatments/:treatmentId/experiment-treatment-localizations") { request, context -> Response in + guard let treatmentId = context.parameters.get("treatmentId") else { return jsonError("Missing treatmentId") } + let limit = request.uri.queryParameters.get("limit").flatMap { Int($0) } + let items = try await self.repo.listTreatmentLocalizations(treatmentId: treatmentId, limit: limit) + return try restFormat(items) + } + + group.post("/experiment-treatments/:treatmentId/experiment-treatment-localizations") { request, context -> Response in + guard let treatmentId = context.parameters.get("treatmentId") else { return jsonError("Missing treatmentId") } + let json = try await Self.jsonBody(request) + guard let locale = json["locale"] as? String, !locale.isEmpty else { + return jsonError("Missing locale", status: .badRequest) + } + let created = try await self.repo.createTreatmentLocalization(treatmentId: treatmentId, locale: locale) + return try restFormat(created) + } + + group.delete("/experiment-treatment-localizations/:localizationId") { _, context -> Response in + guard let localizationId = context.parameters.get("localizationId") else { return jsonError("Missing localizationId") } + try await self.repo.deleteTreatmentLocalization(localizationId: localizationId) + return restResponse("{\"deleted\":true}") + } + } + + 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/ASCCommand/Commands/Web/RESTRoutes.swift b/Sources/ASCCommand/Commands/Web/RESTRoutes.swift index 1b1addc5..1984387d 100644 --- a/Sources/ASCCommand/Commands/Web/RESTRoutes.swift +++ b/Sources/ASCCommand/Commands/Web/RESTRoutes.swift @@ -59,6 +59,9 @@ enum RESTRoutes { if let promotedRepo = try? factory.makePromotedPurchaseRepository(authProvider: auth) { PromotedPurchasesController(repo: promotedRepo).addRoutes(to: v1) } + if let experimentRepo = try? factory.makeExperimentRepository(authProvider: auth) { + ExperimentsController(repo: experimentRepo).addRoutes(to: v1) + } if let groupLocRepo = try? factory.makeSubscriptionGroupLocalizationRepository(authProvider: auth) { SubscriptionGroupLocalizationsController(repo: groupLocRepo).addRoutes(to: v1) } diff --git a/Sources/Domain/Apps/App.swift b/Sources/Domain/Apps/App.swift index 54c57acf..e3f8ab2e 100644 --- a/Sources/Domain/Apps/App.swift +++ b/Sources/Domain/Apps/App.swift @@ -89,6 +89,7 @@ extension App: AffordanceProviding { Affordance(key: "createVersion", command: "versions", action: "create", params: ["app-id": id]), Affordance(key: "listAppInfos", command: "app-infos", action: "list", params: ["app-id": id]), Affordance(key: "listReviews", command: "reviews", action: "list", params: ["app-id": id]), + Affordance(key: "listExperiments", command: "experiments", action: "list", params: ["app-id": id]), Affordance(key: "updateContentRights", command: "apps", action: "update", params: ["app-id": id]), ] } diff --git a/Sources/Domain/Apps/Experiments/AppStoreVersionExperiment+RESTRoutes.swift b/Sources/Domain/Apps/Experiments/AppStoreVersionExperiment+RESTRoutes.swift new file mode 100644 index 00000000..1cfdee44 --- /dev/null +++ b/Sources/Domain/Apps/Experiments/AppStoreVersionExperiment+RESTRoutes.swift @@ -0,0 +1,8 @@ +/// REST route registrations for Product Page Optimization tests and their children. +extension RESTPathResolver { + static let _experimentRoutes: Void = { + registerRoute(command: "experiments", parentParam: "app-id", parentSegment: "apps", segment: "experiments") + registerRoute(command: "experiment-treatments", parentParam: "experiment-id", parentSegment: "experiments", segment: "experiment-treatments") + registerRoute(command: "experiment-treatment-localizations", parentParam: "treatment-id", parentSegment: "experiment-treatments", segment: "experiment-treatment-localizations") + }() +} diff --git a/Sources/Domain/Apps/Experiments/AppStoreVersionExperiment.swift b/Sources/Domain/Apps/Experiments/AppStoreVersionExperiment.swift new file mode 100644 index 00000000..383110bc --- /dev/null +++ b/Sources/Domain/Apps/Experiments/AppStoreVersionExperiment.swift @@ -0,0 +1,141 @@ +/// A Product Page Optimization test (ASC API: `appStoreVersionExperiments` v2). +/// +/// Tests are app-scoped: each one shows a share of App Store traffic one of up +/// to three treatments instead of the original product page. +public struct AppStoreVersionExperiment: Sendable, Equatable, Identifiable { + public let id: String + /// Parent app identifier — injected by Infrastructure + public let appId: String + public let name: String + public let platform: AppStorePlatform + /// Percentage of users (1–100) shown a treatment instead of the original page. + public let trafficProportion: Int + public let state: AppStoreVersionExperimentState + public let isReviewRequired: Bool + /// ISO-8601 timestamps. Kept as strings so JSON in/out is lossless. + public let startDate: String? + public let endDate: String? + public let latestControlVersionId: String? + + public init( + id: String, + appId: String, + name: String, + platform: AppStorePlatform, + trafficProportion: Int, + state: AppStoreVersionExperimentState, + isReviewRequired: Bool = true, + startDate: String? = nil, + endDate: String? = nil, + latestControlVersionId: String? = nil + ) { + self.id = id + self.appId = appId + self.name = name + self.platform = platform + self.trafficProportion = trafficProportion + self.state = state + self.isReviewRequired = isReviewRequired + self.startDate = startDate + self.endDate = endDate + self.latestControlVersionId = latestControlVersionId + } + + /// True while the test is serving traffic: approved, started, and not yet ended. + public var isRunning: Bool { + state.isApproved && startDate != nil && endDate == nil + } + + /// True when App Review has approved the test but it hasn't been started yet. + public var canStart: Bool { + state.isApproved && startDate == nil + } +} + +public enum AppStoreVersionExperimentState: String, Sendable, Codable, Equatable, CaseIterable { + case prepareForSubmission = "PREPARE_FOR_SUBMISSION" + case readyForReview = "READY_FOR_REVIEW" + case waitingForReview = "WAITING_FOR_REVIEW" + case inReview = "IN_REVIEW" + case accepted = "ACCEPTED" + case approved = "APPROVED" + case rejected = "REJECTED" + case completed = "COMPLETED" + case stopped = "STOPPED" + + /// The developer can still change the test (name, traffic, treatments) or delete it. + public var isEditable: Bool { + self == .prepareForSubmission || self == .readyForReview || self == .rejected + } + public var isPendingReview: Bool { self == .waitingForReview || self == .inReview } + public var isApproved: Bool { self == .accepted || self == .approved } + public var isFinished: Bool { self == .completed || self == .stopped } +} + +extension AppStoreVersionExperiment: Codable { + enum CodingKeys: String, CodingKey { + case id, appId, name, platform, trafficProportion, state, isReviewRequired + case startDate, endDate, latestControlVersionId, isRunning, canStart + } + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + appId = try c.decode(String.self, forKey: .appId) + name = try c.decode(String.self, forKey: .name) + platform = try c.decode(AppStorePlatform.self, forKey: .platform) + trafficProportion = try c.decode(Int.self, forKey: .trafficProportion) + state = try c.decode(AppStoreVersionExperimentState.self, forKey: .state) + isReviewRequired = try c.decode(Bool.self, forKey: .isReviewRequired) + startDate = try c.decodeIfPresent(String.self, forKey: .startDate) + endDate = try c.decodeIfPresent(String.self, forKey: .endDate) + latestControlVersionId = try c.decodeIfPresent(String.self, forKey: .latestControlVersionId) + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(appId, forKey: .appId) + try c.encode(name, forKey: .name) + try c.encode(platform, forKey: .platform) + try c.encode(trafficProportion, forKey: .trafficProportion) + try c.encode(state, forKey: .state) + try c.encode(isReviewRequired, forKey: .isReviewRequired) + try c.encodeIfPresent(startDate, forKey: .startDate) + try c.encodeIfPresent(endDate, forKey: .endDate) + try c.encodeIfPresent(latestControlVersionId, forKey: .latestControlVersionId) + try c.encode(isRunning, forKey: .isRunning) + try c.encode(canStart, forKey: .canStart) + } +} + +extension AppStoreVersionExperiment: Presentable { + public static var tableHeaders: [String] { + ["ID", "Name", "Platform", "Traffic %", "State", "Started"] + } + public var tableRow: [String] { + [id, name, platform.displayName, String(trafficProportion), state.rawValue, startDate ?? "-"] + } +} + +extension AppStoreVersionExperiment: AffordanceProviding { + public var structuredAffordances: [Affordance] { + var items: [Affordance] = [ + Affordance(key: "listSiblings", command: "experiments", action: "list", params: ["app-id": appId]), + Affordance(key: "listTreatments", command: "experiment-treatments", action: "list", params: ["experiment-id": id]), + ] + if state.isEditable { + items.append(Affordance(key: "createTreatment", command: "experiment-treatments", action: "create", + params: ["experiment-id": id, "name": ""])) + items.append(Affordance(key: "update", command: "experiments", action: "update", params: ["experiment-id": id])) + items.append(Affordance(key: "delete", command: "experiments", action: "delete", params: ["experiment-id": id])) + } + if canStart { + items.append(Affordance(key: "start", command: "experiments", action: "start", params: ["experiment-id": id])) + } + if isRunning { + items.append(Affordance(key: "stop", command: "experiments", action: "stop", params: ["experiment-id": id])) + } + return items + } +} diff --git a/Sources/Domain/Apps/Experiments/ExperimentRepository.swift b/Sources/Domain/Apps/Experiments/ExperimentRepository.swift new file mode 100644 index 00000000..10e693c9 --- /dev/null +++ b/Sources/Domain/Apps/Experiments/ExperimentRepository.swift @@ -0,0 +1,23 @@ +import Mockable + +/// Product Page Optimization tests, treatments and treatment localizations. +@Mockable +public protocol ExperimentRepository: Sendable { + // Experiments (app-scoped) + func listExperiments(appId: String, state: AppStoreVersionExperimentState?, limit: Int?) async throws -> PaginatedResponse + func getExperiment(experimentId: String) async throws -> AppStoreVersionExperiment + func createExperiment(appId: String, name: String, platform: AppStorePlatform, trafficProportion: Int) async throws -> AppStoreVersionExperiment + func updateExperiment(experimentId: String, name: String?, trafficProportion: Int?, isStarted: Bool?) async throws -> AppStoreVersionExperiment + func deleteExperiment(experimentId: String) async throws + + // Treatments + func listTreatments(experimentId: String, limit: Int?) async throws -> PaginatedResponse + func createTreatment(experimentId: String, name: String, appIconName: String?) async throws -> ExperimentTreatment + func updateTreatment(treatmentId: String, name: String?, appIconName: String?) async throws -> ExperimentTreatment + func deleteTreatment(treatmentId: String) async throws + + // Treatment localizations + func listTreatmentLocalizations(treatmentId: String, limit: Int?) async throws -> [ExperimentTreatmentLocalization] + func createTreatmentLocalization(treatmentId: String, locale: String) async throws -> ExperimentTreatmentLocalization + func deleteTreatmentLocalization(localizationId: String) async throws +} diff --git a/Sources/Domain/Apps/Experiments/ExperimentTreatment.swift b/Sources/Domain/Apps/Experiments/ExperimentTreatment.swift new file mode 100644 index 00000000..a67cc534 --- /dev/null +++ b/Sources/Domain/Apps/Experiments/ExperimentTreatment.swift @@ -0,0 +1,72 @@ +/// One variant of the product page inside a Product Page Optimization test +/// (ASC API: `appStoreVersionExperimentTreatments`). A test has up to three. +public struct ExperimentTreatment: Sendable, Equatable, Identifiable { + public let id: String + /// Parent experiment identifier — injected by Infrastructure + public let experimentId: String + public let name: String + /// Name of the alternate app icon asset this treatment tests, if any. + public let appIconName: String? + /// ISO-8601 timestamp set once this treatment was promoted to the live product page. + public let promotedDate: String? + + public init( + id: String, + experimentId: String, + name: String, + appIconName: String? = nil, + promotedDate: String? = nil + ) { + self.id = id + self.experimentId = experimentId + self.name = name + self.appIconName = appIconName + self.promotedDate = promotedDate + } +} + +extension ExperimentTreatment: Codable { + enum CodingKeys: String, CodingKey { + case id, experimentId, name, appIconName, promotedDate + } + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + experimentId = try c.decode(String.self, forKey: .experimentId) + name = try c.decode(String.self, forKey: .name) + appIconName = try c.decodeIfPresent(String.self, forKey: .appIconName) + promotedDate = try c.decodeIfPresent(String.self, forKey: .promotedDate) + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(experimentId, forKey: .experimentId) + try c.encode(name, forKey: .name) + try c.encodeIfPresent(appIconName, forKey: .appIconName) + try c.encodeIfPresent(promotedDate, forKey: .promotedDate) + } +} + +extension ExperimentTreatment: Presentable { + public static var tableHeaders: [String] { + ["ID", "Name", "App Icon", "Promoted"] + } + public var tableRow: [String] { + [id, name, appIconName ?? "-", promotedDate ?? "-"] + } +} + +extension ExperimentTreatment: AffordanceProviding { + public var structuredAffordances: [Affordance] { + [ + Affordance(key: "listSiblings", command: "experiment-treatments", action: "list", params: ["experiment-id": experimentId]), + Affordance(key: "listLocalizations", command: "experiment-treatment-localizations", action: "list", params: ["treatment-id": id]), + Affordance(key: "createLocalization", command: "experiment-treatment-localizations", action: "create", + params: ["treatment-id": id, "locale": ""]), + Affordance(key: "update", command: "experiment-treatments", action: "update", params: ["treatment-id": id]), + Affordance(key: "delete", command: "experiment-treatments", action: "delete", params: ["treatment-id": id]), + ] + } +} diff --git a/Sources/Domain/Apps/Experiments/ExperimentTreatmentLocalization.swift b/Sources/Domain/Apps/Experiments/ExperimentTreatmentLocalization.swift new file mode 100644 index 00000000..da26188d --- /dev/null +++ b/Sources/Domain/Apps/Experiments/ExperimentTreatmentLocalization.swift @@ -0,0 +1,28 @@ +/// A locale included in a treatment (ASC API: `appStoreVersionExperimentTreatmentLocalizations`). +/// Screenshot sets and app preview sets for the treatment hang off this resource. +public struct ExperimentTreatmentLocalization: Sendable, Equatable, Identifiable, Codable { + public let id: String + /// Parent treatment identifier — injected by Infrastructure + public let treatmentId: String + public let locale: String + + public init(id: String, treatmentId: String, locale: String) { + self.id = id + self.treatmentId = treatmentId + self.locale = locale + } +} + +extension ExperimentTreatmentLocalization: Presentable { + public static var tableHeaders: [String] { ["ID", "Locale"] } + public var tableRow: [String] { [id, locale] } +} + +extension ExperimentTreatmentLocalization: AffordanceProviding { + public var structuredAffordances: [Affordance] { + [ + Affordance(key: "listSiblings", command: "experiment-treatment-localizations", action: "list", params: ["treatment-id": treatmentId]), + Affordance(key: "delete", command: "experiment-treatment-localizations", action: "delete", params: ["localization-id": id]), + ] + } +} diff --git a/Sources/Domain/Shared/RESTPathResolver.swift b/Sources/Domain/Shared/RESTPathResolver.swift index 8de9e48a..fc2026bf 100644 --- a/Sources/Domain/Shared/RESTPathResolver.swift +++ b/Sources/Domain/Shared/RESTPathResolver.swift @@ -163,6 +163,7 @@ public final class RESTPathResolver: @unchecked Sendable { _ = _appShotsRoutes _ = _submissionRoutes _ = _resolutionCenterRoutes + _ = _experimentRoutes initialized = true } diff --git a/Sources/Infrastructure/Apps/Experiments/SDKExperimentRepository.swift b/Sources/Infrastructure/Apps/Experiments/SDKExperimentRepository.swift new file mode 100644 index 00000000..177cb580 --- /dev/null +++ b/Sources/Infrastructure/Apps/Experiments/SDKExperimentRepository.swift @@ -0,0 +1,196 @@ +@preconcurrency import AppStoreConnect_Swift_SDK +import Domain +import Foundation + +/// Adapts the App Store Connect "product page optimization" endpoints +/// (`appStoreVersionExperiments` v2 + treatments + treatment localizations). +public struct SDKExperimentRepository: ExperimentRepository, @unchecked Sendable { + private let client: any APIClient + + public init(client: any APIClient) { + self.client = client + } + + // MARK: - Experiments + + public func listExperiments( + appId: String, + state: Domain.AppStoreVersionExperimentState?, + limit: Int? + ) async throws -> PaginatedResponse { + let filterState = state.flatMap { + APIEndpoint.V1.Apps.WithID.AppStoreVersionExperimentsV2.GetParameters.FilterState(rawValue: $0.rawValue) + } + let request = APIEndpoint.v1.apps.id(appId).appStoreVersionExperimentsV2.get( + parameters: .init(filterState: filterState.map { [$0] }, limit: limit) + ) + let response = try await client.request(request) + let experiments = response.data.map { mapExperiment($0, appId: appId) } + return PaginatedResponse(data: experiments, nextCursor: response.links.next) + } + + public func getExperiment(experimentId: String) async throws -> Domain.AppStoreVersionExperiment { + // Apple omits `relationships.app` unless it is explicitly included; without it + // the model's appId (and every `--app-id` affordance) would be empty. + let request = APIEndpoint.v2.appStoreVersionExperiments.id(experimentId).get(parameters: .init(include: [.app])) + let response = try await client.request(request) + return mapExperiment(response.data, appId: response.data.relationships?.app?.data?.id ?? "") + } + + public func createExperiment( + appId: String, + name: String, + platform: Domain.AppStorePlatform, + trafficProportion: Int + ) async throws -> Domain.AppStoreVersionExperiment { + guard let sdkPlatform = AppStoreConnect_Swift_SDK.Platform(rawValue: platform.rawValue) else { + throw Domain.APIError.unknown("Unsupported platform for create: \(platform.rawValue)") + } + let body = AppStoreVersionExperimentV2CreateRequest(data: .init( + type: .appStoreVersionExperiments, + attributes: .init(name: name, platform: sdkPlatform, trafficProportion: trafficProportion), + relationships: .init(app: .init(data: .init(type: .apps, id: appId))) + )) + let response = try await client.request(APIEndpoint.v2.appStoreVersionExperiments.post(body)) + return mapExperiment(response.data, appId: appId) + } + + public func updateExperiment( + experimentId: String, + name: String?, + trafficProportion: Int?, + isStarted: Bool? + ) async throws -> Domain.AppStoreVersionExperiment { + let body = AppStoreVersionExperimentV2UpdateRequest(data: .init( + type: .appStoreVersionExperiments, + id: experimentId, + attributes: .init(name: name, trafficProportion: trafficProportion, isStarted: isStarted) + )) + _ = try await client.request(APIEndpoint.v2.appStoreVersionExperiments.id(experimentId).patch(body)) + // PATCH responses carry no relationships, so re-read with `include=app` to keep appId populated. + return try await getExperiment(experimentId: experimentId) + } + + public func deleteExperiment(experimentId: String) async throws { + _ = try await client.request(APIEndpoint.v2.appStoreVersionExperiments.id(experimentId).delete) + } + + // MARK: - Treatments + + public func listTreatments(experimentId: String, limit: Int?) async throws -> PaginatedResponse { + let request = APIEndpoint.v2.appStoreVersionExperiments.id(experimentId) + .appStoreVersionExperimentTreatments.get(parameters: .init(limit: limit)) + let response = try await client.request(request) + let treatments = response.data.map { mapTreatment($0, experimentId: experimentId) } + return PaginatedResponse(data: treatments, nextCursor: response.links.next) + } + + public func createTreatment(experimentId: String, name: String, appIconName: String?) async throws -> Domain.ExperimentTreatment { + let body = AppStoreVersionExperimentTreatmentCreateRequest(data: .init( + type: .appStoreVersionExperimentTreatments, + attributes: .init(name: name, appIconName: appIconName), + relationships: .init( + appStoreVersionExperimentV2: .init(data: .init(type: .appStoreVersionExperiments, id: experimentId)) + ) + )) + let response = try await client.request(APIEndpoint.v1.appStoreVersionExperimentTreatments.post(body)) + return mapTreatment(response.data, experimentId: experimentId) + } + + public func updateTreatment(treatmentId: String, name: String?, appIconName: String?) async throws -> Domain.ExperimentTreatment { + let body = AppStoreVersionExperimentTreatmentUpdateRequest(data: .init( + type: .appStoreVersionExperimentTreatments, + id: treatmentId, + attributes: .init(name: name, appIconName: appIconName) + )) + _ = try await client.request(APIEndpoint.v1.appStoreVersionExperimentTreatments.id(treatmentId).patch(body)) + // Same as experiments: the PATCH response has no relationships, so re-read + // with the parent included to keep experimentId populated. + let request = APIEndpoint.v1.appStoreVersionExperimentTreatments.id(treatmentId) + .get(parameters: .init(include: [.appStoreVersionExperimentV2])) + let response = try await client.request(request) + let parentId = response.data.relationships?.appStoreVersionExperimentV2?.data?.id + ?? response.data.relationships?.appStoreVersionExperiment?.data?.id + ?? "" + return mapTreatment(response.data, experimentId: parentId) + } + + public func deleteTreatment(treatmentId: String) async throws { + _ = try await client.request(APIEndpoint.v1.appStoreVersionExperimentTreatments.id(treatmentId).delete) + } + + // MARK: - Treatment localizations + + public func listTreatmentLocalizations(treatmentId: String, limit: Int?) async throws -> [Domain.ExperimentTreatmentLocalization] { + let request = APIEndpoint.v1.appStoreVersionExperimentTreatments.id(treatmentId) + .appStoreVersionExperimentTreatmentLocalizations.get(parameters: .init(limit: limit)) + let response = try await client.request(request) + return response.data.map { mapLocalization($0, treatmentId: treatmentId) } + } + + public func createTreatmentLocalization(treatmentId: String, locale: String) async throws -> Domain.ExperimentTreatmentLocalization { + let body = AppStoreVersionExperimentTreatmentLocalizationCreateRequest(data: .init( + type: .appStoreVersionExperimentTreatmentLocalizations, + attributes: .init(locale: locale), + relationships: .init( + appStoreVersionExperimentTreatment: .init(data: .init(type: .appStoreVersionExperimentTreatments, id: treatmentId)) + ) + )) + let response = try await client.request(APIEndpoint.v1.appStoreVersionExperimentTreatmentLocalizations.post(body)) + return mapLocalization(response.data, treatmentId: treatmentId) + } + + public func deleteTreatmentLocalization(localizationId: String) async throws { + _ = try await client.request(APIEndpoint.v1.appStoreVersionExperimentTreatmentLocalizations.id(localizationId).delete) + } + + // MARK: - Mappers + + private func mapExperiment( + _ sdk: AppStoreConnect_Swift_SDK.AppStoreVersionExperimentV2, + appId: String + ) -> Domain.AppStoreVersionExperiment { + let platform = sdk.attributes?.platform.flatMap { Domain.AppStorePlatform(rawValue: $0.rawValue) } ?? .iOS + let state = sdk.attributes?.state.flatMap { Domain.AppStoreVersionExperimentState(rawValue: $0.rawValue) } ?? .prepareForSubmission + return Domain.AppStoreVersionExperiment( + id: sdk.id, + appId: appId, + name: sdk.attributes?.name ?? "", + platform: platform, + trafficProportion: sdk.attributes?.trafficProportion ?? 0, + state: state, + isReviewRequired: sdk.attributes?.isReviewRequired ?? true, + startDate: sdk.attributes?.startDate.map(Self.iso8601), + endDate: sdk.attributes?.endDate.map(Self.iso8601), + latestControlVersionId: sdk.relationships?.latestControlVersion?.data?.id + ) + } + + private func mapTreatment( + _ sdk: AppStoreConnect_Swift_SDK.AppStoreVersionExperimentTreatment, + experimentId: String + ) -> Domain.ExperimentTreatment { + Domain.ExperimentTreatment( + id: sdk.id, + experimentId: experimentId, + name: sdk.attributes?.name ?? "", + appIconName: sdk.attributes?.appIconName, + promotedDate: sdk.attributes?.promotedDate.map(Self.iso8601) + ) + } + + private func mapLocalization( + _ sdk: AppStoreConnect_Swift_SDK.AppStoreVersionExperimentTreatmentLocalization, + treatmentId: String + ) -> Domain.ExperimentTreatmentLocalization { + Domain.ExperimentTreatmentLocalization( + id: sdk.id, + treatmentId: treatmentId, + locale: sdk.attributes?.locale ?? "" + ) + } + + private static func iso8601(_ date: Date) -> String { + ISO8601DateFormatter().string(from: date) + } +} diff --git a/Sources/Infrastructure/Client/ClientFactory.swift b/Sources/Infrastructure/Client/ClientFactory.swift index 2075c867..8f52ea79 100644 --- a/Sources/Infrastructure/Client/ClientFactory.swift +++ b/Sources/Infrastructure/Client/ClientFactory.swift @@ -171,6 +171,11 @@ public struct ClientFactory: Sendable { return SDKWinBackOfferRepository(client: provider) } + public func makeExperimentRepository(authProvider: any AuthProvider) throws -> any ExperimentRepository { + let provider = try makeProvider(authProvider: authProvider) + return SDKExperimentRepository(client: provider) + } + public func makePromotedPurchaseRepository(authProvider: any AuthProvider) throws -> any PromotedPurchaseRepository { let provider = try makeProvider(authProvider: authProvider) return SDKPromotedPurchaseRepository(client: provider) diff --git a/Tests/ASCCommandTests/Commands/Apps/AppsListTests.swift b/Tests/ASCCommandTests/Commands/Apps/AppsListTests.swift index 3ac7301e..ac8f7515 100644 --- a/Tests/ASCCommandTests/Commands/Apps/AppsListTests.swift +++ b/Tests/ASCCommandTests/Commands/Apps/AppsListTests.swift @@ -24,6 +24,7 @@ struct AppsListTests { "affordances" : { "createVersion" : "asc versions create --app-id app-1", "listAppInfos" : "asc app-infos list --app-id app-1", + "listExperiments" : "asc experiments list --app-id app-1", "listReviews" : "asc reviews list --app-id app-1", "listVersions" : "asc versions list --app-id app-1", "updateContentRights" : "asc apps update --app-id app-1" @@ -56,6 +57,7 @@ struct AppsListTests { "affordances" : { "createVersion" : "asc versions create --app-id app-1", "listAppInfos" : "asc app-infos list --app-id app-1", + "listExperiments" : "asc experiments list --app-id app-1", "listReviews" : "asc reviews list --app-id app-1", "listVersions" : "asc versions list --app-id app-1", "updateContentRights" : "asc apps update --app-id app-1" diff --git a/Tests/ASCCommandTests/Commands/Apps/AppsUpdateTests.swift b/Tests/ASCCommandTests/Commands/Apps/AppsUpdateTests.swift index 07ed5516..93d8755c 100644 --- a/Tests/ASCCommandTests/Commands/Apps/AppsUpdateTests.swift +++ b/Tests/ASCCommandTests/Commands/Apps/AppsUpdateTests.swift @@ -26,6 +26,7 @@ struct AppsUpdateTests { "affordances" : { "createVersion" : "asc versions create --app-id app-9", "listAppInfos" : "asc app-infos list --app-id app-9", + "listExperiments" : "asc experiments list --app-id app-9", "listReviews" : "asc reviews list --app-id app-9", "listVersions" : "asc versions list --app-id app-9", "updateContentRights" : "asc apps update --app-id app-9" diff --git a/Tests/ASCCommandTests/Commands/Experiments/ExperimentTreatmentLocalizationsTests.swift b/Tests/ASCCommandTests/Commands/Experiments/ExperimentTreatmentLocalizationsTests.swift new file mode 100644 index 00000000..a22a89fc --- /dev/null +++ b/Tests/ASCCommandTests/Commands/Experiments/ExperimentTreatmentLocalizationsTests.swift @@ -0,0 +1,57 @@ +import Mockable +import Testing +@testable import ASCCommand +@testable import Domain + +@Suite +struct ExperimentTreatmentLocalizationsTests { + + @Test func `listed localizations show locale and delete affordance`() async throws { + let mockRepo = MockExperimentRepository() + given(mockRepo).listTreatmentLocalizations(treatmentId: .any, limit: .any).willReturn([ + ExperimentTreatmentLocalization(id: "loc-1", treatmentId: "trt-1", locale: "en-US"), + ]) + + let cmd = try ExperimentTreatmentLocalizationsList.parse(["--treatment-id", "trt-1", "--pretty"]) + let output = try await cmd.execute(repo: mockRepo) + + #expect(output == """ + { + "data" : [ + { + "affordances" : { + "delete" : "asc experiment-treatment-localizations delete --localization-id loc-1", + "listSiblings" : "asc experiment-treatment-localizations list --treatment-id trt-1" + }, + "id" : "loc-1", + "locale" : "en-US", + "treatmentId" : "trt-1" + } + ] + } + """) + } + + @Test func `create passes locale and shows the new localization`() async throws { + let mockRepo = MockExperimentRepository() + given(mockRepo).createTreatmentLocalization(treatmentId: .any, locale: .any) + .willReturn(ExperimentTreatmentLocalization(id: "loc-2", treatmentId: "trt-1", locale: "de-DE")) + + let cmd = try ExperimentTreatmentLocalizationsCreate.parse(["--treatment-id", "trt-1", "--locale", "de-DE"]) + let output = try await cmd.execute(repo: mockRepo) + + #expect(output.contains("\"id\":\"loc-2\"")) + #expect(output.contains("\"locale\":\"de-DE\"")) + verify(mockRepo).createTreatmentLocalization(treatmentId: .value("trt-1"), locale: .value("de-DE")).called(1) + } + + @Test func `delete calls the repository with the localization id`() async throws { + let mockRepo = MockExperimentRepository() + given(mockRepo).deleteTreatmentLocalization(localizationId: .any).willReturn(()) + + let cmd = try ExperimentTreatmentLocalizationsDelete.parse(["--localization-id", "loc-1"]) + try await cmd.execute(repo: mockRepo) + + verify(mockRepo).deleteTreatmentLocalization(localizationId: .value("loc-1")).called(1) + } +} diff --git a/Tests/ASCCommandTests/Commands/Experiments/ExperimentTreatmentsTests.swift b/Tests/ASCCommandTests/Commands/Experiments/ExperimentTreatmentsTests.swift new file mode 100644 index 00000000..42af10c1 --- /dev/null +++ b/Tests/ASCCommandTests/Commands/Experiments/ExperimentTreatmentsTests.swift @@ -0,0 +1,98 @@ +import Mockable +import Testing +@testable import ASCCommand +@testable import Domain + +private func makeTreatment(id: String = "trt-1", experimentId: String = "exp-1", appIconName: String? = "AppIcon-Blue") -> ExperimentTreatment { + ExperimentTreatment(id: id, experimentId: experimentId, name: "Blue icon", appIconName: appIconName) +} + +@Suite +struct ExperimentTreatmentsListTests { + + @Test func `listed treatments show icon name and localization affordances`() async throws { + let mockRepo = MockExperimentRepository() + given(mockRepo).listTreatments(experimentId: .any, limit: .any) + .willReturn(PaginatedResponse(data: [makeTreatment()], nextCursor: nil)) + + let cmd = try ExperimentTreatmentsList.parse(["--experiment-id", "exp-1", "--pretty"]) + let output = try await cmd.execute(repo: mockRepo) + + #expect(output == """ + { + "data" : [ + { + "affordances" : { + "createLocalization" : "asc experiment-treatment-localizations create --locale --treatment-id trt-1", + "delete" : "asc experiment-treatments delete --treatment-id trt-1", + "listLocalizations" : "asc experiment-treatment-localizations list --treatment-id trt-1", + "listSiblings" : "asc experiment-treatments list --experiment-id exp-1", + "update" : "asc experiment-treatments update --treatment-id trt-1" + }, + "appIconName" : "AppIcon-Blue", + "experimentId" : "exp-1", + "id" : "trt-1", + "name" : "Blue icon" + } + ] + } + """) + verify(mockRepo).listTreatments(experimentId: .value("exp-1"), limit: .value(nil)).called(1) + } +} + +@Suite +struct ExperimentTreatmentsCreateTests { + + @Test func `create passes name and icon and shows the new treatment`() async throws { + let mockRepo = MockExperimentRepository() + given(mockRepo).createTreatment(experimentId: .any, name: .any, appIconName: .any).willReturn(makeTreatment()) + + let cmd = try ExperimentTreatmentsCreate.parse([ + "--experiment-id", "exp-1", "--name", "Blue icon", "--app-icon-name", "AppIcon-Blue", + ]) + let output = try await cmd.execute(repo: mockRepo) + + #expect(output.contains("\"id\":\"trt-1\"")) + #expect(output.contains("\"appIconName\":\"AppIcon-Blue\"")) + verify(mockRepo).createTreatment( + experimentId: .value("exp-1"), name: .value("Blue icon"), appIconName: .value("AppIcon-Blue") + ).called(1) + } +} + +@Suite +struct ExperimentTreatmentsUpdateTests { + + @Test func `update forwards only the provided fields`() async throws { + let mockRepo = MockExperimentRepository() + given(mockRepo).updateTreatment(treatmentId: .any, name: .any, appIconName: .any).willReturn(makeTreatment()) + + let cmd = try ExperimentTreatmentsUpdate.parse(["--treatment-id", "trt-1", "--name", "Red icon"]) + _ = try await cmd.execute(repo: mockRepo) + + verify(mockRepo).updateTreatment(treatmentId: .value("trt-1"), name: .value("Red icon"), appIconName: .value(nil)).called(1) + } + + @Test func `update requires at least one field`() async throws { + let mockRepo = MockExperimentRepository() + let cmd = try ExperimentTreatmentsUpdate.parse(["--treatment-id", "trt-1"]) + await #expect(throws: (any Error).self) { + try await cmd.execute(repo: mockRepo) + } + } +} + +@Suite +struct ExperimentTreatmentsDeleteTests { + + @Test func `delete calls the repository with the treatment id`() async throws { + let mockRepo = MockExperimentRepository() + given(mockRepo).deleteTreatment(treatmentId: .any).willReturn(()) + + let cmd = try ExperimentTreatmentsDelete.parse(["--treatment-id", "trt-1"]) + try await cmd.execute(repo: mockRepo) + + verify(mockRepo).deleteTreatment(treatmentId: .value("trt-1")).called(1) + } +} diff --git a/Tests/ASCCommandTests/Commands/Experiments/ExperimentsTests.swift b/Tests/ASCCommandTests/Commands/Experiments/ExperimentsTests.swift new file mode 100644 index 00000000..39e4990d --- /dev/null +++ b/Tests/ASCCommandTests/Commands/Experiments/ExperimentsTests.swift @@ -0,0 +1,269 @@ +import Mockable +import Testing +@testable import ASCCommand +@testable import Domain + +private func makeExperiment( + id: String = "exp-1", + appId: String = "app-1", + state: AppStoreVersionExperimentState = .prepareForSubmission, + startDate: String? = nil +) -> AppStoreVersionExperiment { + AppStoreVersionExperiment( + id: id, appId: appId, name: "Icon test", platform: .iOS, + trafficProportion: 30, state: state, isReviewRequired: true, startDate: startDate + ) +} + +@Suite +struct ExperimentsListTests { + + @Test func `listed editable experiment shows fields and treatment, update, delete affordances`() async throws { + let mockRepo = MockExperimentRepository() + given(mockRepo).listExperiments(appId: .any, state: .any, limit: .any) + .willReturn(PaginatedResponse(data: [makeExperiment()], nextCursor: nil)) + + let cmd = try ExperimentsList.parse(["--app-id", "app-1", "--pretty"]) + let output = try await cmd.execute(repo: mockRepo) + + #expect(output == """ + { + "data" : [ + { + "affordances" : { + "createTreatment" : "asc experiment-treatments create --experiment-id exp-1 --name ", + "delete" : "asc experiments delete --experiment-id exp-1", + "listSiblings" : "asc experiments list --app-id app-1", + "listTreatments" : "asc experiment-treatments list --experiment-id exp-1", + "update" : "asc experiments update --experiment-id exp-1" + }, + "appId" : "app-1", + "canStart" : false, + "id" : "exp-1", + "isReviewRequired" : true, + "isRunning" : false, + "name" : "Icon test", + "platform" : "IOS", + "state" : "PREPARE_FOR_SUBMISSION", + "trafficProportion" : 30 + } + ] + } + """) + } + + @Test func `listed running experiment shows stop affordance and startDate`() async throws { + let mockRepo = MockExperimentRepository() + given(mockRepo).listExperiments(appId: .any, state: .any, limit: .any) + .willReturn(PaginatedResponse(data: [makeExperiment(state: .approved, startDate: "2026-09-01T00:00:00Z")], nextCursor: nil)) + + let cmd = try ExperimentsList.parse(["--app-id", "app-1", "--pretty"]) + let output = try await cmd.execute(repo: mockRepo) + + #expect(output == """ + { + "data" : [ + { + "affordances" : { + "listSiblings" : "asc experiments list --app-id app-1", + "listTreatments" : "asc experiment-treatments list --experiment-id exp-1", + "stop" : "asc experiments stop --experiment-id exp-1" + }, + "appId" : "app-1", + "canStart" : false, + "id" : "exp-1", + "isReviewRequired" : true, + "isRunning" : true, + "name" : "Icon test", + "platform" : "IOS", + "startDate" : "2026-09-01T00:00:00Z", + "state" : "APPROVED", + "trafficProportion" : 30 + } + ] + } + """) + } + + @Test func `state flag is forwarded to the repository as a typed filter`() async throws { + let mockRepo = MockExperimentRepository() + given(mockRepo).listExperiments(appId: .any, state: .any, limit: .any) + .willReturn(PaginatedResponse(data: [], nextCursor: nil)) + + let cmd = try ExperimentsList.parse(["--app-id", "app-1", "--state", "APPROVED", "--limit", "5"]) + let output = try await cmd.execute(repo: mockRepo) + + #expect(output == "{\"data\":[]}") + verify(mockRepo).listExperiments(appId: .value("app-1"), state: .value(.approved), limit: .value(5)).called(1) + } + + @Test func `unknown state flag is rejected`() async throws { + let mockRepo = MockExperimentRepository() + let cmd = try ExperimentsList.parse(["--app-id", "app-1", "--state", "BOGUS"]) + await #expect(throws: (any Error).self) { + try await cmd.execute(repo: mockRepo) + } + } + + @Test func `table output lists name, platform, traffic and state`() async throws { + let mockRepo = MockExperimentRepository() + given(mockRepo).listExperiments(appId: .any, state: .any, limit: .any) + .willReturn(PaginatedResponse(data: [makeExperiment()], nextCursor: nil)) + + let cmd = try ExperimentsList.parse(["--app-id", "app-1", "--output", "table"]) + let output = try await cmd.execute(repo: mockRepo) + + #expect(output.contains("Icon test")) + #expect(output.contains("iOS")) + #expect(output.contains("PREPARE_FOR_SUBMISSION")) + } +} + +@Suite +struct ExperimentsGetTests { + + @Test func `get shows a single approved experiment with start affordance`() async throws { + let mockRepo = MockExperimentRepository() + given(mockRepo).getExperiment(experimentId: .any).willReturn(makeExperiment(state: .approved)) + + let cmd = try ExperimentsGet.parse(["--experiment-id", "exp-1", "--pretty"]) + let output = try await cmd.execute(repo: mockRepo) + + #expect(output == """ + { + "data" : [ + { + "affordances" : { + "listSiblings" : "asc experiments list --app-id app-1", + "listTreatments" : "asc experiment-treatments list --experiment-id exp-1", + "start" : "asc experiments start --experiment-id exp-1" + }, + "appId" : "app-1", + "canStart" : true, + "id" : "exp-1", + "isReviewRequired" : true, + "isRunning" : false, + "name" : "Icon test", + "platform" : "IOS", + "state" : "APPROVED", + "trafficProportion" : 30 + } + ] + } + """) + verify(mockRepo).getExperiment(experimentId: .value("exp-1")).called(1) + } +} + +@Suite +struct ExperimentsCreateTests { + + @Test func `create passes name, platform and traffic proportion and shows the new test`() async throws { + let mockRepo = MockExperimentRepository() + given(mockRepo).createExperiment(appId: .any, name: .any, platform: .any, trafficProportion: .any) + .willReturn(makeExperiment()) + + let cmd = try ExperimentsCreate.parse([ + "--app-id", "app-1", "--name", "Icon test", "--platform", "ios", "--traffic-proportion", "30", + ]) + let output = try await cmd.execute(repo: mockRepo) + + #expect(output.contains("\"id\":\"exp-1\"")) + #expect(output.contains("\"state\":\"PREPARE_FOR_SUBMISSION\"")) + verify(mockRepo).createExperiment( + appId: .value("app-1"), name: .value("Icon test"), platform: .value(.iOS), trafficProportion: .value(30) + ).called(1) + } + + @Test func `create rejects traffic proportion outside 1-100`() async throws { + let mockRepo = MockExperimentRepository() + let cmd = try ExperimentsCreate.parse(["--app-id", "app-1", "--name", "X", "--traffic-proportion", "0"]) + await #expect(throws: (any Error).self) { + try await cmd.execute(repo: mockRepo) + } + let tooHigh = try ExperimentsCreate.parse(["--app-id", "app-1", "--name", "X", "--traffic-proportion", "101"]) + await #expect(throws: (any Error).self) { + try await tooHigh.execute(repo: mockRepo) + } + } + + @Test func `create rejects unknown platform`() async throws { + let mockRepo = MockExperimentRepository() + let cmd = try ExperimentsCreate.parse(["--app-id", "app-1", "--name", "X", "--platform", "android", "--traffic-proportion", "30"]) + await #expect(throws: (any Error).self) { + try await cmd.execute(repo: mockRepo) + } + } +} + +@Suite +struct ExperimentsUpdateTests { + + @Test func `update forwards only the provided fields and never touches started`() async throws { + let mockRepo = MockExperimentRepository() + given(mockRepo).updateExperiment(experimentId: .any, name: .any, trafficProportion: .any, isStarted: .any) + .willReturn(makeExperiment()) + + let cmd = try ExperimentsUpdate.parse(["--experiment-id", "exp-1", "--traffic-proportion", "40"]) + let output = try await cmd.execute(repo: mockRepo) + + #expect(output.contains("\"id\":\"exp-1\"")) + verify(mockRepo).updateExperiment( + experimentId: .value("exp-1"), name: .value(nil), trafficProportion: .value(40), isStarted: .value(nil) + ).called(1) + } + + @Test func `update requires at least one field`() async throws { + let mockRepo = MockExperimentRepository() + let cmd = try ExperimentsUpdate.parse(["--experiment-id", "exp-1"]) + await #expect(throws: (any Error).self) { + try await cmd.execute(repo: mockRepo) + } + } +} + +@Suite +struct ExperimentsStartStopTests { + + @Test func `start sets started to true and shows the running test`() async throws { + let mockRepo = MockExperimentRepository() + given(mockRepo).updateExperiment(experimentId: .any, name: .any, trafficProportion: .any, isStarted: .any) + .willReturn(makeExperiment(state: .approved, startDate: "2026-09-01T00:00:00Z")) + + let cmd = try ExperimentsStart.parse(["--experiment-id", "exp-1"]) + let output = try await cmd.execute(repo: mockRepo) + + #expect(output.contains("\"isRunning\":true")) + verify(mockRepo).updateExperiment( + experimentId: .value("exp-1"), name: .value(nil), trafficProportion: .value(nil), isStarted: .value(true) + ).called(1) + } + + @Test func `stop sets started to false`() async throws { + let mockRepo = MockExperimentRepository() + given(mockRepo).updateExperiment(experimentId: .any, name: .any, trafficProportion: .any, isStarted: .any) + .willReturn(makeExperiment(state: .stopped, startDate: "2026-09-01T00:00:00Z")) + + let cmd = try ExperimentsStop.parse(["--experiment-id", "exp-1"]) + let output = try await cmd.execute(repo: mockRepo) + + #expect(output.contains("\"state\":\"STOPPED\"")) + verify(mockRepo).updateExperiment( + experimentId: .value("exp-1"), name: .value(nil), trafficProportion: .value(nil), isStarted: .value(false) + ).called(1) + } +} + +@Suite +struct ExperimentsDeleteTests { + + @Test func `delete calls the repository with the experiment id`() async throws { + let mockRepo = MockExperimentRepository() + given(mockRepo).deleteExperiment(experimentId: .any).willReturn(()) + + let cmd = try ExperimentsDelete.parse(["--experiment-id", "exp-1"]) + try await cmd.execute(repo: mockRepo) + + verify(mockRepo).deleteExperiment(experimentId: .value("exp-1")).called(1) + } +} diff --git a/Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift b/Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift index f2acadf6..30935cc0 100644 --- a/Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift +++ b/Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift @@ -1073,4 +1073,82 @@ struct RESTRoutesTests { #expect(normalized.contains("\"_links\"")) #expect(normalized.contains("/api/v1/iris/review-submissions/sub-1/resolution-center")) } + // MARK: - Experiments (Product Page Optimization) + + @Test func `experiments list returns JSON with _links pointing at REST paths`() async throws { + let mockRepo = MockExperimentRepository() + given(mockRepo).listExperiments(appId: .any, state: .any, limit: .any).willReturn( + PaginatedResponse(data: [ + AppStoreVersionExperiment(id: "exp-1", appId: "app-42", name: "Icon test", platform: .iOS, + trafficProportion: 30, state: .prepareForSubmission) + ], nextCursor: nil) + ) + + let output = try await ExperimentsList.parse(["--app-id", "app-42", "--pretty"]) + .execute(repo: mockRepo, affordanceMode: .rest) + let normalized = output.replacingOccurrences(of: "\\/", with: "/") + + #expect(normalized.contains("\"_links\"")) + #expect(normalized.contains("/api/v1/apps/app-42/experiments")) + #expect(normalized.contains("/api/v1/experiments/exp-1/experiment-treatments")) + #expect(normalized.contains("\"href\" : \"/api/v1/experiments/exp-1\"")) + #expect(!normalized.contains("\"affordances\"")) + } + + @Test func `experiments REST exposes start link for approved unstarted test`() async throws { + let mockRepo = MockExperimentRepository() + given(mockRepo).getExperiment(experimentId: .any).willReturn( + AppStoreVersionExperiment(id: "exp-1", appId: "app-42", name: "Icon test", platform: .iOS, + trafficProportion: 30, state: .approved) + ) + + let output = try await ExperimentsGet.parse(["--experiment-id", "exp-1", "--pretty"]) + .execute(repo: mockRepo, affordanceMode: .rest) + let normalized = output.replacingOccurrences(of: "\\/", with: "/") + + #expect(normalized.contains("/api/v1/experiments/exp-1/start")) + #expect(!normalized.contains("/api/v1/experiments/exp-1/stop")) + } + + @Test func `experiment treatments REST exposes nested path under experiment`() async throws { + let mockRepo = MockExperimentRepository() + given(mockRepo).listTreatments(experimentId: .any, limit: .any).willReturn( + PaginatedResponse(data: [ExperimentTreatment(id: "trt-1", experimentId: "exp-7", name: "Blue icon")], nextCursor: nil) + ) + + let output = try await ExperimentTreatmentsList.parse(["--experiment-id", "exp-7", "--pretty"]) + .execute(repo: mockRepo, affordanceMode: .rest) + let normalized = output.replacingOccurrences(of: "\\/", with: "/") + + #expect(normalized.contains("\"_links\"")) + #expect(normalized.contains("/api/v1/experiments/exp-7/experiment-treatments")) + #expect(normalized.contains("/api/v1/experiment-treatments/trt-1/experiment-treatment-localizations")) + #expect(normalized.contains("\"href\" : \"/api/v1/experiment-treatments/trt-1\"")) + } + + @Test func `experiment treatment localizations REST exposes nested path under treatment`() async throws { + let mockRepo = MockExperimentRepository() + given(mockRepo).listTreatmentLocalizations(treatmentId: .any, limit: .any).willReturn([ + ExperimentTreatmentLocalization(id: "loc-1", treatmentId: "trt-7", locale: "en-US") + ]) + + let output = try await ExperimentTreatmentLocalizationsList.parse(["--treatment-id", "trt-7", "--pretty"]) + .execute(repo: mockRepo, affordanceMode: .rest) + let normalized = output.replacingOccurrences(of: "\\/", with: "/") + + #expect(normalized.contains("\"_links\"")) + #expect(normalized.contains("/api/v1/experiment-treatments/trt-7/experiment-treatment-localizations")) + #expect(normalized.contains("\"href\" : \"/api/v1/experiment-treatment-localizations/loc-1\"")) + } + + @Test func `app REST links include experiments`() async throws { + let mockRepo = MockAppRepository() + given(mockRepo).listApps(limit: .any).willReturn( + PaginatedResponse(data: [App(id: "42", name: "MyApp", bundleId: "com.test")]) + ) + let output = try await AppsList.parse(["--pretty"]).execute(repo: mockRepo, affordanceMode: .rest) + let normalized = output.replacingOccurrences(of: "\\/", with: "/") + + #expect(normalized.contains("/api/v1/apps/42/experiments")) + } } diff --git a/Tests/ASCCommandTests/OutputFormatterTests.swift b/Tests/ASCCommandTests/OutputFormatterTests.swift index ead76bab..add5a52f 100644 --- a/Tests/ASCCommandTests/OutputFormatterTests.swift +++ b/Tests/ASCCommandTests/OutputFormatterTests.swift @@ -82,6 +82,7 @@ struct OutputFormatterTests { "affordances" : { "createVersion" : "asc versions create --app-id 1", "listAppInfos" : "asc app-infos list --app-id 1", + "listExperiments" : "asc experiments list --app-id 1", "listReviews" : "asc reviews list --app-id 1", "listVersions" : "asc versions list --app-id 1", "updateContentRights" : "asc apps update --app-id 1" @@ -111,6 +112,7 @@ struct OutputFormatterTests { "affordances" : { "createVersion" : "asc versions create --app-id app-1", "listAppInfos" : "asc app-infos list --app-id app-1", + "listExperiments" : "asc experiments list --app-id app-1", "listReviews" : "asc reviews list --app-id app-1", "listVersions" : "asc versions list --app-id app-1", "updateContentRights" : "asc apps update --app-id app-1" diff --git a/Tests/DomainTests/Apps/AffordancesTests.swift b/Tests/DomainTests/Apps/AffordancesTests.swift index b9a27e64..6bb9d6d4 100644 --- a/Tests/DomainTests/Apps/AffordancesTests.swift +++ b/Tests/DomainTests/Apps/AffordancesTests.swift @@ -83,6 +83,13 @@ struct AffordancesTests { // MARK: - App affordances (extended) + @Test + func `app affordances include listExperiments command`() { + let app = App(id: "app-1", name: "My App", bundleId: "com.example") + #expect(app.affordances["listExperiments"] == "asc experiments list --app-id app-1") + #expect(app.apiLinks["listExperiments"]?.href == "/api/v1/apps/app-1/experiments") + } + @Test func `app affordances include listAppInfos command`() { let app = App(id: "app-1", name: "My App", bundleId: "com.example") diff --git a/Tests/DomainTests/Apps/Experiments/AppStoreVersionExperimentTests.swift b/Tests/DomainTests/Apps/Experiments/AppStoreVersionExperimentTests.swift new file mode 100644 index 00000000..21b73eb9 --- /dev/null +++ b/Tests/DomainTests/Apps/Experiments/AppStoreVersionExperimentTests.swift @@ -0,0 +1,119 @@ +import Foundation +import Testing +@testable import Domain + +@Suite +struct AppStoreVersionExperimentTests { + + @Test func `experiment carries appId, name, platform and traffic proportion`() { + let exp = MockRepositoryFactory.makeExperiment(id: "exp-1", appId: "app-1", name: "Icon test", trafficProportion: 30) + #expect(exp.appId == "app-1") + #expect(exp.name == "Icon test") + #expect(exp.platform == .iOS) + #expect(exp.trafficProportion == 30) + } + + @Test func `experiment state is editable before submission, ready for review, or rejected`() { + #expect(AppStoreVersionExperimentState.prepareForSubmission.isEditable == true) + #expect(AppStoreVersionExperimentState.readyForReview.isEditable == true) + #expect(AppStoreVersionExperimentState.rejected.isEditable == true) + #expect(AppStoreVersionExperimentState.waitingForReview.isEditable == false) + #expect(AppStoreVersionExperimentState.inReview.isEditable == false) + #expect(AppStoreVersionExperimentState.accepted.isEditable == false) + #expect(AppStoreVersionExperimentState.approved.isEditable == false) + #expect(AppStoreVersionExperimentState.completed.isEditable == false) + #expect(AppStoreVersionExperimentState.stopped.isEditable == false) + } + + @Test func `experiment state is pending review while waiting for or in review`() { + #expect(AppStoreVersionExperimentState.waitingForReview.isPendingReview == true) + #expect(AppStoreVersionExperimentState.inReview.isPendingReview == true) + #expect(AppStoreVersionExperimentState.approved.isPendingReview == false) + } + + @Test func `experiment state is approved when accepted or approved`() { + #expect(AppStoreVersionExperimentState.accepted.isApproved == true) + #expect(AppStoreVersionExperimentState.approved.isApproved == true) + #expect(AppStoreVersionExperimentState.readyForReview.isApproved == false) + } + + @Test func `experiment state is finished when completed or stopped`() { + #expect(AppStoreVersionExperimentState.completed.isFinished == true) + #expect(AppStoreVersionExperimentState.stopped.isFinished == true) + #expect(AppStoreVersionExperimentState.approved.isFinished == false) + } + + @Test func `experiment is running when approved and started but not ended`() { + let running = MockRepositoryFactory.makeExperiment(state: .approved, startDate: "2026-09-01T00:00:00Z") + #expect(running.isRunning == true) + #expect(running.canStart == false) + + let notStarted = MockRepositoryFactory.makeExperiment(state: .approved) + #expect(notStarted.isRunning == false) + #expect(notStarted.canStart == true) + + let ended = MockRepositoryFactory.makeExperiment(state: .completed, startDate: "2026-09-01T00:00:00Z", endDate: "2026-09-20T00:00:00Z") + #expect(ended.isRunning == false) + #expect(ended.canStart == false) + } + + @Test func `editable experiment offers treatments, update and delete`() { + let exp = MockRepositoryFactory.makeExperiment(id: "exp-1", appId: "app-1", state: .prepareForSubmission) + #expect(exp.affordances["listSiblings"] == "asc experiments list --app-id app-1") + #expect(exp.affordances["listTreatments"] == "asc experiment-treatments list --experiment-id exp-1") + #expect(exp.affordances["createTreatment"] == "asc experiment-treatments create --experiment-id exp-1 --name ") + #expect(exp.affordances["update"] == "asc experiments update --experiment-id exp-1") + #expect(exp.affordances["delete"] == "asc experiments delete --experiment-id exp-1") + #expect(exp.affordances["start"] == nil) + #expect(exp.affordances["stop"] == nil) + } + + @Test func `approved but unstarted experiment offers start only`() { + let exp = MockRepositoryFactory.makeExperiment(id: "exp-1", appId: "app-1", state: .approved) + #expect(exp.affordances["start"] == "asc experiments start --experiment-id exp-1") + #expect(exp.affordances["stop"] == nil) + #expect(exp.affordances["update"] == nil) + #expect(exp.affordances["delete"] == nil) + #expect(exp.affordances["createTreatment"] == nil) + } + + @Test func `running experiment offers stop only`() { + let exp = MockRepositoryFactory.makeExperiment(id: "exp-1", appId: "app-1", state: .approved, startDate: "2026-09-01T00:00:00Z") + #expect(exp.affordances["stop"] == "asc experiments stop --experiment-id exp-1") + #expect(exp.affordances["start"] == nil) + #expect(exp.affordances["update"] == nil) + #expect(exp.affordances["delete"] == nil) + } + + @Test func `experiment in review offers no mutations`() { + let exp = MockRepositoryFactory.makeExperiment(id: "exp-1", appId: "app-1", state: .inReview) + #expect(exp.affordances["update"] == nil) + #expect(exp.affordances["delete"] == nil) + #expect(exp.affordances["start"] == nil) + #expect(exp.affordances["stop"] == nil) + #expect(exp.affordances["listTreatments"] == "asc experiment-treatments list --experiment-id exp-1") + } + + @Test func `experiment apiLinks resolve to REST paths`() { + let exp = MockRepositoryFactory.makeExperiment(id: "exp-1", appId: "app-1", state: .prepareForSubmission) + #expect(exp.apiLinks["listSiblings"]?.href == "/api/v1/apps/app-1/experiments") + #expect(exp.apiLinks["listTreatments"]?.href == "/api/v1/experiments/exp-1/experiment-treatments") + #expect(exp.apiLinks["update"]?.href == "/api/v1/experiments/exp-1") + #expect(exp.apiLinks["update"]?.method == "PATCH") + #expect(exp.apiLinks["delete"]?.method == "DELETE") + } + + @Test func `experiment omits nil dates and control version from JSON`() throws { + let exp = MockRepositoryFactory.makeExperiment(id: "exp-1", appId: "app-1") + let json = String(decoding: try JSONEncoder().encode(exp), as: UTF8.self) + #expect(!json.contains("startDate")) + #expect(!json.contains("endDate")) + #expect(!json.contains("latestControlVersionId")) + } + + @Test func `experiment table row shows name, platform, traffic and state`() { + let exp = MockRepositoryFactory.makeExperiment(id: "exp-1", appId: "app-1", name: "Icon test", trafficProportion: 30, state: .approved) + #expect(AppStoreVersionExperiment.tableHeaders == ["ID", "Name", "Platform", "Traffic %", "State", "Started"]) + #expect(exp.tableRow == ["exp-1", "Icon test", "iOS", "30", "APPROVED", "-"]) + } +} diff --git a/Tests/DomainTests/Apps/Experiments/ExperimentTreatmentLocalizationTests.swift b/Tests/DomainTests/Apps/Experiments/ExperimentTreatmentLocalizationTests.swift new file mode 100644 index 00000000..727f7e9c --- /dev/null +++ b/Tests/DomainTests/Apps/Experiments/ExperimentTreatmentLocalizationTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing +@testable import Domain + +@Suite +struct ExperimentTreatmentLocalizationTests { + + @Test func `treatment localization carries treatmentId and locale`() { + let loc = MockRepositoryFactory.makeExperimentTreatmentLocalization(id: "loc-1", treatmentId: "trt-1", locale: "en-US") + #expect(loc.treatmentId == "trt-1") + #expect(loc.locale == "en-US") + } + + @Test func `treatment localization offers siblings and delete`() { + let loc = MockRepositoryFactory.makeExperimentTreatmentLocalization(id: "loc-1", treatmentId: "trt-1") + #expect(loc.affordances["listSiblings"] == "asc experiment-treatment-localizations list --treatment-id trt-1") + #expect(loc.affordances["delete"] == "asc experiment-treatment-localizations delete --localization-id loc-1") + } + + @Test func `treatment localization apiLinks resolve to REST paths`() { + let loc = MockRepositoryFactory.makeExperimentTreatmentLocalization(id: "loc-1", treatmentId: "trt-1") + #expect(loc.apiLinks["listSiblings"]?.href == "/api/v1/experiment-treatments/trt-1/experiment-treatment-localizations") + #expect(loc.apiLinks["delete"]?.href == "/api/v1/experiment-treatment-localizations/loc-1") + #expect(loc.apiLinks["delete"]?.method == "DELETE") + } + + @Test func `treatment localization table row shows locale`() { + let loc = MockRepositoryFactory.makeExperimentTreatmentLocalization(id: "loc-1", treatmentId: "trt-1", locale: "de-DE") + #expect(ExperimentTreatmentLocalization.tableHeaders == ["ID", "Locale"]) + #expect(loc.tableRow == ["loc-1", "de-DE"]) + } +} diff --git a/Tests/DomainTests/Apps/Experiments/ExperimentTreatmentTests.swift b/Tests/DomainTests/Apps/Experiments/ExperimentTreatmentTests.swift new file mode 100644 index 00000000..1afd928c --- /dev/null +++ b/Tests/DomainTests/Apps/Experiments/ExperimentTreatmentTests.swift @@ -0,0 +1,44 @@ +import Foundation +import Testing +@testable import Domain + +@Suite +struct ExperimentTreatmentTests { + + @Test func `treatment carries experimentId and name`() { + let t = MockRepositoryFactory.makeExperimentTreatment(id: "trt-1", experimentId: "exp-1", name: "Blue icon", appIconName: "AppIcon-Blue") + #expect(t.experimentId == "exp-1") + #expect(t.name == "Blue icon") + #expect(t.appIconName == "AppIcon-Blue") + } + + @Test func `treatment offers siblings, localizations, update and delete`() { + let t = MockRepositoryFactory.makeExperimentTreatment(id: "trt-1", experimentId: "exp-1") + #expect(t.affordances["listSiblings"] == "asc experiment-treatments list --experiment-id exp-1") + #expect(t.affordances["listLocalizations"] == "asc experiment-treatment-localizations list --treatment-id trt-1") + #expect(t.affordances["createLocalization"] == "asc experiment-treatment-localizations create --locale --treatment-id trt-1") + #expect(t.affordances["update"] == "asc experiment-treatments update --treatment-id trt-1") + #expect(t.affordances["delete"] == "asc experiment-treatments delete --treatment-id trt-1") + } + + @Test func `treatment apiLinks resolve to REST paths`() { + let t = MockRepositoryFactory.makeExperimentTreatment(id: "trt-1", experimentId: "exp-1") + #expect(t.apiLinks["listSiblings"]?.href == "/api/v1/experiments/exp-1/experiment-treatments") + #expect(t.apiLinks["listLocalizations"]?.href == "/api/v1/experiment-treatments/trt-1/experiment-treatment-localizations") + #expect(t.apiLinks["update"]?.href == "/api/v1/experiment-treatments/trt-1") + #expect(t.apiLinks["delete"]?.method == "DELETE") + } + + @Test func `treatment omits nil appIconName and promotedDate from JSON`() throws { + let t = MockRepositoryFactory.makeExperimentTreatment(id: "trt-1", experimentId: "exp-1") + let json = String(decoding: try JSONEncoder().encode(t), as: UTF8.self) + #expect(!json.contains("appIconName")) + #expect(!json.contains("promotedDate")) + } + + @Test func `treatment table row shows name and icon`() { + let t = MockRepositoryFactory.makeExperimentTreatment(id: "trt-1", experimentId: "exp-1", name: "Blue icon", appIconName: "AppIcon-Blue") + #expect(ExperimentTreatment.tableHeaders == ["ID", "Name", "App Icon", "Promoted"]) + #expect(t.tableRow == ["trt-1", "Blue icon", "AppIcon-Blue", "-"]) + } +} diff --git a/Tests/DomainTests/TestHelpers/MockRepositoryFactory.swift b/Tests/DomainTests/TestHelpers/MockRepositoryFactory.swift index 97901bf8..7e991ae9 100644 --- a/Tests/DomainTests/TestHelpers/MockRepositoryFactory.swift +++ b/Tests/DomainTests/TestHelpers/MockRepositoryFactory.swift @@ -1390,3 +1390,45 @@ struct MockRepositoryFactory { ) } } + +// MARK: - Experiments (Product Page Optimization) + +extension MockRepositoryFactory { + static func makeExperiment( + id: String = "exp-1", + appId: String = "app-1", + name: String = "Test", + platform: AppStorePlatform = .iOS, + trafficProportion: Int = 50, + state: AppStoreVersionExperimentState = .prepareForSubmission, + isReviewRequired: Bool = true, + startDate: String? = nil, + endDate: String? = nil, + latestControlVersionId: String? = nil + ) -> AppStoreVersionExperiment { + AppStoreVersionExperiment( + id: id, appId: appId, name: name, platform: platform, + trafficProportion: trafficProportion, state: state, + isReviewRequired: isReviewRequired, startDate: startDate, endDate: endDate, + latestControlVersionId: latestControlVersionId + ) + } + + static func makeExperimentTreatment( + id: String = "trt-1", + experimentId: String = "exp-1", + name: String = "Treatment A", + appIconName: String? = nil, + promotedDate: String? = nil + ) -> ExperimentTreatment { + ExperimentTreatment(id: id, experimentId: experimentId, name: name, appIconName: appIconName, promotedDate: promotedDate) + } + + static func makeExperimentTreatmentLocalization( + id: String = "loc-1", + treatmentId: String = "trt-1", + locale: String = "en-US" + ) -> ExperimentTreatmentLocalization { + ExperimentTreatmentLocalization(id: id, treatmentId: treatmentId, locale: locale) + } +} diff --git a/Tests/InfrastructureTests/Apps/Experiments/SDKExperimentRepositoryTests.swift b/Tests/InfrastructureTests/Apps/Experiments/SDKExperimentRepositoryTests.swift new file mode 100644 index 00000000..380a4961 --- /dev/null +++ b/Tests/InfrastructureTests/Apps/Experiments/SDKExperimentRepositoryTests.swift @@ -0,0 +1,265 @@ +@preconcurrency import AppStoreConnect_Swift_SDK +import Foundation +import Testing +@testable import Infrastructure +@testable import Domain + +@Suite +struct SDKExperimentRepositoryTests { + + private func sdkExperiment( + id: String = "exp-1", + state: AppStoreVersionExperimentV2.Attributes.State = .prepareForSubmission, + startDate: Date? = nil + ) -> AppStoreVersionExperimentV2 { + AppStoreVersionExperimentV2( + type: .appStoreVersionExperiments, id: id, + attributes: .init(name: "Icon test", platform: .ios, trafficProportion: 30, + state: state, isReviewRequired: true, startDate: startDate), + relationships: .init(latestControlVersion: .init(data: .init(type: .appStoreVersions, id: "ver-9"))) + ) + } + + // MARK: - Experiments + + @Test func `listExperiments injects appId and maps attributes`() async throws { + let stub = StubAPIClient() + stub.willReturn(AppStoreVersionExperimentsV2Response( + data: [sdkExperiment(id: "exp-1"), sdkExperiment(id: "exp-2")], + links: .init(this: "") + )) + + let repo = SDKExperimentRepository(client: stub) + let result = try await repo.listExperiments(appId: "app-99", state: nil, limit: nil) + + #expect(result.data.count == 2) + #expect(result.data.allSatisfy { $0.appId == "app-99" }) + #expect(result.data[0].name == "Icon test") + #expect(result.data[0].platform == .iOS) + #expect(result.data[0].trafficProportion == 30) + #expect(result.data[0].state == .prepareForSubmission) + #expect(result.data[0].isReviewRequired == true) + #expect(result.data[0].latestControlVersionId == "ver-9") + #expect(stub.lastPath == "/v1/apps/app-99/appStoreVersionExperimentsV2") + } + + @Test func `listExperiments passes state filter and limit as query`() async throws { + let stub = StubAPIClient() + stub.willReturn(AppStoreVersionExperimentsV2Response(data: [], links: .init(this: ""))) + + let repo = SDKExperimentRepository(client: stub) + _ = try await repo.listExperiments(appId: "app-1", state: .approved, limit: 5) + + let query = Dictionary(uniqueKeysWithValues: (stub.lastQuery ?? []).map { ($0.0, $0.1 ?? "") }) + #expect(query["filter[state]"] == "APPROVED") + #expect(query["limit"] == "5") + } + + @Test func `listExperiments formats startDate as ISO-8601`() async throws { + let stub = StubAPIClient() + stub.willReturn(AppStoreVersionExperimentsV2Response( + data: [sdkExperiment(state: .approved, startDate: Date(timeIntervalSince1970: 1_756_684_800))], + links: .init(this: "") + )) + + let repo = SDKExperimentRepository(client: stub) + let result = try await repo.listExperiments(appId: "app-1", state: nil, limit: nil) + + #expect(result.data[0].startDate == "2025-09-01T00:00:00Z") + #expect(result.data[0].isRunning == true) + } + + @Test func `getExperiment resolves appId from the app relationship`() async throws { + let stub = StubAPIClient() + var sdk = sdkExperiment(id: "exp-7") + sdk.relationships?.app = .init(data: .init(type: .apps, id: "app-3")) + stub.willReturn(AppStoreVersionExperimentV2Response(data: sdk, links: .init(this: ""))) + + let repo = SDKExperimentRepository(client: stub) + let exp = try await repo.getExperiment(experimentId: "exp-7") + + #expect(exp.id == "exp-7") + #expect(exp.appId == "app-3") + #expect(stub.lastPath == "/v2/appStoreVersionExperiments/exp-7") + } + + @Test func `createExperiment injects appId and posts to v2`() async throws { + let stub = StubAPIClient() + stub.willReturn(AppStoreVersionExperimentV2Response(data: sdkExperiment(id: "exp-new"), links: .init(this: ""))) + + let repo = SDKExperimentRepository(client: stub) + let exp = try await repo.createExperiment(appId: "app-1", name: "Icon test", platform: .iOS, trafficProportion: 30) + + #expect(exp.id == "exp-new") + #expect(exp.appId == "app-1") + #expect(stub.lastPath == "/v2/appStoreVersionExperiments") + } + + @Test func `updateExperiment patches v2 and keeps appId from relationship`() async throws { + let stub = StubAPIClient() + var sdk = sdkExperiment(id: "exp-1", state: .approved, startDate: Date(timeIntervalSince1970: 0)) + sdk.relationships?.app = .init(data: .init(type: .apps, id: "app-1")) + stub.willReturn(AppStoreVersionExperimentV2Response(data: sdk, links: .init(this: ""))) + + let repo = SDKExperimentRepository(client: stub) + let exp = try await repo.updateExperiment(experimentId: "exp-1", name: nil, trafficProportion: nil, isStarted: true) + + #expect(exp.appId == "app-1") + #expect(exp.isRunning == true) + #expect(stub.lastPath == "/v2/appStoreVersionExperiments/exp-1") + } + + @Test func `getExperiment asks Apple to include the app relationship`() async throws { + let stub = StubAPIClient() + var sdk = sdkExperiment(id: "exp-7") + sdk.relationships?.app = .init(data: .init(type: .apps, id: "app-3")) + stub.willReturn(AppStoreVersionExperimentV2Response(data: sdk, links: .init(this: ""))) + + let repo = SDKExperimentRepository(client: stub) + _ = try await repo.getExperiment(experimentId: "exp-7") + + // Apple omits `relationships.app` from GET unless `include=app` is requested. + let query = Dictionary(uniqueKeysWithValues: (stub.lastQuery ?? []).map { ($0.0, $0.1 ?? "") }) + #expect(query["include"] == "app") + } + + @Test func `updateExperiment re-fetches with the app relationship so appId is never empty`() async throws { + let stub = StubAPIClient() + var sdk = sdkExperiment(id: "exp-1") + sdk.relationships?.app = .init(data: .init(type: .apps, id: "app-1")) + stub.willReturn(AppStoreVersionExperimentV2Response(data: sdk, links: .init(this: ""))) + + let repo = SDKExperimentRepository(client: stub) + let exp = try await repo.updateExperiment(experimentId: "exp-1", name: "Renamed", trafficProportion: nil, isStarted: nil) + + // PATCH responses carry no relationships; the adapter follows up with GET ?include=app. + let query = Dictionary(uniqueKeysWithValues: (stub.lastQuery ?? []).map { ($0.0, $0.1 ?? "") }) + #expect(query["include"] == "app") + #expect(exp.appId == "app-1") + } + + @Test func `deleteExperiment issues a void request`() async throws { + let stub = StubAPIClient() + let repo = SDKExperimentRepository(client: stub) + try await repo.deleteExperiment(experimentId: "exp-1") + #expect(stub.voidRequestCalled == true) + } + + // MARK: - Treatments + + private func sdkTreatment(id: String = "trt-1", promotedDate: Date? = nil) -> AppStoreVersionExperimentTreatment { + AppStoreVersionExperimentTreatment( + type: .appStoreVersionExperimentTreatments, id: id, + attributes: .init(name: "Blue icon", appIconName: "AppIcon-Blue", promotedDate: promotedDate) + ) + } + + @Test func `listTreatments injects experimentId and maps icon name`() async throws { + let stub = StubAPIClient() + stub.willReturn(AppStoreVersionExperimentTreatmentsResponse( + data: [sdkTreatment(id: "trt-1"), sdkTreatment(id: "trt-2")], + links: .init(this: "") + )) + + let repo = SDKExperimentRepository(client: stub) + let result = try await repo.listTreatments(experimentId: "exp-5", limit: nil) + + #expect(result.data.count == 2) + #expect(result.data.allSatisfy { $0.experimentId == "exp-5" }) + #expect(result.data[0].name == "Blue icon") + #expect(result.data[0].appIconName == "AppIcon-Blue") + #expect(result.data[0].promotedDate == nil) + #expect(stub.lastPath == "/v2/appStoreVersionExperiments/exp-5/appStoreVersionExperimentTreatments") + } + + @Test func `createTreatment injects experimentId`() async throws { + let stub = StubAPIClient() + stub.willReturn(AppStoreVersionExperimentTreatmentResponse(data: sdkTreatment(id: "trt-new"), links: .init(this: ""))) + + let repo = SDKExperimentRepository(client: stub) + let t = try await repo.createTreatment(experimentId: "exp-1", name: "Blue icon", appIconName: "AppIcon-Blue") + + #expect(t.id == "trt-new") + #expect(t.experimentId == "exp-1") + #expect(stub.lastPath == "/v1/appStoreVersionExperimentTreatments") + } + + @Test func `updateTreatment resolves experimentId from the v2 relationship`() async throws { + let stub = StubAPIClient() + var sdk = sdkTreatment(id: "trt-1") + sdk.relationships = .init(appStoreVersionExperimentV2: .init(data: .init(type: .appStoreVersionExperiments, id: "exp-4"))) + stub.willReturn(AppStoreVersionExperimentTreatmentResponse(data: sdk, links: .init(this: ""))) + + let repo = SDKExperimentRepository(client: stub) + let t = try await repo.updateTreatment(treatmentId: "trt-1", name: "Red icon", appIconName: nil) + + #expect(t.experimentId == "exp-4") + #expect(stub.lastPath == "/v1/appStoreVersionExperimentTreatments/trt-1") + } + + @Test func `updateTreatment re-fetches with the experiment relationship so experimentId is never empty`() async throws { + let stub = StubAPIClient() + var sdk = sdkTreatment(id: "trt-1") + sdk.relationships = .init(appStoreVersionExperimentV2: .init(data: .init(type: .appStoreVersionExperiments, id: "exp-4"))) + stub.willReturn(AppStoreVersionExperimentTreatmentResponse(data: sdk, links: .init(this: ""))) + + let repo = SDKExperimentRepository(client: stub) + let t = try await repo.updateTreatment(treatmentId: "trt-1", name: "Red icon", appIconName: nil) + + let query = Dictionary(uniqueKeysWithValues: (stub.lastQuery ?? []).map { ($0.0, $0.1 ?? "") }) + #expect(query["include"] == "appStoreVersionExperimentV2") + #expect(t.experimentId == "exp-4") + } + + @Test func `deleteTreatment issues a void request`() async throws { + let stub = StubAPIClient() + let repo = SDKExperimentRepository(client: stub) + try await repo.deleteTreatment(treatmentId: "trt-1") + #expect(stub.voidRequestCalled == true) + } + + // MARK: - Treatment localizations + + private func sdkLocalization(id: String = "loc-1", locale: String = "en-US") -> AppStoreVersionExperimentTreatmentLocalization { + AppStoreVersionExperimentTreatmentLocalization( + type: .appStoreVersionExperimentTreatmentLocalizations, id: id, + attributes: .init(locale: locale) + ) + } + + @Test func `listTreatmentLocalizations injects treatmentId and maps locale`() async throws { + let stub = StubAPIClient() + stub.willReturn(AppStoreVersionExperimentTreatmentLocalizationsResponse( + data: [sdkLocalization(id: "loc-1", locale: "en-US"), sdkLocalization(id: "loc-2", locale: "de-DE")], + links: .init(this: "") + )) + + let repo = SDKExperimentRepository(client: stub) + let locs = try await repo.listTreatmentLocalizations(treatmentId: "trt-3", limit: nil) + + #expect(locs.count == 2) + #expect(locs.allSatisfy { $0.treatmentId == "trt-3" }) + #expect(locs[1].locale == "de-DE") + #expect(stub.lastPath == "/v1/appStoreVersionExperimentTreatments/trt-3/appStoreVersionExperimentTreatmentLocalizations") + } + + @Test func `createTreatmentLocalization injects treatmentId`() async throws { + let stub = StubAPIClient() + stub.willReturn(AppStoreVersionExperimentTreatmentLocalizationResponse(data: sdkLocalization(id: "loc-new", locale: "fr-FR"), links: .init(this: ""))) + + let repo = SDKExperimentRepository(client: stub) + let loc = try await repo.createTreatmentLocalization(treatmentId: "trt-1", locale: "fr-FR") + + #expect(loc.id == "loc-new") + #expect(loc.treatmentId == "trt-1") + #expect(loc.locale == "fr-FR") + #expect(stub.lastPath == "/v1/appStoreVersionExperimentTreatmentLocalizations") + } + + @Test func `deleteTreatmentLocalization issues a void request`() async throws { + let stub = StubAPIClient() + let repo = SDKExperimentRepository(client: stub) + try await repo.deleteTreatmentLocalization(localizationId: "loc-1") + #expect(stub.voidRequestCalled == true) + } +} diff --git a/docs/features/product-page-optimization.md b/docs/features/product-page-optimization.md new file mode 100644 index 00000000..d7027db1 --- /dev/null +++ b/docs/features/product-page-optimization.md @@ -0,0 +1,369 @@ +# Product Page Optimization (Experiments) + +App Store **Product Page Optimization** tests — run up to three alternate product pages (treatments) against the original and let the App Store split traffic between them. Apple's API calls these `appStoreVersionExperiments` (v2); the CLI uses `experiments`. + +A test is app-scoped (it is not attached to a specific version) and can be created when the app is **Ready for Distribution** or **Pre-Order Ready for Distribution**. Each treatment can test an alternate app icon (`--app-icon-name`) and, per locale, its own screenshot / preview sets. + +Resource hierarchy: + +``` +App → AppStoreVersionExperiment → ExperimentTreatment → ExperimentTreatmentLocalization +``` + +## 1. CLI Usage + +### `asc experiments` + +| Command | Required flags | Optional flags | Notes | +|---------|----------------|----------------|-------| +| `asc experiments list` | `--app-id` | `--state `, `--limit ` | `--state` accepts any `AppStoreVersionExperimentState` raw value (case-insensitive) | +| `asc experiments get` | `--experiment-id` | | | +| `asc experiments create` | `--app-id`, `--name`, `--traffic-proportion <1-100>` | `--platform ios\|macos\|tvos\|visionos` (default `ios`) | Traffic proportion is validated client-side | +| `asc experiments update` | `--experiment-id` | `--name`, `--traffic-proportion` | At least one field required | +| `asc experiments start` | `--experiment-id` | | `PATCH { started: true }` | +| `asc experiments stop` | `--experiment-id` | | `PATCH { started: false }` | +| `asc experiments delete` | `--experiment-id` | | | + +### `asc experiment-treatments` + +| Command | Required flags | Optional flags | +|---------|----------------|----------------| +| `asc experiment-treatments list` | `--experiment-id` | `--limit ` | +| `asc experiment-treatments create` | `--experiment-id`, `--name` | `--app-icon-name ` | +| `asc experiment-treatments update` | `--treatment-id` | `--name`, `--app-icon-name` (at least one) | +| `asc experiment-treatments delete` | `--treatment-id` | | + +### `asc experiment-treatment-localizations` + +| Command | Required flags | Optional flags | +|---------|----------------|----------------| +| `asc experiment-treatment-localizations list` | `--treatment-id` | `--limit ` | +| `asc experiment-treatment-localizations create` | `--treatment-id`, `--locale` | | +| `asc experiment-treatment-localizations delete` | `--localization-id` | | + +### Examples + +```bash +asc experiments list --app-id 6792459661 --pretty +asc experiments list --app-id 6792459661 --state APPROVED --output table +asc experiments create --app-id 6792459661 --name "Icon test" --platform ios --traffic-proportion 30 +asc experiment-treatments create --experiment-id exp-1 --name "Blue icon" --app-icon-name AppIcon-Blue +asc experiment-treatment-localizations create --treatment-id trt-1 --locale en-US +asc experiments start --experiment-id exp-1 +``` + +### Output samples + +JSON (`--pretty`) for an editable test: + +```json +{ + "data" : [ + { + "affordances" : { + "createTreatment" : "asc experiment-treatments create --experiment-id exp-1 --name ", + "delete" : "asc experiments delete --experiment-id exp-1", + "listSiblings" : "asc experiments list --app-id app-1", + "listTreatments" : "asc experiment-treatments list --experiment-id exp-1", + "update" : "asc experiments update --experiment-id exp-1" + }, + "appId" : "app-1", + "canStart" : false, + "id" : "exp-1", + "isReviewRequired" : true, + "isRunning" : false, + "name" : "Icon test", + "platform" : "IOS", + "state" : "PREPARE_FOR_SUBMISSION", + "trafficProportion" : 30 + } + ] +} +``` + +`startDate`, `endDate` and `latestControlVersionId` are omitted when nil. + +Table (`--output table`): + +``` +ID Name Platform Traffic % State Started +----- --------- -------- --------- ---------------------- ------- +exp-1 Icon test iOS 30 PREPARE_FOR_SUBMISSION - +``` + +## 2. REST Endpoints + +| Path | Method | Body / query | +|------|--------|--------------| +| `/api/v1/apps/:appId/experiments` | GET | `?state=&limit=` | +| `/api/v1/apps/:appId/experiments` | POST | `{ "name", "trafficProportion", "platform"? }` | +| `/api/v1/experiments/:experimentId` | GET | | +| `/api/v1/experiments/:experimentId` | PATCH | `{ "name"?, "trafficProportion"? }` | +| `/api/v1/experiments/:experimentId/start` | POST | | +| `/api/v1/experiments/:experimentId/stop` | POST | | +| `/api/v1/experiments/:experimentId` | DELETE | | +| `/api/v1/experiments/:experimentId/experiment-treatments` | GET | `?limit=` | +| `/api/v1/experiments/:experimentId/experiment-treatments` | POST | `{ "name", "appIconName"? }` | +| `/api/v1/experiment-treatments/:treatmentId` | PATCH | `{ "name"?, "appIconName"? }` | +| `/api/v1/experiment-treatments/:treatmentId` | DELETE | | +| `/api/v1/experiment-treatments/:treatmentId/experiment-treatment-localizations` | GET | `?limit=` | +| `/api/v1/experiment-treatments/:treatmentId/experiment-treatment-localizations` | POST | `{ "locale" }` | +| `/api/v1/experiment-treatment-localizations/:localizationId` | DELETE | | + +Query-param mapping (CLI flag → REST query): + +| CLI flag | REST query | +|----------|------------| +| `--state` | `?state=` | +| `--limit` | `?limit=` | + +Discovery: `GET /api/v1/apps` items carry `_links.listExperiments` → `/api/v1/apps/:appId/experiments`. Each experiment's `_links` include `listTreatments`, and conditionally `update`/`delete`/`createTreatment` (editable), `start` (approved, not started) or `stop` (running). + +```bash +curl "http://127.0.0.1:8420/api/v1/apps/6792459661/experiments?state=APPROVED&limit=5" +curl -X POST -H 'Content-Type: application/json' \ + -d '{"name":"Icon test","platform":"ios","trafficProportion":30}' \ + http://127.0.0.1:8420/api/v1/apps/6792459661/experiments +curl -X POST http://127.0.0.1:8420/api/v1/experiments/exp-1/start +``` + +## 3. Typical Workflow + +```bash +APP_ID=6792459661 + +# 1. Create the test (app must be live / pre-order ready) +EXP=$(asc experiments create --app-id $APP_ID --name "Icon test" --traffic-proportion 30 \ + | jq -r '.data[0].id') + +# 2. Add up to three treatments +TRT=$(asc experiment-treatments create --experiment-id $EXP --name "Blue icon" --app-icon-name AppIcon-Blue \ + | jq -r '.data[0].id') + +# 3. Choose the locales included in the treatment +asc experiment-treatment-localizations create --treatment-id $TRT --locale en-US +asc experiment-treatment-localizations create --treatment-id $TRT --locale de-DE + +# 4. Submit for review (via review-submissions) and wait for state ACCEPTED/APPROVED +asc experiments get --experiment-id $EXP | jq '.data[0] | {state, canStart}' + +# 5. Start serving traffic, then stop when you have a winner (tests auto-complete after 90 days) +asc experiments start --experiment-id $EXP +asc experiments stop --experiment-id $EXP +``` + +## 4. Architecture + +``` +┌────────────────────────── ASCCommand ───────────────────────────┐ +│ Commands/Experiments/ │ +│ ExperimentsCommand.swift (3 command groups) │ +│ Experiments{List,Get,Create,Update,Start,Stop,Delete}.swift │ +│ ExperimentTreatments{List,Create,Update,Delete}.swift │ +│ ExperimentTreatmentLocalizations{List,Create,Delete}.swift │ +│ Commands/Web/Controllers/ExperimentsController.swift │ +└───────────────────────────────┬─────────────────────────────────┘ + │ ExperimentRepository (protocol) +┌────────────────────────── Infrastructure ───────────────────────┐ +│ Apps/Experiments/SDKExperimentRepository.swift │ +│ injects appId / experimentId / treatmentId into every model │ +│ formats SDK `Date` → ISO-8601 strings │ +└───────────────────────────────┬─────────────────────────────────┘ + │ implements +┌────────────────────────────── Domain ───────────────────────────┐ +│ Apps/Experiments/ │ +│ AppStoreVersionExperiment (+ AppStoreVersionExperimentState) │ +│ ExperimentTreatment │ +│ ExperimentTreatmentLocalization │ +│ ExperimentRepository (@Mockable) │ +│ AppStoreVersionExperiment+RESTRoutes │ +└──────────────────────────────────────────────────────────────────┘ +``` + +Dependencies flow strictly downward. The Domain has zero I/O; the same `execute(repo:affordanceMode:)` serves both CLI and REST. + +## 5. Domain Models + +### `AppStoreVersionExperiment` + +```swift +public struct AppStoreVersionExperiment: Sendable, Equatable, Identifiable, Codable { + public let id: String + public let appId: String // injected by Infrastructure + public let name: String + public let platform: AppStorePlatform + public let trafficProportion: Int // 1–100 + public let state: AppStoreVersionExperimentState + public let isReviewRequired: Bool + public let startDate: String? // ISO-8601 + public let endDate: String? // ISO-8601 + public let latestControlVersionId: String? + + public var isRunning: Bool // state.isApproved && startDate != nil && endDate == nil + public var canStart: Bool // state.isApproved && startDate == nil +} +``` + +`isRunning` and `canStart` are encoded into JSON output; nil optionals are omitted. + +### `AppStoreVersionExperimentState` + +| Case | Raw value | `isEditable` | `isPendingReview` | `isApproved` | `isFinished` | +|------|-----------|:---:|:---:|:---:|:---:| +| `prepareForSubmission` | `PREPARE_FOR_SUBMISSION` | ✓ | | | | +| `readyForReview` | `READY_FOR_REVIEW` | ✓ | | | | +| `waitingForReview` | `WAITING_FOR_REVIEW` | | ✓ | | | +| `inReview` | `IN_REVIEW` | | ✓ | | | +| `accepted` | `ACCEPTED` | | | ✓ | | +| `approved` | `APPROVED` | | | ✓ | | +| `rejected` | `REJECTED` | ✓ | | | | +| `completed` | `COMPLETED` | | | | ✓ | +| `stopped` | `STOPPED` | | | | ✓ | + +### Affordances (state-aware) + +| Key | Command | Shown when | +|-----|---------|------------| +| `listSiblings` | `experiments list --app-id` | always | +| `listTreatments` | `experiment-treatments list --experiment-id` | always | +| `createTreatment` | `experiment-treatments create --experiment-id --name ` | `state.isEditable` | +| `update` | `experiments update --experiment-id` | `state.isEditable` | +| `delete` | `experiments delete --experiment-id` | `state.isEditable` | +| `start` | `experiments start --experiment-id` | `canStart` | +| `stop` | `experiments stop --experiment-id` | `isRunning` | + +### `ExperimentTreatment` + +```swift +public struct ExperimentTreatment: Sendable, Equatable, Identifiable, Codable { + public let id: String + public let experimentId: String // injected by Infrastructure + public let name: String + public let appIconName: String? // alternate icon asset under test + public let promotedDate: String? // ISO-8601, set once promoted to the live page +} +``` + +Affordances: `listSiblings`, `listLocalizations`, `createLocalization` (`--locale `), `update`, `delete`. + +### `ExperimentTreatmentLocalization` + +```swift +public struct ExperimentTreatmentLocalization: Sendable, Equatable, Identifiable, Codable { + public let id: String + public let treatmentId: String // injected by Infrastructure + public let locale: String +} +``` + +Affordances: `listSiblings`, `delete`. + +### `ExperimentRepository` + +```swift +@Mockable +public protocol ExperimentRepository: Sendable { + func listExperiments(appId: String, state: AppStoreVersionExperimentState?, limit: Int?) async throws -> PaginatedResponse + func getExperiment(experimentId: String) async throws -> AppStoreVersionExperiment + func createExperiment(appId: String, name: String, platform: AppStorePlatform, trafficProportion: Int) async throws -> AppStoreVersionExperiment + func updateExperiment(experimentId: String, name: String?, trafficProportion: Int?, isStarted: Bool?) async throws -> AppStoreVersionExperiment + func deleteExperiment(experimentId: String) async throws + + func listTreatments(experimentId: String, limit: Int?) async throws -> PaginatedResponse + func createTreatment(experimentId: String, name: String, appIconName: String?) async throws -> ExperimentTreatment + func updateTreatment(treatmentId: String, name: String?, appIconName: String?) async throws -> ExperimentTreatment + func deleteTreatment(treatmentId: String) async throws + + func listTreatmentLocalizations(treatmentId: String, limit: Int?) async throws -> [ExperimentTreatmentLocalization] + func createTreatmentLocalization(treatmentId: String, locale: String) async throws -> ExperimentTreatmentLocalization + func deleteTreatmentLocalization(localizationId: String) async throws +} +``` + +## 6. File Map + +``` +Sources/ +├── Domain/Apps/Experiments/ +│ ├── AppStoreVersionExperiment.swift +│ ├── AppStoreVersionExperiment+RESTRoutes.swift +│ ├── ExperimentTreatment.swift +│ ├── ExperimentTreatmentLocalization.swift +│ └── ExperimentRepository.swift +├── Infrastructure/Apps/Experiments/ +│ └── SDKExperimentRepository.swift +└── ASCCommand/ + ├── Commands/Experiments/ + │ ├── ExperimentsCommand.swift + │ ├── ExperimentsList.swift / Get / Create / Update / Start / Stop / Delete + │ ├── ExperimentTreatmentsList.swift / Create / Update / Delete + │ └── ExperimentTreatmentLocalizationsList.swift / Create / Delete + └── Commands/Web/Controllers/ExperimentsController.swift + +Tests/ +├── DomainTests/Apps/Experiments/ +│ ├── AppStoreVersionExperimentTests.swift +│ ├── ExperimentTreatmentTests.swift +│ └── ExperimentTreatmentLocalizationTests.swift +├── InfrastructureTests/Apps/Experiments/SDKExperimentRepositoryTests.swift +└── ASCCommandTests/Commands/Experiments/ + ├── ExperimentsTests.swift + ├── ExperimentTreatmentsTests.swift + └── ExperimentTreatmentLocalizationsTests.swift +``` + +| Wiring file | Change | +|-------------|--------| +| `Sources/Domain/Apps/App.swift` | `listExperiments` affordance on `App` | +| `Sources/Domain/Shared/RESTPathResolver.swift` | `_ = _experimentRoutes` in `ensureInitialized()` | +| `Sources/Infrastructure/Client/ClientFactory.swift` | `makeExperimentRepository(authProvider:)` | +| `Sources/ASCCommand/ClientProvider.swift` | `makeExperimentRepository()` | +| `Sources/ASCCommand/ASC.swift` | registers the three command groups | +| `Sources/ASCCommand/Commands/Web/RESTRoutes.swift` | wires `ExperimentsController` | +| `Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift` | REST `_links` tests | +| `Tests/DomainTests/TestHelpers/MockRepositoryFactory.swift` | `makeExperiment`, `makeExperimentTreatment`, `makeExperimentTreatmentLocalization` | + +## 7. API Reference + +| ASC endpoint | SDK call | Repository method | +|--------------|----------|-------------------| +| `GET /v1/apps/{id}/appStoreVersionExperimentsV2` | `APIEndpoint.v1.apps.id(_).appStoreVersionExperimentsV2.get(parameters:)` | `listExperiments` | +| `GET /v2/appStoreVersionExperiments/{id}` | `APIEndpoint.v2.appStoreVersionExperiments.id(_).get()` | `getExperiment` | +| `POST /v2/appStoreVersionExperiments` | `APIEndpoint.v2.appStoreVersionExperiments.post(_)` | `createExperiment` | +| `PATCH /v2/appStoreVersionExperiments/{id}` | `APIEndpoint.v2.appStoreVersionExperiments.id(_).patch(_)` | `updateExperiment` (also `start`/`stop` via `started`) | +| `DELETE /v2/appStoreVersionExperiments/{id}` | `APIEndpoint.v2.appStoreVersionExperiments.id(_).delete` | `deleteExperiment` | +| `GET /v2/appStoreVersionExperiments/{id}/appStoreVersionExperimentTreatments` | `….id(_).appStoreVersionExperimentTreatments.get(parameters:)` | `listTreatments` | +| `POST /v1/appStoreVersionExperimentTreatments` | `APIEndpoint.v1.appStoreVersionExperimentTreatments.post(_)` | `createTreatment` | +| `PATCH /v1/appStoreVersionExperimentTreatments/{id}` | `….id(_).patch(_)` | `updateTreatment` | +| `DELETE /v1/appStoreVersionExperimentTreatments/{id}` | `….id(_).delete` | `deleteTreatment` | +| `GET /v1/appStoreVersionExperimentTreatments/{id}/appStoreVersionExperimentTreatmentLocalizations` | `….id(_).appStoreVersionExperimentTreatmentLocalizations.get(parameters:)` | `listTreatmentLocalizations` | +| `POST /v1/appStoreVersionExperimentTreatmentLocalizations` | `APIEndpoint.v1.appStoreVersionExperimentTreatmentLocalizations.post(_)` | `createTreatmentLocalization` | +| `DELETE /v1/appStoreVersionExperimentTreatmentLocalizations/{id}` | `….id(_).delete` | `deleteTreatmentLocalization` | + +Parent IDs: `listExperiments`/`createExperiment` inject the request's `appId`. Apple omits relationships from GET unless included and from PATCH always, so `getExperiment` requests `include=app` and `updateExperiment` re-reads the experiment after the PATCH (two calls) to keep `appId` populated. Treatments follow the same rule: list/create inject `experimentId` from the request; `updateTreatment` re-reads with `include=appStoreVersionExperimentV2`. An empty parent ID would silently break every `--app-id`/`--experiment-id` affordance, which is why the extra call is worth it. + +## 8. Testing + +```swift +@Test func `approved but unstarted experiment offers start only`() { + let exp = MockRepositoryFactory.makeExperiment(id: "exp-1", appId: "app-1", state: .approved) + #expect(exp.affordances["start"] == "asc experiments start --experiment-id exp-1") + #expect(exp.affordances["stop"] == nil) + #expect(exp.affordances["update"] == nil) +} +``` + +```bash +swift test --filter 'AppStoreVersionExperimentTests|ExperimentTreatmentTests|SDKExperimentRepositoryTests|ExperimentsListTests' +``` + +## 9. Extending + +**Attach screenshot sets to a treatment localization.** ASC exposes `GET /v1/appStoreVersionExperimentTreatmentLocalizations/{id}/appScreenshotSets`, and `AppScreenshotSetCreateRequest` accepts an `appStoreVersionExperimentTreatmentLocalization` relationship. The natural next step is a `ScreenshotRepository` method that targets a treatment localization instead of a version localization: + +```swift +// Domain/Apps/Versions/Localizations/ScreenshotSets/ScreenshotRepository.swift +func createScreenshotSet(treatmentLocalizationId: String, displayType: ScreenshotDisplayType) async throws -> AppScreenshotSet +``` + +and a `createScreenshotSet` affordance on `ExperimentTreatmentLocalization`. App preview sets follow the same shape (`…/appPreviewSets`).