diff --git a/CHANGELOG.md b/CHANGELOG.md index bae89892..78c3c565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Submit in-app purchases and subscriptions with an app version** — `asc versions submit --version-id --with-products` adds every in-app purchase and subscription that is `READY_TO_SUBMIT` with a submittable version (and its subscription group's version) to the app version's review submission and submits them together — the way first-time products must go to review — using only the public API and your API key, no iris web session. `--dry-run` lists what would be submitted and submits nothing. REST: `POST /api/v1/versions/:id/submit?with-products=true&dry-run=true` (the route `submitForReview` links pointed at now exists). See `docs/features/submit-with-products.md`. +- **Product versions** — `asc iap versions list --iap-id`, `asc subscriptions versions list --subscription-id` and `asc subscription-groups versions list --group-id` show each product's review versions and state; a submittable version offers `addToSubmission`. IAPs, subscriptions and groups gain a `listVersions` affordance. REST: `GET /api/v1/{iap,subscriptions,subscription-groups}/:id/versions`. +- **Build a review submission step by step** — `asc review-submissions create --app-id [--platform]` (opens or reuses the app's draft), `items add --submission-id` with one of `--version-id`, `--iap-version-id`, `--subscription-version-id`, `--subscription-group-version-id`, `items remove --item-id`, and `submit --submission-id`. REST: `POST /api/v1/apps/:appId/review-submissions`, `POST /api/v1/review-submissions/:id/items`, `DELETE /api/v1/review-submissions/items/:itemId`, `POST /api/v1/review-submissions/:id/submit`. +- **`STORAGE` performance metrics** — the new category Apple reports is mapped and usable with `perf-metrics list --metric-type STORAGE`. + +### Changed +- **Dependencies updated to their latest releases**, with `Package.swift` minimums raised to match: appstoreconnect-swift-sdk 4.4.3 (was 4.2.0), Hummingbird 2.27.0, swift-argument-parser 1.8.2, TauTUI 0.2.2, SweetCookieKit 0.5.3, Mockable 0.6.4 and the rest. The `hello-plugin` example pins Hummingbird 2.27.0 to match the host. +- **Refused submissions explain why** — when Apple refuses to add an item to, or submit, a review submission, the error lists the specific reasons from Apple's `associatedErrors` (missing device screenshots, content rights declaration, App Privacy answers, pricing) instead of only "please check associated errors". + +### Fixed +- **`review-submissions items list` shows what each item points at** — items never asked Apple for their relationships (`include=`), so every item showed no linked type or id, even app versions. They now show `APP_STORE_VERSION`, the new product version types and the others, with the `getVersion` affordance for app versions. + --- ## [0.18.4] - 2026-09-23 diff --git a/CLAUDE.md b/CLAUDE.md index b463bc24..b5c39a14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,6 +100,8 @@ Shared helpers (all in `Sources/ASCCommand/Commands/Web/RESTRoutes.swift`): - `restFormat(items)` — REST equivalent of `formatter.formatAgentItems(items, affordanceMode: .rest)` - `jsonError(message, status:)` — JSON error response (lives in `Infrastructure/Web/ASCWebServer.swift`; `import Infrastructure`) +Affordance actions map to REST by `RESTPathResolver`: `list`/`get` → GET, `create` and `add` → POST to the parent's collection, `update` → PATCH, `delete` and `remove` → DELETE on the resource, anything else (e.g. `submit`) → POST `…/{id}/{action}`. + Controllers are structs with dependencies injected at init (Hummingbird pattern). Repositories are constructed once in `RESTRoutes.configure`, never per request. ### Resource Hierarchy @@ -122,6 +124,8 @@ App → AppStoreVersionExperiment → ExperimentTreatment → ExperimentTreatmen App → PerformanceMetric (via perfPowerMetrics) Build → PerformanceMetric (via perfPowerMetrics) Build → DiagnosticSignatureInfo → DiagnosticLogEntry +InAppPurchase | Subscription | SubscriptionGroup → ProductVersion +App → ReviewSubmission → ReviewSubmissionItem → (AppStoreVersion | ProductVersion | …) ``` Domain folders are nested to mirror the resource hierarchy: @@ -144,6 +148,8 @@ Domain/ │ ├── Experiments/ → AppStoreVersionExperiment, AppStoreVersionExperimentState, │ │ ExperimentTreatment, ExperimentTreatmentLocalization, ExperimentRepository │ ├── Pricing/ → PricingRepository +│ ├── ProductVersions/ → ProductVersion, ProductVersionKind, ProductVersionState, +│ │ ProductVersionRepository (IAP/subscription/group review versions) │ ├── TestFlight/ → BetaGroup, BetaTester, TestFlightRepository │ └── Performance/ → PerformanceMetric, PerformanceMetricCategory, DiagnosticSignatureInfo, │ DiagnosticType, DiagnosticLogEntry, PerfMetricsRepository, DiagnosticsRepository @@ -152,7 +158,9 @@ Domain/ │ ├── Certificates/ → Certificate, CertificateRepository │ ├── Devices/ → Device, DeviceRepository │ └── Profiles/ → Profile, ProfileRepository -├── Submissions/ → ReviewSubmission, ReviewSubmissionState, SubmissionRepository +├── Submissions/ → ReviewSubmission, ReviewSubmissionState, ReviewSubmissionItem, +│ ReviewItemTarget, ReviewSubmissionError, SubmissionRepository, +│ SubmissionPlanner/SubmissionPlan (versions submit --with-products) ├── Auth/ → AuthCredentials, AuthProvider, AuthStatus, AuthStorage, CredentialSource, AuthError ├── Projects/ → ProjectConfig, ProjectConfigStorage ├── Skills/ → Skill, SkillCheckResult, SkillConfig, SkillRepository, SkillConfigStorage diff --git a/Package.resolved b/Package.resolved index d43260bf..32ff934e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/AvdLee/appstoreconnect-swift-sdk.git", "state" : { - "revision" : "40a007c9a26d1e1a77eff86117e508da33b9837e", - "version" : "4.2.0" + "revision" : "55fceaba611dcf7f4e7f897addcd8fb90867fa01", + "version" : "4.4.3" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/async-http-client.git", "state" : { - "revision" : "3a5b74a58782c3b4c1f0bc75e9b67b10c2494e8f", - "version" : "1.33.1" + "revision" : "f95c908967e98c68c5ce3fd61a7974e7e869e303", + "version" : "1.36.1" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/hummingbird-project/hummingbird.git", "state" : { - "revision" : "a2ed0a0294de56e18ba55344eafc801a7a385a90", - "version" : "2.22.0" + "revision" : "bb1f427eca980a1280a83aee0079cd4371455de6", + "version" : "2.27.0" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/hummingbird-project/hummingbird-websocket.git", "state" : { - "revision" : "716c54294152c6d3301a6239a1d74db57cbcd6dc", - "version" : "2.6.0" + "revision" : "89c66b8d6655209268c44f619afcf771d8d83ec9", + "version" : "2.7.0" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Kolos65/Mockable", "state" : { - "revision" : "e969f8469667382af3cbf6e457b2e771e70745f3", - "version" : "0.6.0" + "revision" : "0b239389c5fa6666fb30dc97fbb23fc3ca851a85", + "version" : "0.6.4" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/steipete/SweetCookieKit.git", "state" : { - "revision" : "4d5b71ffbb296937dc5ee8472f64721bca771cf0", - "version" : "0.4.0" + "revision" : "aa9fbefd7d4fb6335a3cd71fd6210f9a35535185", + "version" : "0.5.3" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", - "version" : "1.7.0" + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "810496cf121e525d660cd0ea89a758740476b85f", - "version" : "1.5.1" + "revision" : "3b6410f7dee09eb33cdd26260c5fd47fda19b0e2", + "version" : "1.7.3" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { - "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", - "version" : "1.1.3" + "revision" : "3da39bbc4e687d4192af7c9cf4eab805745a0b9c", + "version" : "1.1.5" } }, { @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { - "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", - "version" : "1.3.0" + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-certificates.git", "state" : { - "revision" : "24ccdeeeed4dfaae7955fcac9dbf5489ed4f1a25", - "version" : "1.18.0" + "revision" : "ff86b924ead66f853b8baf91f3c41926a8f36177", + "version" : "1.21.0" } }, { @@ -132,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-cmark.git", "state" : { - "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe", - "version" : "0.7.1" + "revision" : "08ddb528923cc1a6527e02b7a1aee9e516ca749a", + "version" : "0.9.0" } }, { @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", - "version" : "1.4.1" + "revision" : "a66de878e87ef5a3d5d390e0f6d9002aa5541a43", + "version" : "1.7.0" } }, { @@ -150,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-configuration.git", "state" : { - "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", - "version" : "1.2.0" + "revision" : "3533f65d3e36dcdffc91ce34ef4d3c9c1887fd4b", + "version" : "1.2.1" } }, { @@ -168,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ainame/swift-displaywidth.git", "state" : { - "revision" : "e2bfbdf1469366483451708bca36511f4f48ddb1", - "version" : "0.0.3" + "revision" : "5493276f6c91fd09d348e6cea70d66346fc7b610", + "version" : "0.1.0" } }, { @@ -177,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-distributed-tracing.git", "state" : { - "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", - "version" : "1.4.1" + "revision" : "cc504a45f6ce73ce6067837d7ac19fa67b229a56", + "version" : "1.5.0" } }, { @@ -186,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-structured-headers.git", "state" : { - "revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b", - "version" : "1.6.0" + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" } }, { @@ -195,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-types.git", "state" : { - "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", - "version" : "1.5.1" + "revision" : "bff4b6903cdc99dda49649dd52f46c11cfd3ed50", + "version" : "1.8.0" } }, { @@ -204,8 +204,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "8c0f217f01000dd30f60d6e536569ad4e74291f9", - "version" : "1.11.0" + "revision" : "9c6fb14227f55d8f711ce3847dc2f419fb0ecacb", + "version" : "1.15.1" } }, { @@ -213,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-markdown.git", "state" : { - "revision" : "7d9a5ce307528578dfa777d505496bd5f544ad94", - "version" : "0.7.3" + "revision" : "25cb61d3482054b09ae76ca4f281b1bfe7fe5a43", + "version" : "0.9.0" } }, { @@ -222,8 +222,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-metrics.git", "state" : { - "revision" : "f17c111cec972c2a4922cef38cf64f76f7e87886", - "version" : "2.8.0" + "revision" : "087e8074afa97040c3b870c8664fe5482fb87cc4", + "version" : "2.11.0" } }, { @@ -231,8 +231,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/hummingbird-project/swift-mustache.git", "state" : { - "revision" : "2e2a84698dd8a5fff2fe28857f0f95bb03d21d64", - "version" : "2.0.2" + "revision" : "0b91b3127baee1d97c45a7390a49779256b4295d", + "version" : "2.1.0" } }, { @@ -240,8 +240,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "558f24a4647193b5a0e2104031b71c55d31ff83a", - "version" : "2.97.1" + "revision" : "21de5f08c1a166a6dd293d0e587ad977bf8dac5d", + "version" : "2.103.0" } }, { @@ -249,8 +249,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-extras.git", "state" : { - "revision" : "abcf5312eb8ed2fb11916078aef7c46b06f20813", - "version" : "1.33.0" + "revision" : "41449336c8ecfadac6b4b5be75f9c3c306e61ced", + "version" : "1.35.1" } }, { @@ -258,8 +258,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-http2.git", "state" : { - "revision" : "6d8d596f0a9bfebb925733003731fe2d749b7e02", - "version" : "1.42.0" + "revision" : "0f3e54e29c944c2e835ad52159da7d9e1c94ac69", + "version" : "1.46.0" } }, { @@ -267,8 +267,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-ssl.git", "state" : { - "revision" : "df9c3406028e3297246e6e7081977a167318b692", - "version" : "2.36.1" + "revision" : "322f3c2a4a21df31c84ca416bf65ee5e9059e440", + "version" : "2.37.5" } }, { @@ -276,8 +276,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { - "revision" : "60c3e187154421171721c1a38e800b390680fb5d", - "version" : "1.26.0" + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" } }, { @@ -301,10 +301,10 @@ { "identity" : "swift-service-lifecycle", "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "location" : "https://github.com/swift-server/swift-service-lifecycle", "state" : { - "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", - "version" : "2.11.0" + "revision" : "7f9326b0326ff86e3646295ea6e891f68c471c5e", + "version" : "2.12.0" } }, { @@ -312,8 +312,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/adam-fowler/swift-srp.git", "state" : { - "revision" : "f558df594b4cc3d23b15645fd25f5cc64fc2b6c1", - "version" : "2.3.0" + "revision" : "1345dfeff4d1bc54fc36257325371df3d1d7a813", + "version" : "2.4.0" } }, { @@ -321,17 +321,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax.git", "state" : { - "revision" : "4799286537280063c85a32f09884cfbca301b1a1", - "version" : "602.0.0" + "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", + "version" : "603.0.2" } }, { "identity" : "swift-system", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system.git", + "location" : "https://github.com/apple/swift-system", "state" : { - "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", - "version" : "1.6.4" + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" } }, { @@ -339,8 +339,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/hummingbird-project/swift-websocket.git", "state" : { - "revision" : "ca48d46c25f8fa948d37eaa480c73172182cf90f", - "version" : "1.5.0" + "revision" : "126df9655565068bd97838c072c1db11f9fd42ee", + "version" : "1.6.1" } }, { @@ -348,8 +348,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/steipete/TauTUI.git", "state" : { - "revision" : "d30c095b65885adb7dc5042a9f5012e574cdc73b", - "version" : "0.1.5" + "revision" : "c452fee5a932bf8c17ff81ce9e8fdb0071a16a5b", + "version" : "0.2.2" } }, { @@ -366,8 +366,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "34e463e98ab8541c604af706c99bed7160f5ec70", - "version" : "1.8.1" + "revision" : "dfd70507def84cb5fb821278448a262c6ff2bbad", + "version" : "1.9.0" } } ], diff --git a/Package.swift b/Package.swift index 8ae8055f..b1057de0 100644 --- a/Package.swift +++ b/Package.swift @@ -10,15 +10,15 @@ let package = Package( .library(name: "ASCPlugin", targets: ["ASCPlugin"]), ], dependencies: [ - .package(url: "https://github.com/AvdLee/appstoreconnect-swift-sdk.git", from: "4.0.0"), - .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), - .package(url: "https://github.com/Kolos65/Mockable", from: "0.6.0"), - .package(url: "https://github.com/steipete/TauTUI.git", from: "0.1.5"), - .package(url: "https://github.com/steipete/SweetCookieKit.git", from: "0.3.0"), - .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.22.0"), - .package(url: "https://github.com/hummingbird-project/hummingbird-websocket.git", from: "2.6.0"), - .package(url: "https://github.com/hummingbird-project/swift-mustache.git", from: "2.0.2"), - .package(url: "https://github.com/adam-fowler/swift-srp.git", from: "2.0.0"), + .package(url: "https://github.com/AvdLee/appstoreconnect-swift-sdk.git", from: "4.4.3"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.8.2"), + .package(url: "https://github.com/Kolos65/Mockable", from: "0.6.4"), + .package(url: "https://github.com/steipete/TauTUI.git", from: "0.2.2"), + .package(url: "https://github.com/steipete/SweetCookieKit.git", from: "0.5.3"), + .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.27.0"), + .package(url: "https://github.com/hummingbird-project/hummingbird-websocket.git", from: "2.7.0"), + .package(url: "https://github.com/hummingbird-project/swift-mustache.git", from: "2.1.0"), + .package(url: "https://github.com/adam-fowler/swift-srp.git", from: "2.4.0"), ], targets: [ .target( diff --git a/README.md b/README.md index 70b6c78a..4f112ebf 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,20 @@ asc apps list # find your app ID asc init --app-id # pin it — skip --app-id on every future command ``` -### Optional: sign in to iris (unlocks first-time IAP submissions, web UI parity) +### First-time IAPs and subscriptions: submit them with the app version -The public ASC API (above) covers most workflows but **can't submit the first IAP for an app** — Apple requires that to ride along with a new App Store version, and the only path that accepts the flag is the iris private API. Sign in once with your Apple ID and `asc iap list` will auto-route the right command for each IAP: +Apple requires first-time in-app purchases and subscriptions to go to review together with an app version. The public API supports this through product versions, so your API key is enough: + +```bash +asc versions submit --version-id --with-products --dry-run # see what would go to review +asc versions submit --version-id --with-products # app version + every READY_TO_SUBMIT product +``` + +See [docs/features/submit-with-products.md](docs/features/submit-with-products.md) for the step-by-step `review-submissions create / items add / submit` commands. + +### Optional: sign in to iris (web UI parity) + +Some App Store Connect web features have no public API. Sign in once with your Apple ID and `asc iap list` will also offer the iris submission routes for each IAP: ```bash asc iris auth login --apple-id you@example.com --interactive @@ -46,7 +57,7 @@ Now `asc iap list --app-id ` enriches each IAP with the right submission aff | Category | What you can do | | --- | --- | -| **Apps & Versions** | List apps, create versions, link builds, submit for App Store review | +| **Apps & Versions** | List apps, create versions, link builds, submit for App Store review — with first-time IAPs and subscriptions in the same submission (`--with-products`) | | **Builds** | Archive Xcode projects, export IPA/PKG, upload to App Store Connect, distribute to TestFlight, update beta notes | | **Metadata** | Update What's New, description, and keywords per locale | | **App Info** | Set per-locale name, subtitle, privacy policy; manage categories and age rating | @@ -152,7 +163,7 @@ asc versions list --app-id asc versions create --app-id --version --platform ios asc versions set-build --version-id --build-id asc versions check-readiness --version-id -asc versions submit --version-id +asc versions submit --version-id [--with-products] [--dry-run] asc version-review-detail get --version-id asc version-review-detail update --version-id --contact-first-name Jane --contact-email dev@example.com ``` @@ -194,6 +205,10 @@ asc beta-app-localizations delete --localization-id asc review-submissions list --app-id [--state WAITING_FOR_REVIEW,IN_REVIEW,READY_FOR_REVIEW] [--limit 200] asc review-submissions get --submission-id asc review-submissions items list --submission-id [--state REJECTED] +asc review-submissions create --app-id [--platform ios] +asc review-submissions items add --submission-id --iap-version-id # or --version-id / --subscription-version-id / --subscription-group-version-id +asc review-submissions submit --submission-id +asc iap versions list --iap-id # also: subscriptions / subscription-groups versions list # Rejected? Read App Review's actual message (iris cookie auth — see docs/features/resolution-center.md) asc iris resolution-center get --submission-id --plain-text diff --git a/Sources/ASCCommand/ClientProvider.swift b/Sources/ASCCommand/ClientProvider.swift index 0fd4a6c9..2633a22f 100644 --- a/Sources/ASCCommand/ClientProvider.swift +++ b/Sources/ASCCommand/ClientProvider.swift @@ -153,6 +153,12 @@ struct ClientProvider { return try factory.makeInAppPurchaseSubmissionRepository(authProvider: authProvider) } + static func makeProductVersionRepository() throws -> any ProductVersionRepository { + let authProvider = CompositeAuthProvider() + let factory = ClientFactory() + return try factory.makeProductVersionRepository(authProvider: authProvider) + } + static func makeInAppPurchasePriceRepository() throws -> any InAppPurchasePriceRepository { let authProvider = CompositeAuthProvider() let factory = ClientFactory() diff --git a/Sources/ASCCommand/Commands/IAP/IAPCommand.swift b/Sources/ASCCommand/Commands/IAP/IAPCommand.swift index 03cdf76f..ab43cadc 100644 --- a/Sources/ASCCommand/Commands/IAP/IAPCommand.swift +++ b/Sources/ASCCommand/Commands/IAP/IAPCommand.swift @@ -13,6 +13,7 @@ struct IAPCommand: AsyncParsableCommand { IAPUnsubmit.self, IAPPricePointsCommand.self, IAPPricesCommand.self, + IAPVersionsCommand.self, ] ) } diff --git a/Sources/ASCCommand/Commands/IAP/IAPVersionsCommand.swift b/Sources/ASCCommand/Commands/IAP/IAPVersionsCommand.swift new file mode 100644 index 00000000..9788ad85 --- /dev/null +++ b/Sources/ASCCommand/Commands/IAP/IAPVersionsCommand.swift @@ -0,0 +1,9 @@ +import ArgumentParser + +struct IAPVersionsCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "versions", + abstract: "List review versions of an in-app purchase (add a submittable one to a review submission)", + subcommands: [IAPVersionsList.self] + ) +} diff --git a/Sources/ASCCommand/Commands/IAP/IAPVersionsList.swift b/Sources/ASCCommand/Commands/IAP/IAPVersionsList.swift new file mode 100644 index 00000000..66035cb0 --- /dev/null +++ b/Sources/ASCCommand/Commands/IAP/IAPVersionsList.swift @@ -0,0 +1,28 @@ +import ArgumentParser +import Domain + +struct IAPVersionsList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List review versions of an in-app purchase (add a submittable one to a review submission)" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "IAP ID") + var iapId: String + + func run() async throws { + let repo = try ClientProvider.makeProductVersionRepository() + print(try await execute(repo: repo)) + } + + func execute( + repo: any ProductVersionRepository, + affordanceMode: AffordanceMode = .cli + ) async throws -> String { + let versions = try await repo.listInAppPurchaseVersions(iapId: iapId) + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems(versions, affordanceMode: affordanceMode) + } +} diff --git a/Sources/ASCCommand/Commands/Performance/PerfMetricsCommand.swift b/Sources/ASCCommand/Commands/Performance/PerfMetricsCommand.swift index 17f7f4cf..bc485ddc 100644 --- a/Sources/ASCCommand/Commands/Performance/PerfMetricsCommand.swift +++ b/Sources/ASCCommand/Commands/Performance/PerfMetricsCommand.swift @@ -24,7 +24,7 @@ struct PerfMetricsList: AsyncParsableCommand { @Option(name: .long, help: "Build ID (mutually exclusive with --app-id)") var buildId: String? - @Option(name: .long, help: "Filter by metric type: HANG, LAUNCH, MEMORY, DISK, BATTERY, TERMINATION, ANIMATION") + @Option(name: .long, help: "Filter by metric type: HANG, LAUNCH, MEMORY, DISK, BATTERY, TERMINATION, ANIMATION, STORAGE") var metricType: String? func run() async throws { diff --git a/Sources/ASCCommand/Commands/ReviewSubmissions/ReviewSubmissionItemsAdd.swift b/Sources/ASCCommand/Commands/ReviewSubmissions/ReviewSubmissionItemsAdd.swift new file mode 100644 index 00000000..5c413b0f --- /dev/null +++ b/Sources/ASCCommand/Commands/ReviewSubmissions/ReviewSubmissionItemsAdd.swift @@ -0,0 +1,47 @@ +import ArgumentParser +import Domain + +struct ReviewSubmissionItemsAdd: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "add", + abstract: "Add an app version, or an IAP, subscription or subscription group version, to a draft review submission" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "Review submission ID") + var submissionId: String + + @Option(name: .long, help: "App Store version ID") + var versionId: String? + + @Option(name: .long, help: "In-app purchase version ID (from `asc iap versions list`)") + var iapVersionId: String? + + @Option(name: .long, help: "Subscription version ID (from `asc subscriptions versions list`)") + var subscriptionVersionId: String? + + @Option(name: .long, help: "Subscription group version ID (from `asc subscription-groups versions list`)") + var subscriptionGroupVersionId: String? + + func run() async throws { + let repo = try ClientProvider.makeSubmissionRepository() + print(try await execute(repo: repo)) + } + + func execute(repo: any SubmissionRepository, affordanceMode: AffordanceMode = .cli) async throws -> String { + guard let target = ReviewItemTarget( + versionId: versionId, + iapVersionId: iapVersionId, + subscriptionVersionId: subscriptionVersionId, + subscriptionGroupVersionId: subscriptionGroupVersionId + ) else { + throw ValidationError( + "Pass exactly one of --version-id, --iap-version-id, --subscription-version-id, --subscription-group-version-id" + ) + } + let item = try await repo.addItem(submissionId: submissionId, target: target) + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems([item], affordanceMode: affordanceMode) + } +} diff --git a/Sources/ASCCommand/Commands/ReviewSubmissions/ReviewSubmissionItemsCommand.swift b/Sources/ASCCommand/Commands/ReviewSubmissions/ReviewSubmissionItemsCommand.swift index cf247a00..a8b0dbc5 100644 --- a/Sources/ASCCommand/Commands/ReviewSubmissions/ReviewSubmissionItemsCommand.swift +++ b/Sources/ASCCommand/Commands/ReviewSubmissions/ReviewSubmissionItemsCommand.swift @@ -3,7 +3,7 @@ import ArgumentParser struct ReviewSubmissionItemsCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "items", - abstract: "Inspect per-item review state inside a submission (find which item Apple rejected)", - subcommands: [ReviewSubmissionItemsList.self] + abstract: "Add, remove and inspect the items in a review submission (find which item Apple rejected)", + subcommands: [ReviewSubmissionItemsList.self, ReviewSubmissionItemsAdd.self, ReviewSubmissionItemsRemove.self] ) } diff --git a/Sources/ASCCommand/Commands/ReviewSubmissions/ReviewSubmissionItemsRemove.swift b/Sources/ASCCommand/Commands/ReviewSubmissions/ReviewSubmissionItemsRemove.swift new file mode 100644 index 00000000..273229b4 --- /dev/null +++ b/Sources/ASCCommand/Commands/ReviewSubmissions/ReviewSubmissionItemsRemove.swift @@ -0,0 +1,23 @@ +import ArgumentParser +import Domain + +struct ReviewSubmissionItemsRemove: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "remove", + abstract: "Remove an item from a draft review submission" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "Review submission item ID") + var itemId: String + + func run() async throws { + let repo = try ClientProvider.makeSubmissionRepository() + try await execute(repo: repo) + } + + func execute(repo: any SubmissionRepository) async throws { + try await repo.removeItem(itemId: itemId) + } +} diff --git a/Sources/ASCCommand/Commands/ReviewSubmissions/ReviewSubmissionsCommand.swift b/Sources/ASCCommand/Commands/ReviewSubmissions/ReviewSubmissionsCommand.swift index 1a570a4f..d1a924bd 100644 --- a/Sources/ASCCommand/Commands/ReviewSubmissions/ReviewSubmissionsCommand.swift +++ b/Sources/ASCCommand/Commands/ReviewSubmissions/ReviewSubmissionsCommand.swift @@ -3,10 +3,12 @@ import ArgumentParser struct ReviewSubmissionsCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "review-submissions", - abstract: "Inspect App Store review submissions (state, rejected items, drill-in affordances)", + abstract: "Build, submit and inspect App Store review submissions (app versions plus IAP and subscription versions)", subcommands: [ ReviewSubmissionsList.self, ReviewSubmissionsGet.self, + ReviewSubmissionsCreate.self, + ReviewSubmissionsSubmit.self, ReviewSubmissionItemsCommand.self, ] ) diff --git a/Sources/ASCCommand/Commands/ReviewSubmissions/ReviewSubmissionsCreate.swift b/Sources/ASCCommand/Commands/ReviewSubmissions/ReviewSubmissionsCreate.swift new file mode 100644 index 00000000..7f7274d7 --- /dev/null +++ b/Sources/ASCCommand/Commands/ReviewSubmissions/ReviewSubmissionsCreate.swift @@ -0,0 +1,31 @@ +import ArgumentParser +import Domain + +struct ReviewSubmissionsCreate: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Open a draft review submission for an app (reuses the open draft if there is one)" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "App ID") + var appId: String + + @Option(name: .long, help: "Platform: ios, macos, tvos, visionos") + var platform: String = "ios" + + func run() async throws { + let repo = try ClientProvider.makeSubmissionRepository() + print(try await execute(repo: repo)) + } + + func execute(repo: any SubmissionRepository, affordanceMode: AffordanceMode = .cli) async throws -> String { + guard let appStorePlatform = AppStorePlatform(cliArgument: platform) else { + throw ValidationError("Unknown platform '\(platform)'. Use: ios, macos, tvos, visionos") + } + let submission = try await repo.createSubmission(appId: appId, platform: appStorePlatform) + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems([submission], affordanceMode: affordanceMode) + } +} diff --git a/Sources/ASCCommand/Commands/ReviewSubmissions/ReviewSubmissionsSubmit.swift b/Sources/ASCCommand/Commands/ReviewSubmissions/ReviewSubmissionsSubmit.swift new file mode 100644 index 00000000..43863926 --- /dev/null +++ b/Sources/ASCCommand/Commands/ReviewSubmissions/ReviewSubmissionsSubmit.swift @@ -0,0 +1,25 @@ +import ArgumentParser +import Domain + +struct ReviewSubmissionsSubmit: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "submit", + abstract: "Send a draft review submission, with all its items, to App Review" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "Review submission ID") + var submissionId: String + + func run() async throws { + let repo = try ClientProvider.makeSubmissionRepository() + print(try await execute(repo: repo)) + } + + func execute(repo: any SubmissionRepository, affordanceMode: AffordanceMode = .cli) async throws -> String { + let submission = try await repo.submit(submissionId: submissionId) + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems([submission], affordanceMode: affordanceMode) + } +} diff --git a/Sources/ASCCommand/Commands/SubscriptionGroups/SubscriptionGroupVersionsCommand.swift b/Sources/ASCCommand/Commands/SubscriptionGroups/SubscriptionGroupVersionsCommand.swift new file mode 100644 index 00000000..fc7f7e94 --- /dev/null +++ b/Sources/ASCCommand/Commands/SubscriptionGroups/SubscriptionGroupVersionsCommand.swift @@ -0,0 +1,9 @@ +import ArgumentParser + +struct SubscriptionGroupVersionsCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "versions", + abstract: "List review versions of a subscription group (add a submittable one to a review submission)", + subcommands: [SubscriptionGroupVersionsList.self] + ) +} diff --git a/Sources/ASCCommand/Commands/SubscriptionGroups/SubscriptionGroupVersionsList.swift b/Sources/ASCCommand/Commands/SubscriptionGroups/SubscriptionGroupVersionsList.swift new file mode 100644 index 00000000..4996ff73 --- /dev/null +++ b/Sources/ASCCommand/Commands/SubscriptionGroups/SubscriptionGroupVersionsList.swift @@ -0,0 +1,28 @@ +import ArgumentParser +import Domain + +struct SubscriptionGroupVersionsList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List review versions of a subscription group (add a submittable one to a review submission)" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "Subscription group ID") + var groupId: String + + func run() async throws { + let repo = try ClientProvider.makeProductVersionRepository() + print(try await execute(repo: repo)) + } + + func execute( + repo: any ProductVersionRepository, + affordanceMode: AffordanceMode = .cli + ) async throws -> String { + let versions = try await repo.listSubscriptionGroupVersions(groupId: groupId) + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems(versions, affordanceMode: affordanceMode) + } +} diff --git a/Sources/ASCCommand/Commands/SubscriptionGroups/SubscriptionGroupsCommand.swift b/Sources/ASCCommand/Commands/SubscriptionGroups/SubscriptionGroupsCommand.swift index 227a1f04..fac6d176 100644 --- a/Sources/ASCCommand/Commands/SubscriptionGroups/SubscriptionGroupsCommand.swift +++ b/Sources/ASCCommand/Commands/SubscriptionGroups/SubscriptionGroupsCommand.swift @@ -9,6 +9,7 @@ struct SubscriptionGroupsCommand: AsyncParsableCommand { SubscriptionGroupsCreate.self, SubscriptionGroupsUpdate.self, SubscriptionGroupsDelete.self, + SubscriptionGroupVersionsCommand.self, ] ) } diff --git a/Sources/ASCCommand/Commands/Subscriptions/SubscriptionVersionsCommand.swift b/Sources/ASCCommand/Commands/Subscriptions/SubscriptionVersionsCommand.swift new file mode 100644 index 00000000..f2aef909 --- /dev/null +++ b/Sources/ASCCommand/Commands/Subscriptions/SubscriptionVersionsCommand.swift @@ -0,0 +1,9 @@ +import ArgumentParser + +struct SubscriptionVersionsCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "versions", + abstract: "List review versions of a subscription (add a submittable one to a review submission)", + subcommands: [SubscriptionVersionsList.self] + ) +} diff --git a/Sources/ASCCommand/Commands/Subscriptions/SubscriptionVersionsList.swift b/Sources/ASCCommand/Commands/Subscriptions/SubscriptionVersionsList.swift new file mode 100644 index 00000000..4ec98088 --- /dev/null +++ b/Sources/ASCCommand/Commands/Subscriptions/SubscriptionVersionsList.swift @@ -0,0 +1,28 @@ +import ArgumentParser +import Domain + +struct SubscriptionVersionsList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List review versions of a subscription (add a submittable one to a review submission)" + ) + + @OptionGroup var globals: GlobalOptions + + @Option(name: .long, help: "Subscription ID") + var subscriptionId: String + + func run() async throws { + let repo = try ClientProvider.makeProductVersionRepository() + print(try await execute(repo: repo)) + } + + func execute( + repo: any ProductVersionRepository, + affordanceMode: AffordanceMode = .cli + ) async throws -> String { + let versions = try await repo.listSubscriptionVersions(subscriptionId: subscriptionId) + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + return try formatter.formatAgentItems(versions, affordanceMode: affordanceMode) + } +} diff --git a/Sources/ASCCommand/Commands/Subscriptions/SubscriptionsCommand.swift b/Sources/ASCCommand/Commands/Subscriptions/SubscriptionsCommand.swift index b4224572..a0ea56ab 100644 --- a/Sources/ASCCommand/Commands/Subscriptions/SubscriptionsCommand.swift +++ b/Sources/ASCCommand/Commands/Subscriptions/SubscriptionsCommand.swift @@ -13,6 +13,7 @@ struct SubscriptionsCommand: AsyncParsableCommand { SubscriptionsUnsubmit.self, SubscriptionPricePointsCommand.self, SubscriptionPricesCommand.self, + SubscriptionVersionsCommand.self, ] ) } diff --git a/Sources/ASCCommand/Commands/Versions/VersionsSubmit.swift b/Sources/ASCCommand/Commands/Versions/VersionsSubmit.swift index 985be183..bf403a70 100644 --- a/Sources/ASCCommand/Commands/Versions/VersionsSubmit.swift +++ b/Sources/ASCCommand/Commands/Versions/VersionsSubmit.swift @@ -4,7 +4,13 @@ import Domain struct VersionsSubmit: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "submit", - abstract: "Submit an App Store version for review" + abstract: "Submit an App Store version for review", + discussion: """ + --with-products also sends every in-app purchase and subscription that is \ + READY_TO_SUBMIT (with its subscription group) in the same review submission — \ + the way first-time products must go to review. --dry-run shows those items \ + and submits nothing. + """ ) @OptionGroup var globals: GlobalOptions @@ -12,9 +18,28 @@ struct VersionsSubmit: AsyncParsableCommand { @Option(name: .long, help: "App Store Version ID") var versionId: String + @Flag(name: .long, help: "Also submit every ready in-app purchase and subscription version with this app version") + var withProducts: Bool = false + + @Flag(name: .long, help: "List what would be submitted without submitting (implies --with-products)") + var dryRun: Bool = false + func run() async throws { - let repo = try ClientProvider.makeSubmissionRepository() - print(try await execute(repo: repo)) + if withProducts || dryRun { + let planner = SubmissionPlanner( + iapRepo: try ClientProvider.makeInAppPurchaseRepository(), + groupRepo: try ClientProvider.makeSubscriptionGroupRepository(), + subscriptionRepo: try ClientProvider.makeSubscriptionRepository(), + productVersionRepo: try ClientProvider.makeProductVersionRepository() + ) + print(try await executeWithProducts( + submissionRepo: try ClientProvider.makeSubmissionRepository(), + versionRepo: try ClientProvider.makeVersionRepository(), + planner: planner + )) + } else { + print(try await execute(repo: try ClientProvider.makeSubmissionRepository())) + } } func execute(repo: any SubmissionRepository) async throws -> String { @@ -27,4 +52,20 @@ struct VersionsSubmit: AsyncParsableCommand { rowMapper: { [$0.id, $0.platform.displayName, $0.state.displayName] } ) } + + func executeWithProducts( + submissionRepo: any SubmissionRepository, + versionRepo: any VersionRepository, + planner: SubmissionPlanner, + affordanceMode: AffordanceMode = .cli + ) async throws -> String { + let version = try await versionRepo.getVersion(id: versionId) + let plan = try await planner.plan(for: version) + let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty) + if dryRun { + return try formatter.formatAgentItems(plan.items, affordanceMode: affordanceMode) + } + let submission = try await plan.submit(repo: submissionRepo) + return try formatter.formatAgentItems([submission], affordanceMode: affordanceMode) + } } diff --git a/Sources/ASCCommand/Commands/Web/Controllers/ProductVersionsController.swift b/Sources/ASCCommand/Commands/Web/Controllers/ProductVersionsController.swift new file mode 100644 index 00000000..ea1686d1 --- /dev/null +++ b/Sources/ASCCommand/Commands/Web/Controllers/ProductVersionsController.swift @@ -0,0 +1,27 @@ +import Domain +import Hummingbird +import HummingbirdWebSocket +import Infrastructure + +/// Review versions of in-app purchases, subscriptions and subscription groups. +/// A submittable version is sent to review by adding it to a review submission. +struct ProductVersionsController: Sendable { + let repo: any ProductVersionRepository + + func addRoutes(to group: RouterGroup) { + group.get("/iap/:iapId/versions") { _, context -> Response in + guard let iapId = context.parameters.get("iapId") else { return jsonError("Missing iapId") } + return try restFormat(try await self.repo.listInAppPurchaseVersions(iapId: iapId)) + } + + group.get("/subscriptions/:subscriptionId/versions") { _, context -> Response in + guard let subscriptionId = context.parameters.get("subscriptionId") else { return jsonError("Missing subscriptionId") } + return try restFormat(try await self.repo.listSubscriptionVersions(subscriptionId: subscriptionId)) + } + + group.get("/subscription-groups/:groupId/versions") { _, context -> Response in + guard let groupId = context.parameters.get("groupId") else { return jsonError("Missing groupId") } + return try restFormat(try await self.repo.listSubscriptionGroupVersions(groupId: groupId)) + } + } +} diff --git a/Sources/ASCCommand/Commands/Web/Controllers/ReviewSubmissionsController.swift b/Sources/ASCCommand/Commands/Web/Controllers/ReviewSubmissionsController.swift index b86d9b1d..d89e6938 100644 --- a/Sources/ASCCommand/Commands/Web/Controllers/ReviewSubmissionsController.swift +++ b/Sources/ASCCommand/Commands/Web/Controllers/ReviewSubmissionsController.swift @@ -29,6 +29,50 @@ struct ReviewSubmissionsController: Sendable { return try restFormat(items) } + // POST /api/v1/apps/:appId/review-submissions {"platform": "ios"} — open (or reuse) a draft. + group.post("/apps/:appId/review-submissions") { request, context -> Response in + guard let appId = context.parameters.get("appId") else { return jsonError("Missing appId") } + let json = try await Self.jsonBody(request) + let platformRaw = (json["platform"] as? String) ?? "ios" + guard let platform = AppStorePlatform(cliArgument: platformRaw) ?? AppStorePlatform(rawValue: platformRaw) else { + return jsonError("Invalid platform '\(platformRaw)'", status: .badRequest) + } + return try restFormat(try await self.submissionRepo.createSubmission(appId: appId, platform: platform)) + } + + // POST /api/v1/review-submissions/:id/items — body carries exactly one of the CLI's + // version flags as a key: version-id, iap-version-id, subscription-version-id, + // subscription-group-version-id. + group.post("/review-submissions/:id/items") { request, context -> Response in + guard let id = context.parameters.get("id") else { return jsonError("Missing submission id") } + let json = try await Self.jsonBody(request) + guard let target = ReviewItemTarget( + versionId: json["version-id"] as? String, + iapVersionId: json["iap-version-id"] as? String, + subscriptionVersionId: json["subscription-version-id"] as? String, + subscriptionGroupVersionId: json["subscription-group-version-id"] as? String + ) else { + return jsonError( + "Body needs exactly one of version-id, iap-version-id, subscription-version-id, subscription-group-version-id", + status: .badRequest + ) + } + return try restFormat(try await self.submissionRepo.addItem(submissionId: id, target: target)) + } + + // DELETE /api/v1/review-submissions/items/:itemId — remove an item from a draft. + group.delete("/review-submissions/items/:itemId") { _, context -> Response in + guard let itemId = context.parameters.get("itemId") else { return jsonError("Missing item id") } + try await self.submissionRepo.removeItem(itemId: itemId) + return restResponse("{\"removed\":true}") + } + + // POST /api/v1/review-submissions/:id/submit — send the draft to App Review. + group.post("/review-submissions/:id/submit") { _, context -> Response in + guard let id = context.parameters.get("id") else { return jsonError("Missing submission id") } + return try restFormat(try await self.submissionRepo.submit(submissionId: id)) + } + // GET /api/v1/review-submissions/:id — single submission detail. group.get("/review-submissions/:id") { _, context -> Response in guard let id = context.parameters.get("id") else { return jsonError("Missing submission id") } @@ -48,4 +92,9 @@ struct ReviewSubmissionsController: Sendable { return try restFormat(items) } } + + 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/Controllers/VersionSubmissionController.swift b/Sources/ASCCommand/Commands/Web/Controllers/VersionSubmissionController.swift new file mode 100644 index 00000000..eacfa064 --- /dev/null +++ b/Sources/ASCCommand/Commands/Web/Controllers/VersionSubmissionController.swift @@ -0,0 +1,31 @@ +import Domain +import Hummingbird +import HummingbirdWebSocket +import Infrastructure + +/// `POST /api/v1/versions/:versionId/submit` — submits an app version for review. +/// `?with-products=true` also sends every ready in-app purchase and subscription +/// version in the same review submission; `?dry-run=true` lists those items and +/// submits nothing. Query names match the CLI's `--with-products` / `--dry-run`. +struct VersionSubmissionController: Sendable { + let submissionRepo: any SubmissionRepository + let versionRepo: any VersionRepository + let planner: SubmissionPlanner + + func addRoutes(to group: RouterGroup) { + group.post("/versions/:versionId/submit") { request, context -> Response in + guard let versionId = context.parameters.get("versionId") else { return jsonError("Missing versionId") } + let query = request.uri.queryParameters + let withProducts = query["with-products"].map { $0 == "true" } ?? false + let dryRun = query["dry-run"].map { $0 == "true" } ?? false + + guard withProducts || dryRun else { + return try restFormat(try await self.submissionRepo.submitVersion(versionId: versionId)) + } + let version = try await self.versionRepo.getVersion(id: versionId) + let plan = try await self.planner.plan(for: version) + if dryRun { return try restFormat(plan.items) } + return try restFormat(try await plan.submit(repo: self.submissionRepo)) + } + } +} diff --git a/Sources/ASCCommand/Commands/Web/RESTRoutes.swift b/Sources/ASCCommand/Commands/Web/RESTRoutes.swift index 1984387d..6b75a5c5 100644 --- a/Sources/ASCCommand/Commands/Web/RESTRoutes.swift +++ b/Sources/ASCCommand/Commands/Web/RESTRoutes.swift @@ -59,6 +59,21 @@ enum RESTRoutes { if let promotedRepo = try? factory.makePromotedPurchaseRepository(authProvider: auth) { PromotedPurchasesController(repo: promotedRepo).addRoutes(to: v1) } + if let productVersionRepo = try? factory.makeProductVersionRepository(authProvider: auth) { + ProductVersionsController(repo: productVersionRepo).addRoutes(to: v1) + if let submissionRepo = try? factory.makeSubmissionRepository(authProvider: auth), + let versionRepo = try? factory.makeVersionRepository(authProvider: auth), + let iapRepo = try? factory.makeInAppPurchaseRepository(authProvider: auth), + let groupRepo = try? factory.makeSubscriptionGroupRepository(authProvider: auth), + let subscriptionRepo = try? factory.makeSubscriptionRepository(authProvider: auth) { + VersionSubmissionController( + submissionRepo: submissionRepo, + versionRepo: versionRepo, + planner: SubmissionPlanner(iapRepo: iapRepo, groupRepo: groupRepo, + subscriptionRepo: subscriptionRepo, productVersionRepo: productVersionRepo) + ).addRoutes(to: v1) + } + } if let experimentRepo = try? factory.makeExperimentRepository(authProvider: auth) { ExperimentsController(repo: experimentRepo).addRoutes(to: v1) } diff --git a/Sources/Domain/Apps/InAppPurchases/InAppPurchase.swift b/Sources/Domain/Apps/InAppPurchases/InAppPurchase.swift index b03d1b9f..19344071 100644 --- a/Sources/Domain/Apps/InAppPurchases/InAppPurchase.swift +++ b/Sources/Domain/Apps/InAppPurchases/InAppPurchase.swift @@ -153,6 +153,7 @@ extension InAppPurchase: Presentable { extension InAppPurchase: AffordanceProviding { public var structuredAffordances: [Affordance] { var items: [Affordance] = [ + Affordance(key: "listVersions", command: "iap versions", action: "list", params: ["iap-id": id]), Affordance(key: "createLocalization", command: "iap-localizations", action: "create", params: ["iap-id": id, "locale": "en-US", "name": ""]), Affordance(key: "delete", command: "iap", action: "delete", diff --git a/Sources/Domain/Apps/Performance/PerfPowerMetric.swift b/Sources/Domain/Apps/Performance/PerfPowerMetric.swift index 5b627633..4a3a6aa1 100644 --- a/Sources/Domain/Apps/Performance/PerfPowerMetric.swift +++ b/Sources/Domain/Apps/Performance/PerfPowerMetric.swift @@ -8,6 +8,7 @@ public enum PerformanceMetricCategory: String, Sendable, Equatable, Codable, Cas case battery = "BATTERY" case termination = "TERMINATION" case animation = "ANIMATION" + case storage = "STORAGE" } public enum PerfMetricParentType: String, Sendable, Equatable, Codable { diff --git a/Sources/Domain/Apps/ProductVersions/ProductVersion+RESTRoutes.swift b/Sources/Domain/Apps/ProductVersions/ProductVersion+RESTRoutes.swift new file mode 100644 index 00000000..25548934 --- /dev/null +++ b/Sources/Domain/Apps/ProductVersions/ProductVersion+RESTRoutes.swift @@ -0,0 +1,8 @@ +/// REST route registrations for product versions. +extension RESTPathResolver { + static let _productVersionRoutes: Void = { + registerRoute(command: "iap versions", parentParam: "iap-id", parentSegment: "iap", segment: "versions") + registerRoute(command: "subscriptions versions", parentParam: "subscription-id", parentSegment: "subscriptions", segment: "versions") + registerRoute(command: "subscription-groups versions", parentParam: "group-id", parentSegment: "subscription-groups", segment: "versions") + }() +} diff --git a/Sources/Domain/Apps/ProductVersions/ProductVersion.swift b/Sources/Domain/Apps/ProductVersions/ProductVersion.swift new file mode 100644 index 00000000..79f57abe --- /dev/null +++ b/Sources/Domain/Apps/ProductVersions/ProductVersion.swift @@ -0,0 +1,99 @@ +/// A reviewable version of an in-app purchase, subscription or subscription group. +/// +/// Apple versions products the same way it versions apps: each change goes through +/// review as a version, and a version is sent to review by adding it as an item to a +/// `ReviewSubmission` — alongside an app version when the product is new. +public struct ProductVersion: Sendable, Equatable, Identifiable, Codable { + public let id: String + /// Parent product identifier (IAP, subscription or subscription group id) — + /// Apple's response omits it, so Infrastructure injects it from the request. + public let productId: String + public let kind: ProductVersionKind + public let version: Int? + public let state: ProductVersionState + + public init(id: String, productId: String, kind: ProductVersionKind, version: Int?, state: ProductVersionState) { + self.id = id + self.productId = productId + self.kind = kind + self.version = version + self.state = state + } + + public var isSubmittable: Bool { state.isSubmittable } + public var isInReview: Bool { state.isInReview } + public var isApproved: Bool { state.isApproved } +} + +public enum ProductVersionKind: String, Sendable, Equatable, Codable, CaseIterable { + case inAppPurchase = "IN_APP_PURCHASE" + case subscription = "SUBSCRIPTION" + case subscriptionGroup = "SUBSCRIPTION_GROUP" + + /// `asc` command that lists versions of this kind's product. + var listCommand: String { + switch self { + case .inAppPurchase: "iap versions" + case .subscription: "subscriptions versions" + case .subscriptionGroup: "subscription-groups versions" + } + } + + /// Flag naming the parent product in `listCommand`. + var productParam: String { + switch self { + case .inAppPurchase: "iap-id" + case .subscription: "subscription-id" + case .subscriptionGroup: "group-id" + } + } + + /// Flag `review-submissions items add` takes for a version of this kind. + public var itemParam: String { + switch self { + case .inAppPurchase: "iap-version-id" + case .subscription: "subscription-version-id" + case .subscriptionGroup: "subscription-group-version-id" + } + } +} + +public enum ProductVersionState: String, Sendable, Equatable, Codable, 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 replacedWithNewVersion = "REPLACED_WITH_NEW_VERSION" + case rejected = "REJECTED" + case developerRejected = "DEVELOPER_REJECTED" + + /// Can be added to a review submission. + public var isSubmittable: Bool { + self == .prepareForSubmission || self == .rejected || self == .developerRejected + } + + public var isInReview: Bool { self == .waitingForReview || self == .inReview } + + public var isApproved: Bool { self == .accepted || self == .approved } +} + +extension ProductVersion: AffordanceProviding { + public var structuredAffordances: [Affordance] { + var items = [ + Affordance(key: "listVersions", command: kind.listCommand, action: "list", + params: [kind.productParam: productId]), + ] + if isSubmittable { + items.append(Affordance(key: "addToSubmission", command: "review-submissions items", action: "add", + params: ["submission-id": "", kind.itemParam: id])) + } + return items + } +} + +extension ProductVersion: Presentable { + public static var tableHeaders: [String] { ["ID", "Product ID", "Kind", "Version", "State"] } + public var tableRow: [String] { [id, productId, kind.rawValue, version.map(String.init) ?? "-", state.rawValue] } +} diff --git a/Sources/Domain/Apps/ProductVersions/ProductVersionRepository.swift b/Sources/Domain/Apps/ProductVersions/ProductVersionRepository.swift new file mode 100644 index 00000000..a749e2b1 --- /dev/null +++ b/Sources/Domain/Apps/ProductVersions/ProductVersionRepository.swift @@ -0,0 +1,8 @@ +import Mockable + +@Mockable +public protocol ProductVersionRepository: Sendable { + func listInAppPurchaseVersions(iapId: String) async throws -> [ProductVersion] + func listSubscriptionVersions(subscriptionId: String) async throws -> [ProductVersion] + func listSubscriptionGroupVersions(groupId: String) async throws -> [ProductVersion] +} diff --git a/Sources/Domain/Apps/Subscriptions/Subscription.swift b/Sources/Domain/Apps/Subscriptions/Subscription.swift index f09482cc..cfaf84d3 100644 --- a/Sources/Domain/Apps/Subscriptions/Subscription.swift +++ b/Sources/Domain/Apps/Subscriptions/Subscription.swift @@ -117,6 +117,8 @@ extension Subscription: Presentable { extension Subscription: AffordanceProviding { public var structuredAffordances: [Affordance] { var items: [Affordance] = [ + Affordance(key: "listVersions", command: "subscriptions versions", action: "list", + params: ["subscription-id": id]), Affordance(key: "createIntroductoryOffer", command: "subscription-offers", action: "create", params: ["subscription-id": id, "duration": "ONE_MONTH", "mode": "FREE_TRIAL", "periods": "1"]), Affordance(key: "createLocalization", command: "subscription-localizations", action: "create", diff --git a/Sources/Domain/Apps/Subscriptions/SubscriptionGroup.swift b/Sources/Domain/Apps/Subscriptions/SubscriptionGroup.swift index f9e935bd..77781b3f 100644 --- a/Sources/Domain/Apps/Subscriptions/SubscriptionGroup.swift +++ b/Sources/Domain/Apps/Subscriptions/SubscriptionGroup.swift @@ -23,6 +23,8 @@ extension SubscriptionGroup: Presentable { extension SubscriptionGroup: AffordanceProviding { public var structuredAffordances: [Affordance] { [ + Affordance(key: "listVersions", command: "subscription-groups versions", action: "list", + params: ["group-id": id]), Affordance(key: "createLocalization", command: "subscription-group-localizations", action: "create", params: ["group-id": id, "locale": "en-US", "name": ""]), Affordance(key: "createSubscription", command: "subscriptions", action: "create", diff --git a/Sources/Domain/Shared/Affordance.swift b/Sources/Domain/Shared/Affordance.swift index 264733c9..3867b0d7 100644 --- a/Sources/Domain/Shared/Affordance.swift +++ b/Sources/Domain/Shared/Affordance.swift @@ -41,7 +41,7 @@ public struct Affordance: Sendable, Equatable { case "list", "get": return "GET" case "create": return "POST" case "update": return "PATCH" - case "delete": return "DELETE" + case "delete", "remove": return "DELETE" default: return "POST" } } diff --git a/Sources/Domain/Shared/RESTPathResolver.swift b/Sources/Domain/Shared/RESTPathResolver.swift index fc2026bf..18aec972 100644 --- a/Sources/Domain/Shared/RESTPathResolver.swift +++ b/Sources/Domain/Shared/RESTPathResolver.swift @@ -65,7 +65,9 @@ public final class RESTPathResolver: @unchecked Sendable { let base = "/api/v1" - if action != "list", action != "create" { + // `add` attaches an existing resource to a parent's collection (e.g. an item to a + // review submission), so it resolves like `create`: POST to the collection. + if action != "list", action != "create", action != "add" { // Action on this resource by its own id (e.g. `versions get --version-id v-1`). if let ownId = params["\(singularize(command))-id"] { return resourcePath(base: base, segment: command, id: ownId, action: action) @@ -90,7 +92,7 @@ public final class RESTPathResolver: @unchecked Sendable { } } - // List/create under a parent resource. + // List/create/add under a parent resource. if let route = currentRoutes[command], let parentId = params[route.parentParam] { return "\(base)/\(route.parentSegment)/\(parentId)/\(route.segment)" @@ -116,7 +118,9 @@ public final class RESTPathResolver: @unchecked Sendable { // Multi-word commands (e.g. `iris iap-submissions`) carry their CLI subcommand // structure as a space; convert to a slash for clean URL segments. let urlSegment = segment.replacingOccurrences(of: " ", with: "/") - if action == "get" || action == "update" || action == "delete" { + // `remove` detaches a resource from its parent (e.g. an item from a review + // submission) — addressed like `delete`, by the resource's own path. + if action == "get" || action == "update" || action == "delete" || action == "remove" { return "\(base)/\(urlSegment)/\(id)" } return "\(base)/\(urlSegment)/\(id)/\(action)" @@ -162,6 +166,7 @@ public final class RESTPathResolver: @unchecked Sendable { _ = _codeSigningRoutes _ = _appShotsRoutes _ = _submissionRoutes + _ = _productVersionRoutes _ = _resolutionCenterRoutes _ = _experimentRoutes diff --git a/Sources/Domain/Submissions/ReviewItemTarget.swift b/Sources/Domain/Submissions/ReviewItemTarget.swift new file mode 100644 index 00000000..668af92a --- /dev/null +++ b/Sources/Domain/Submissions/ReviewItemTarget.swift @@ -0,0 +1,44 @@ +/// What a review submission item sends to review: an app version, or a version +/// of an in-app purchase, subscription or subscription group. +public enum ReviewItemTarget: Sendable, Equatable { + case appStoreVersion(String) + case inAppPurchaseVersion(String) + case subscriptionVersion(String) + case subscriptionGroupVersion(String) + + /// The item named by exactly one version id (the CLI flags / REST body keys + /// `version-id`, `iap-version-id`, `subscription-version-id`, + /// `subscription-group-version-id`); `nil` when none or several are given. + public init?( + versionId: String? = nil, + iapVersionId: String? = nil, + subscriptionVersionId: String? = nil, + subscriptionGroupVersionId: String? = nil + ) { + let targets: [ReviewItemTarget] = [ + versionId.map(ReviewItemTarget.appStoreVersion), + iapVersionId.map(ReviewItemTarget.inAppPurchaseVersion), + subscriptionVersionId.map(ReviewItemTarget.subscriptionVersion), + subscriptionGroupVersionId.map(ReviewItemTarget.subscriptionGroupVersion), + ].compactMap { $0 } + guard targets.count == 1, let target = targets.first else { return nil } + self = target + } + + public var id: String { + switch self { + case .appStoreVersion(let id), .inAppPurchaseVersion(let id), + .subscriptionVersion(let id), .subscriptionGroupVersion(let id): + return id + } + } + + public var linkedResource: ReviewSubmissionItemLinkedResource { + switch self { + case .appStoreVersion: .appStoreVersion + case .inAppPurchaseVersion: .inAppPurchaseVersion + case .subscriptionVersion: .subscriptionVersion + case .subscriptionGroupVersion: .subscriptionGroupVersion + } + } +} diff --git a/Sources/Domain/Submissions/ReviewSubmission.swift b/Sources/Domain/Submissions/ReviewSubmission.swift index a1aa3c38..96542fe6 100644 --- a/Sources/Domain/Submissions/ReviewSubmission.swift +++ b/Sources/Domain/Submissions/ReviewSubmission.swift @@ -25,6 +25,7 @@ public struct ReviewSubmission: Sendable, Equatable, Identifiable, Codable { public var isComplete: Bool { state.isComplete } public var isPending: Bool { state.isPending } public var hasIssues: Bool { state.hasIssues } + public var isEditable: Bool { state.isEditable } } extension ReviewSubmission: AffordanceProviding { @@ -37,6 +38,14 @@ extension ReviewSubmission: AffordanceProviding { Affordance(key: "listVersions", command: "versions", action: "list", params: ["app-id": appId]), ] + if isEditable { + // One representative target; `items add` also takes --iap-version-id, + // --subscription-version-id and --subscription-group-version-id. + items.append(Affordance(key: "addItem", command: "review-submissions items", action: "add", + params: ["submission-id": id, "version-id": ""])) + items.append(Affordance(key: "submit", command: "review-submissions", action: "submit", + params: ["submission-id": id])) + } if hasIssues { // Agent shortcut: when Apple flags issues, the rejected items expose // which resource needs fixing — surface them as the first thing to do. diff --git a/Sources/Domain/Submissions/ReviewSubmissionError.swift b/Sources/Domain/Submissions/ReviewSubmissionError.swift new file mode 100644 index 00000000..e9ee3fec --- /dev/null +++ b/Sources/Domain/Submissions/ReviewSubmissionError.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Apple refused to add an item to, or submit, a review submission. `reasons` are the +/// specific problems Apple listed (missing screenshots, pricing, privacy answers, …), +/// which its headline message only points at. +public enum ReviewSubmissionError: Error, Equatable, Sendable, CustomStringConvertible, LocalizedError { + case refused(message: String, reasons: [String]) + + public var description: String { + switch self { + case .refused(let message, let reasons): + (["Apple refused the review submission: \(message)"] + reasons.map { " - \($0)" }) + .joined(separator: "\n") + } + } + + public var errorDescription: String? { description } +} diff --git a/Sources/Domain/Submissions/ReviewSubmissionItem.swift b/Sources/Domain/Submissions/ReviewSubmissionItem.swift index f753653c..05bd150f 100644 --- a/Sources/Domain/Submissions/ReviewSubmissionItem.swift +++ b/Sources/Domain/Submissions/ReviewSubmissionItem.swift @@ -79,6 +79,10 @@ extension ReviewSubmissionItem: AffordanceProviding { Affordance(key: "listSiblings", command: "review-submissions items", action: "list", params: ["submission-id": submissionId]), ] + if isPending { + items.append(Affordance(key: "remove", command: "review-submissions items", action: "remove", + params: ["item-id": id])) + } if isRejected { // The reviewer's message text lives only behind the iris (cookie-auth) // surface — the official API has no resolutionCenter endpoints. @@ -92,7 +96,8 @@ extension ReviewSubmissionItem: AffordanceProviding { params: ["version-id": linkedId])) case .appCustomProductPageVersion, .appStoreVersionExperiment, .appEvent, .backgroundAssetVersion, .gameCenterAchievementVersion, .gameCenterActivityVersion, - .gameCenterChallengeVersion, .gameCenterLeaderboardSetVersion, .gameCenterLeaderboardVersion: + .gameCenterChallengeVersion, .gameCenterLeaderboardSetVersion, .gameCenterLeaderboardVersion, + .inAppPurchaseVersion, .subscriptionVersion, .subscriptionGroupVersion: // Other resource types don't have a top-level `asc get` yet. break } @@ -115,4 +120,7 @@ public enum ReviewSubmissionItemLinkedResource: String, Sendable, Equatable, Cod case gameCenterChallengeVersion = "GAME_CENTER_CHALLENGE_VERSION" case gameCenterLeaderboardSetVersion = "GAME_CENTER_LEADERBOARD_SET_VERSION" case gameCenterLeaderboardVersion = "GAME_CENTER_LEADERBOARD_VERSION" + case inAppPurchaseVersion = "IN_APP_PURCHASE_VERSION" + case subscriptionVersion = "SUBSCRIPTION_VERSION" + case subscriptionGroupVersion = "SUBSCRIPTION_GROUP_VERSION" } diff --git a/Sources/Domain/Submissions/ReviewSubmissionState.swift b/Sources/Domain/Submissions/ReviewSubmissionState.swift index b8bd379e..c98a2ae3 100644 --- a/Sources/Domain/Submissions/ReviewSubmissionState.swift +++ b/Sources/Domain/Submissions/ReviewSubmissionState.swift @@ -21,6 +21,10 @@ public enum ReviewSubmissionState: String, Sendable, Equatable, Codable, CaseIte /// The submission has unresolved issues that require developer action. public var hasIssues: Bool { self == .unresolvedIssues } + /// Still accepts items and can be submitted: a draft, or a submission Apple + /// sent back with issues. + public var isEditable: Bool { self == .readyForReview || self == .unresolvedIssues } + public var displayName: String { switch self { case .readyForReview: return "Ready for Review" diff --git a/Sources/Domain/Submissions/SubmissionPlan.swift b/Sources/Domain/Submissions/SubmissionPlan.swift new file mode 100644 index 00000000..7a6724f8 --- /dev/null +++ b/Sources/Domain/Submissions/SubmissionPlan.swift @@ -0,0 +1,126 @@ +/// Everything to send to App Review together with an app version: the version +/// itself first, then each ready product's submittable version. A subscription +/// group's version goes right before its subscriptions. +public struct SubmissionPlan: Sendable, Equatable { + public let appId: String + public let platform: AppStorePlatform + public let items: [PlannedReviewItem] + + public init(appId: String, platform: AppStorePlatform, items: [PlannedReviewItem]) { + self.appId = appId + self.platform = platform + self.items = items + } + + /// Opens (or reuses) the app's draft, adds the items in order and submits it. + /// Stops at the first item Apple refuses, before anything is submitted — the + /// items added so far stay in the draft. + public func submit(repo: some SubmissionRepository) async throws -> ReviewSubmission { + let draft = try await repo.createSubmission(appId: appId, platform: platform) + for item in items { + _ = try await repo.addItem(submissionId: draft.id, target: item.target) + } + return try await repo.submit(submissionId: draft.id) + } +} + +/// One thing a `SubmissionPlan` sends to review. +public struct PlannedReviewItem: Sendable, Equatable, Codable { + public let kind: ReviewSubmissionItemLinkedResource + /// The app version id, or the product version id. + public let versionId: String + /// The app id for the app version, else the IAP, subscription or group id. + public let productId: String + /// Version string for the app version, else the product's name. + public let name: String + + public init(kind: ReviewSubmissionItemLinkedResource, versionId: String, productId: String, name: String) { + self.kind = kind + self.versionId = versionId + self.productId = productId + self.name = name + } + + var target: ReviewItemTarget { + switch kind { + case .inAppPurchaseVersion: .inAppPurchaseVersion(versionId) + case .subscriptionVersion: .subscriptionVersion(versionId) + case .subscriptionGroupVersion: .subscriptionGroupVersion(versionId) + default: .appStoreVersion(versionId) + } + } +} + +extension PlannedReviewItem: AffordanceProviding { + public var structuredAffordances: [Affordance] { + switch kind { + case .inAppPurchaseVersion: + [Affordance(key: "listVersions", command: "iap versions", action: "list", params: ["iap-id": productId])] + case .subscriptionVersion: + [Affordance(key: "listVersions", command: "subscriptions versions", action: "list", params: ["subscription-id": productId])] + case .subscriptionGroupVersion: + [Affordance(key: "listVersions", command: "subscription-groups versions", action: "list", params: ["group-id": productId])] + default: + [Affordance(key: "getVersion", command: "versions", action: "get", params: ["version-id": versionId])] + } + } +} + +extension PlannedReviewItem: Presentable { + public static var tableHeaders: [String] { ["Kind", "Version ID", "Product ID", "Name"] } + public var tableRow: [String] { [kind.rawValue, versionId, productId, name] } +} + +/// Works out what to submit with an app version: every in-app purchase and +/// subscription that is `READY_TO_SUBMIT` and has a submittable version, plus +/// the version of each subscription group one of them belongs to. +public struct SubmissionPlanner: Sendable { + let iapRepo: any InAppPurchaseRepository + let groupRepo: any SubscriptionGroupRepository + let subscriptionRepo: any SubscriptionRepository + let productVersionRepo: any ProductVersionRepository + + public init( + iapRepo: any InAppPurchaseRepository, + groupRepo: any SubscriptionGroupRepository, + subscriptionRepo: any SubscriptionRepository, + productVersionRepo: any ProductVersionRepository + ) { + self.iapRepo = iapRepo + self.groupRepo = groupRepo + self.subscriptionRepo = subscriptionRepo + self.productVersionRepo = productVersionRepo + } + + public func plan(for version: AppStoreVersion) async throws -> SubmissionPlan { + var items = [PlannedReviewItem(kind: .appStoreVersion, versionId: version.id, + productId: version.appId, name: version.versionString)] + + let iaps = try await iapRepo.listInAppPurchases(appId: version.appId, limit: 200).data + for iap in iaps where iap.state == .readyToSubmit { + if let v = try await productVersionRepo.listInAppPurchaseVersions(iapId: iap.id).first(where: \.isSubmittable) { + items.append(PlannedReviewItem(kind: .inAppPurchaseVersion, versionId: v.id, productId: iap.id, name: iap.referenceName)) + } + } + + let groups = try await groupRepo.listSubscriptionGroups(appId: version.appId, limit: 200).data + for group in groups { + var subscriptionItems: [PlannedReviewItem] = [] + let subscriptions = try await subscriptionRepo.listSubscriptions(groupId: group.id, limit: 200).data + for subscription in subscriptions where subscription.state == .readyToSubmit { + if let v = try await productVersionRepo.listSubscriptionVersions(subscriptionId: subscription.id).first(where: \.isSubmittable) { + subscriptionItems.append(PlannedReviewItem(kind: .subscriptionVersion, versionId: v.id, + productId: subscription.id, name: subscription.name)) + } + } + guard !subscriptionItems.isEmpty else { continue } + if let v = try await productVersionRepo.listSubscriptionGroupVersions(groupId: group.id).first(where: \.isSubmittable) { + items.append(PlannedReviewItem(kind: .subscriptionGroupVersion, versionId: v.id, + productId: group.id, name: group.referenceName)) + } + items += subscriptionItems + } + + return SubmissionPlan(appId: version.appId, platform: version.platform, items: items) + } +} diff --git a/Sources/Domain/Submissions/SubmissionRepository.swift b/Sources/Domain/Submissions/SubmissionRepository.swift index 6c7a049f..61401aeb 100644 --- a/Sources/Domain/Submissions/SubmissionRepository.swift +++ b/Sources/Domain/Submissions/SubmissionRepository.swift @@ -6,4 +6,10 @@ public protocol SubmissionRepository: Sendable { func listSubmissions(appId: String, states: [ReviewSubmissionState]?, limit: Int?) async throws -> [ReviewSubmission] func getSubmission(id: String) async throws -> ReviewSubmission func listSubmissionItems(submissionId: String) async throws -> [ReviewSubmissionItem] + /// Returns the app's open draft for `platform`, creating one when there is none — + /// Apple allows a single open submission per app and platform. + func createSubmission(appId: String, platform: AppStorePlatform) async throws -> ReviewSubmission + func addItem(submissionId: String, target: ReviewItemTarget) async throws -> ReviewSubmissionItem + func removeItem(itemId: String) async throws + func submit(submissionId: String) async throws -> ReviewSubmission } diff --git a/Sources/Infrastructure/Apps/Performance/SDKPerfMetricsRepository.swift b/Sources/Infrastructure/Apps/Performance/SDKPerfMetricsRepository.swift index f1dce2a0..cc9259fb 100644 --- a/Sources/Infrastructure/Apps/Performance/SDKPerfMetricsRepository.swift +++ b/Sources/Infrastructure/Apps/Performance/SDKPerfMetricsRepository.swift @@ -39,6 +39,7 @@ public struct SDKPerfMetricsRepository: PerfMetricsRepository, @unchecked Sendab case .memory: mapped = .memory case .animation: mapped = .animation case .termination: mapped = .termination + case .storage: mapped = .storage } return mapped.map { [$0] } } @@ -56,6 +57,7 @@ public struct SDKPerfMetricsRepository: PerfMetricsRepository, @unchecked Sendab case .memory: mapped = .memory case .animation: mapped = .animation case .termination: mapped = .termination + case .storage: mapped = .storage } return mapped.map { [$0] } } @@ -105,6 +107,7 @@ public struct SDKPerfMetricsRepository: PerfMetricsRepository, @unchecked Sendab case .battery: return .battery case .termination: return .termination case .animation: return .animation + case .storage: return .storage } } } diff --git a/Sources/Infrastructure/Apps/ProductVersions/SDKProductVersionRepository.swift b/Sources/Infrastructure/Apps/ProductVersions/SDKProductVersionRepository.swift new file mode 100644 index 00000000..7ac62fee --- /dev/null +++ b/Sources/Infrastructure/Apps/ProductVersions/SDKProductVersionRepository.swift @@ -0,0 +1,53 @@ +@preconcurrency import AppStoreConnect_Swift_SDK +import Domain + +public struct SDKProductVersionRepository: ProductVersionRepository, @unchecked Sendable { + private let client: any APIClient + + public init(client: any APIClient) { + self.client = client + } + + public func listInAppPurchaseVersions(iapId: String) async throws -> [Domain.ProductVersion] { + let pages = try await client.requestAllPages( + APIEndpoint.v2.inAppPurchases.id(iapId).versions.get(parameters: .init(limit: 200)), + nextCursor: { $0.meta?.paging.nextCursor } + ) + return pages.flatMap(\.data).map { + map(id: $0.id, productId: iapId, kind: .inAppPurchase, + version: $0.attributes?.version, state: $0.attributes?.state?.rawValue) + } + } + + public func listSubscriptionVersions(subscriptionId: String) async throws -> [Domain.ProductVersion] { + let pages = try await client.requestAllPages( + APIEndpoint.v1.subscriptions.id(subscriptionId).versions.get(parameters: .init(limit: 200)), + nextCursor: { $0.meta?.paging.nextCursor } + ) + return pages.flatMap(\.data).map { + map(id: $0.id, productId: subscriptionId, kind: .subscription, + version: $0.attributes?.version, state: $0.attributes?.state?.rawValue) + } + } + + public func listSubscriptionGroupVersions(groupId: String) async throws -> [Domain.ProductVersion] { + let pages = try await client.requestAllPages( + APIEndpoint.v1.subscriptionGroups.id(groupId).versions.get(parameters: .init(limit: 200)), + nextCursor: { $0.meta?.paging.nextCursor } + ) + return pages.flatMap(\.data).map { + map(id: $0.id, productId: groupId, kind: .subscriptionGroup, + version: $0.attributes?.version, state: $0.attributes?.state?.rawValue) + } + } + + /// The three SDK version types share the same attributes and state values. + private func map( + id: String, productId: String, kind: Domain.ProductVersionKind, version: Int?, state: String? + ) -> Domain.ProductVersion { + Domain.ProductVersion( + id: id, productId: productId, kind: kind, version: version, + state: state.flatMap(Domain.ProductVersionState.init(rawValue:)) ?? .prepareForSubmission + ) + } +} diff --git a/Sources/Infrastructure/Client/ClientFactory.swift b/Sources/Infrastructure/Client/ClientFactory.swift index 8f52ea79..134e9fac 100644 --- a/Sources/Infrastructure/Client/ClientFactory.swift +++ b/Sources/Infrastructure/Client/ClientFactory.swift @@ -141,6 +141,11 @@ public struct ClientFactory: Sendable { return SDKInAppPurchaseSubmissionRepository(client: provider) } + public func makeProductVersionRepository(authProvider: any AuthProvider) throws -> any ProductVersionRepository { + let provider = try makeProvider(authProvider: authProvider) + return SDKProductVersionRepository(client: provider) + } + public func makeInAppPurchasePriceRepository(authProvider: any AuthProvider) throws -> any InAppPurchasePriceRepository { let provider = try makeProvider(authProvider: authProvider) return SDKInAppPurchasePriceRepository(client: provider) diff --git a/Sources/Infrastructure/Submissions/OpenAPISubmissionRepository.swift b/Sources/Infrastructure/Submissions/OpenAPISubmissionRepository.swift index 5dc7c141..06c76f81 100644 --- a/Sources/Infrastructure/Submissions/OpenAPISubmissionRepository.swift +++ b/Sources/Infrastructure/Submissions/OpenAPISubmissionRepository.swift @@ -67,6 +67,71 @@ public struct OpenAPISubmissionRepository: SubmissionRepository, @unchecked Send return try await patchSubmitted(id: submissionId, appId: appId, platform: platform) } + public func createSubmission(appId: String, platform: Domain.AppStorePlatform) async throws -> Domain.ReviewSubmission { + guard let sdkPlatform = AppStoreConnect_Swift_SDK.Platform(rawValue: platform.rawValue) else { + throw APIError.unknown("Unsupported review submission platform \(platform.rawValue)") + } + let listResp = try await client.request(APIEndpoint.v1.reviewSubmissions.get(parameters: .init( + filterPlatform: APIEndpoint.V1.ReviewSubmissions.GetParameters.FilterPlatform(rawValue: platform.rawValue).map { [$0] }, + filterState: [.unresolvedIssues, .readyForReview], + filterApp: [appId] + ))) + if let open = listResp.data.first { + return mapListedSubmission(open, appId: appId) + } + let created = try await client.request(APIEndpoint.v1.reviewSubmissions.post( + ReviewSubmissionCreateRequest(data: .init( + type: .reviewSubmissions, + attributes: .init(platform: sdkPlatform), + relationships: .init(app: .init(data: .init(type: .apps, id: appId))) + )) + )) + return mapListedSubmission(created.data, appId: appId) + } + + public func addItem(submissionId: String, target: Domain.ReviewItemTarget) async throws -> Domain.ReviewSubmissionItem { + var relationships = ReviewSubmissionItemCreateRequest.Data.Relationships( + reviewSubmission: .init(data: .init(type: .reviewSubmissions, id: submissionId)) + ) + switch target { + case .appStoreVersion(let id): + relationships.appStoreVersion = .init(data: .init(type: .appStoreVersions, id: id)) + case .inAppPurchaseVersion(let id): + relationships.inAppPurchaseVersion = .init(data: .init(type: .inAppPurchaseVersions, id: id)) + case .subscriptionVersion(let id): + relationships.subscriptionVersion = .init(data: .init(type: .subscriptionVersions, id: id)) + case .subscriptionGroupVersion(let id): + relationships.subscriptionGroupVersion = .init(data: .init(type: .subscriptionGroupVersions, id: id)) + } + let response = try await explainingRefusal { + try await client.request(APIEndpoint.v1.reviewSubmissionItems.post( + ReviewSubmissionItemCreateRequest(data: .init(type: .reviewSubmissionItems, relationships: relationships)) + )) + } + // Apple's create response omits the relationship, so the target supplies it. + let item = mapSubmissionItem(response.data, submissionId: submissionId) + return Domain.ReviewSubmissionItem( + id: item.id, submissionId: submissionId, state: item.state, + linkedResourceId: target.id, linkedResourceType: target.linkedResource + ) + } + + public func removeItem(itemId: String) async throws { + try await client.request(APIEndpoint.v1.reviewSubmissionItems.id(itemId).delete) + } + + public func submit(submissionId: String) async throws -> Domain.ReviewSubmission { + _ = try await explainingRefusal { + try await client.request(APIEndpoint.v1.reviewSubmissions.id(submissionId).patch( + ReviewSubmissionUpdateRequest(data: .init( + type: .reviewSubmissions, id: submissionId, attributes: .init(isSubmitted: true) + )) + )) + } + // The PATCH response doesn't carry the app relationship — re-read it. + return try await getSubmission(id: submissionId) + } + public func getSubmission(id: String) async throws -> Domain.ReviewSubmission { let request = APIEndpoint.v1.reviewSubmissions.id(id).get(parameters: .init( include: [.app] @@ -77,7 +142,17 @@ public struct OpenAPISubmissionRepository: SubmissionRepository, @unchecked Send } public func listSubmissionItems(submissionId: String) async throws -> [Domain.ReviewSubmissionItem] { - let request = APIEndpoint.v1.reviewSubmissions.id(submissionId).items.get(parameters: .init()) + // `include` makes Apple return each item's relationship linkage, which the mapper + // reads to report what the item points at. + let request = APIEndpoint.v1.reviewSubmissions.id(submissionId).items.get(parameters: .init( + include: [ + .appStoreVersion, .appCustomProductPageVersion, + .appStoreVersionExperimentV2, .appEvent, .backgroundAssetVersion, + .gameCenterAchievementVersion, .gameCenterActivityVersion, .gameCenterChallengeVersion, + .gameCenterLeaderboardSetVersion, .gameCenterLeaderboardVersion, + .inAppPurchaseVersion, .subscriptionVersion, .subscriptionGroupVersion, + ] + )) let response = try await client.request(request) return response.data.map { mapSubmissionItem($0, submissionId: submissionId) } } @@ -105,6 +180,9 @@ public struct OpenAPISubmissionRepository: SubmissionRepository, @unchecked Send if let id = sdkItem.relationships?.gameCenterChallengeVersion?.data?.id { return (id, .gameCenterChallengeVersion) } if let id = sdkItem.relationships?.gameCenterLeaderboardSetVersion?.data?.id { return (id, .gameCenterLeaderboardSetVersion) } if let id = sdkItem.relationships?.gameCenterLeaderboardVersion?.data?.id { return (id, .gameCenterLeaderboardVersion) } + if let id = sdkItem.relationships?.inAppPurchaseVersion?.data?.id { return (id, .inAppPurchaseVersion) } + if let id = sdkItem.relationships?.subscriptionVersion?.data?.id { return (id, .subscriptionVersion) } + if let id = sdkItem.relationships?.subscriptionGroupVersion?.data?.id { return (id, .subscriptionGroupVersion) } return (nil, nil) }() @@ -187,4 +265,20 @@ public struct OpenAPISubmissionRepository: SubmissionRepository, @unchecked Send submittedDate: sdkSubmission.attributes?.submittedDate ) } + + /// Apple's refusals carry a generic headline ("please check associated errors") and + /// put the actual reasons in `meta.associatedErrors`, which the SDK's error text drops. + /// Surface them so the user knows what to fix. + private func explainingRefusal(_ request: () async throws -> T) async throws -> T { + do { + return try await request() + } catch APIProvider.Error.requestFailure(let status, let response?, _) where (400..<500).contains(status) { + guard let first = response.errors?.first else { throw APIError.unknown("\(response)") } + let reasons = (response.errors ?? []) + .compactMap { $0.meta?.associatedErrors } + .flatMap { $0.sorted(by: { $0.key < $1.key }).flatMap(\.value) } + .map { $0.detail ?? $0.title } + throw ReviewSubmissionError.refused(message: first.detail ?? first.title, reasons: reasons) + } + } } diff --git a/Tests/ASCCommandTests/Commands/IAP/IAPCreateTests.swift b/Tests/ASCCommandTests/Commands/IAP/IAPCreateTests.swift index 4724cc70..2a56d46e 100644 --- a/Tests/ASCCommandTests/Commands/IAP/IAPCreateTests.swift +++ b/Tests/ASCCommandTests/Commands/IAP/IAPCreateTests.swift @@ -42,6 +42,7 @@ struct IAPCreateTests { "listLocalizations" : "asc iap-localizations list --iap-id iap-new", "listOfferCodes" : "asc iap-offer-codes list --iap-id iap-new", "listPricePoints" : "asc iap price-points list --iap-id iap-new", + "listVersions" : "asc iap versions list --iap-id iap-new", "setPrice" : "asc iap prices set --base-territory --iap-id iap-new --price-point-id ", "update" : "asc iap update --iap-id iap-new --reference-name ", "uploadImage" : "asc iap-images upload --file --iap-id iap-new", diff --git a/Tests/ASCCommandTests/Commands/IAP/IAPListTests.swift b/Tests/ASCCommandTests/Commands/IAP/IAPListTests.swift index 761b5fc3..56df0a4b 100644 --- a/Tests/ASCCommandTests/Commands/IAP/IAPListTests.swift +++ b/Tests/ASCCommandTests/Commands/IAP/IAPListTests.swift @@ -38,6 +38,7 @@ struct IAPListTests { "listLocalizations" : "asc iap-localizations list --iap-id iap-1", "listOfferCodes" : "asc iap-offer-codes list --iap-id iap-1", "listPricePoints" : "asc iap price-points list --iap-id iap-1", + "listVersions" : "asc iap versions list --iap-id iap-1", "setPrice" : "asc iap prices set --base-territory --iap-id iap-1 --price-point-id ", "update" : "asc iap update --iap-id iap-1 --reference-name ", "uploadImage" : "asc iap-images upload --file --iap-id iap-1", diff --git a/Tests/ASCCommandTests/Commands/IAP/IAPVersionsListTests.swift b/Tests/ASCCommandTests/Commands/IAP/IAPVersionsListTests.swift new file mode 100644 index 00000000..73905651 --- /dev/null +++ b/Tests/ASCCommandTests/Commands/IAP/IAPVersionsListTests.swift @@ -0,0 +1,36 @@ +import Mockable +import Testing +@testable import ASCCommand +@testable import Domain + +@Suite +struct IAPVersionsListTests { + + @Test func `listed IAP versions show state and the command to add them to a submission`() async throws { + let mockRepo = MockProductVersionRepository() + given(mockRepo).listInAppPurchaseVersions(iapId: .value("iap-1")).willReturn([ + ProductVersion(id: "iv-1", productId: "iap-1", kind: .inAppPurchase, version: 1, state: .prepareForSubmission), + ]) + + let cmd = try IAPVersionsList.parse(["--iap-id", "iap-1", "--pretty"]) + let output = try await cmd.execute(repo: mockRepo) + + #expect(output == """ + { + "data" : [ + { + "affordances" : { + "addToSubmission" : "asc review-submissions items add --iap-version-id iv-1 --submission-id ", + "listVersions" : "asc iap versions list --iap-id iap-1" + }, + "id" : "iv-1", + "kind" : "IN_APP_PURCHASE", + "productId" : "iap-1", + "state" : "PREPARE_FOR_SUBMISSION", + "version" : 1 + } + ] + } + """) + } +} diff --git a/Tests/ASCCommandTests/Commands/ReviewSubmissions/ReviewSubmissionsBuildTests.swift b/Tests/ASCCommandTests/Commands/ReviewSubmissions/ReviewSubmissionsBuildTests.swift new file mode 100644 index 00000000..7eb50601 --- /dev/null +++ b/Tests/ASCCommandTests/Commands/ReviewSubmissions/ReviewSubmissionsBuildTests.swift @@ -0,0 +1,158 @@ +import ArgumentParser +import Mockable +import Testing +@testable import ASCCommand +@testable import Domain + +@Suite +struct ReviewSubmissionsBuildTests { + + // MARK: - create + + @Test func `creating a submission shows the draft with commands to add items and submit`() async throws { + let mockRepo = MockSubmissionRepository() + given(mockRepo).createSubmission(appId: .value("app-1"), platform: .value(.iOS)).willReturn( + ReviewSubmission(id: "sub-1", appId: "app-1", platform: .iOS, state: .readyForReview) + ) + + let cmd = try ReviewSubmissionsCreate.parse(["--app-id", "app-1", "--pretty"]) + let output = try await cmd.execute(repo: mockRepo) + + #expect(output == """ + { + "data" : [ + { + "affordances" : { + "addItem" : "asc review-submissions items add --submission-id sub-1 --version-id ", + "getSubmission" : "asc review-submissions get --submission-id sub-1", + "listItems" : "asc review-submissions items list --submission-id sub-1", + "listVersions" : "asc versions list --app-id app-1", + "submit" : "asc review-submissions submit --submission-id sub-1" + }, + "appId" : "app-1", + "id" : "sub-1", + "platform" : "IOS", + "state" : "READY_FOR_REVIEW" + } + ] + } + """) + } + + @Test func `creating a submission for macOS asks for a macOS draft`() async throws { + let mockRepo = MockSubmissionRepository() + given(mockRepo).createSubmission(appId: .any, platform: .value(.macOS)).willReturn( + ReviewSubmission(id: "sub-2", appId: "app-1", platform: .macOS, state: .readyForReview) + ) + + let cmd = try ReviewSubmissionsCreate.parse(["--app-id", "app-1", "--platform", "macos"]) + _ = try await cmd.execute(repo: mockRepo) + + verify(mockRepo).createSubmission(appId: .value("app-1"), platform: .value(.macOS)).called(1) + } + + @Test func `creating a submission rejects an unknown platform`() async throws { + let cmd = try ReviewSubmissionsCreate.parse(["--app-id", "app-1", "--platform", "android"]) + await #expect(throws: ValidationError.self) { try await cmd.execute(repo: MockSubmissionRepository()) } + } + + // MARK: - items add + + @Test func `adding a subscription version shows the new item and how to remove it`() async throws { + let mockRepo = MockSubmissionRepository() + given(mockRepo).addItem(submissionId: .value("sub-1"), target: .value(.subscriptionVersion("sv-1"))).willReturn( + ReviewSubmissionItem(id: "item-1", submissionId: "sub-1", state: .readyForReview, + linkedResourceId: "sv-1", linkedResourceType: .subscriptionVersion) + ) + + let cmd = try ReviewSubmissionItemsAdd.parse(["--submission-id", "sub-1", "--subscription-version-id", "sv-1", "--pretty"]) + let output = try await cmd.execute(repo: mockRepo) + + #expect(output == """ + { + "data" : [ + { + "affordances" : { + "getSubmission" : "asc review-submissions get --submission-id sub-1", + "listSiblings" : "asc review-submissions items list --submission-id sub-1", + "remove" : "asc review-submissions items remove --item-id item-1" + }, + "id" : "item-1", + "linkedResourceId" : "sv-1", + "linkedResourceType" : "SUBSCRIPTION_VERSION", + "state" : "READY_FOR_REVIEW", + "submissionId" : "sub-1" + } + ] + } + """) + } + + @Test func `each version flag adds that kind of version`() async throws { + let cases: [(String, ReviewItemTarget)] = [ + ("--version-id", .appStoreVersion("x-1")), + ("--iap-version-id", .inAppPurchaseVersion("x-1")), + ("--subscription-version-id", .subscriptionVersion("x-1")), + ("--subscription-group-version-id", .subscriptionGroupVersion("x-1")), + ] + for (flag, target) in cases { + let mockRepo = MockSubmissionRepository() + given(mockRepo).addItem(submissionId: .any, target: .any).willReturn( + ReviewSubmissionItem(id: "item-1", submissionId: "sub-1", state: .readyForReview) + ) + let cmd = try ReviewSubmissionItemsAdd.parse(["--submission-id", "sub-1", flag, "x-1"]) + _ = try await cmd.execute(repo: mockRepo) + verify(mockRepo).addItem(submissionId: .value("sub-1"), target: .value(target)).called(1) + } + } + + @Test func `adding an item needs exactly one version flag`() async throws { + let none = try ReviewSubmissionItemsAdd.parse(["--submission-id", "sub-1"]) + await #expect(throws: ValidationError.self) { try await none.execute(repo: MockSubmissionRepository()) } + + let two = try ReviewSubmissionItemsAdd.parse(["--submission-id", "sub-1", "--version-id", "v-1", "--iap-version-id", "iv-1"]) + await #expect(throws: ValidationError.self) { try await two.execute(repo: MockSubmissionRepository()) } + } + + // MARK: - items remove + + @Test func `removing an item removes it from its submission`() async throws { + let mockRepo = MockSubmissionRepository() + given(mockRepo).removeItem(itemId: .any).willReturn() + + let cmd = try ReviewSubmissionItemsRemove.parse(["--item-id", "item-1"]) + try await cmd.execute(repo: mockRepo) + + verify(mockRepo).removeItem(itemId: .value("item-1")).called(1) + } + + // MARK: - submit + + @Test func `submitting shows the submission waiting for review`() async throws { + let mockRepo = MockSubmissionRepository() + given(mockRepo).submit(submissionId: .value("sub-1")).willReturn( + ReviewSubmission(id: "sub-1", appId: "app-1", platform: .iOS, state: .waitingForReview) + ) + + let cmd = try ReviewSubmissionsSubmit.parse(["--submission-id", "sub-1", "--pretty"]) + let output = try await cmd.execute(repo: mockRepo) + + #expect(output == """ + { + "data" : [ + { + "affordances" : { + "getSubmission" : "asc review-submissions get --submission-id sub-1", + "listItems" : "asc review-submissions items list --submission-id sub-1", + "listVersions" : "asc versions list --app-id app-1" + }, + "appId" : "app-1", + "id" : "sub-1", + "platform" : "IOS", + "state" : "WAITING_FOR_REVIEW" + } + ] + } + """) + } +} diff --git a/Tests/ASCCommandTests/Commands/ReviewSubmissions/ReviewSubmissionsGetTests.swift b/Tests/ASCCommandTests/Commands/ReviewSubmissions/ReviewSubmissionsGetTests.swift index 3727c9ce..d45979d6 100644 --- a/Tests/ASCCommandTests/Commands/ReviewSubmissions/ReviewSubmissionsGetTests.swift +++ b/Tests/ASCCommandTests/Commands/ReviewSubmissions/ReviewSubmissionsGetTests.swift @@ -20,11 +20,13 @@ struct ReviewSubmissionsGetTests { "data" : [ { "affordances" : { + "addItem" : "asc review-submissions items add --submission-id sub-1 --version-id ", "getResolutionDetails" : "asc iris resolution-center get --submission-id sub-1", "getSubmission" : "asc review-submissions get --submission-id sub-1", "listItems" : "asc review-submissions items list --submission-id sub-1", "listRejectedItems" : "asc review-submissions items list --state REJECTED --submission-id sub-1", - "listVersions" : "asc versions list --app-id app-42" + "listVersions" : "asc versions list --app-id app-42", + "submit" : "asc review-submissions submit --submission-id sub-1" }, "appId" : "app-42", "id" : "sub-1", diff --git a/Tests/ASCCommandTests/Commands/SubscriptionGroups/SubscriptionGroupVersionsListTests.swift b/Tests/ASCCommandTests/Commands/SubscriptionGroups/SubscriptionGroupVersionsListTests.swift new file mode 100644 index 00000000..06fc3f2b --- /dev/null +++ b/Tests/ASCCommandTests/Commands/SubscriptionGroups/SubscriptionGroupVersionsListTests.swift @@ -0,0 +1,36 @@ +import Mockable +import Testing +@testable import ASCCommand +@testable import Domain + +@Suite +struct SubscriptionGroupVersionsListTests { + + @Test func `listed subscription group versions show the command to add them to a submission`() async throws { + let mockRepo = MockProductVersionRepository() + given(mockRepo).listSubscriptionGroupVersions(groupId: .value("grp-1")).willReturn([ + ProductVersion(id: "gv-1", productId: "grp-1", kind: .subscriptionGroup, version: 1, state: .prepareForSubmission), + ]) + + let cmd = try SubscriptionGroupVersionsList.parse(["--group-id", "grp-1", "--pretty"]) + let output = try await cmd.execute(repo: mockRepo) + + #expect(output == """ + { + "data" : [ + { + "affordances" : { + "addToSubmission" : "asc review-submissions items add --submission-id --subscription-group-version-id gv-1", + "listVersions" : "asc subscription-groups versions list --group-id grp-1" + }, + "id" : "gv-1", + "kind" : "SUBSCRIPTION_GROUP", + "productId" : "grp-1", + "state" : "PREPARE_FOR_SUBMISSION", + "version" : 1 + } + ] + } + """) + } +} diff --git a/Tests/ASCCommandTests/Commands/SubscriptionGroups/SubscriptionGroupsCreateTests.swift b/Tests/ASCCommandTests/Commands/SubscriptionGroups/SubscriptionGroupsCreateTests.swift index 98a72f2f..bce2e10c 100644 --- a/Tests/ASCCommandTests/Commands/SubscriptionGroups/SubscriptionGroupsCreateTests.swift +++ b/Tests/ASCCommandTests/Commands/SubscriptionGroups/SubscriptionGroupsCreateTests.swift @@ -32,6 +32,7 @@ struct SubscriptionGroupsCreateTests { "delete" : "asc subscription-groups delete --group-id grp-new", "listLocalizations" : "asc subscription-group-localizations list --group-id grp-new", "listSubscriptions" : "asc subscriptions list --group-id grp-new", + "listVersions" : "asc subscription-groups versions list --group-id grp-new", "update" : "asc subscription-groups update --group-id grp-new --reference-name " }, "appId" : "app-1", diff --git a/Tests/ASCCommandTests/Commands/SubscriptionGroups/SubscriptionGroupsListTests.swift b/Tests/ASCCommandTests/Commands/SubscriptionGroups/SubscriptionGroupsListTests.swift index 95f51cd4..83f29603 100644 --- a/Tests/ASCCommandTests/Commands/SubscriptionGroups/SubscriptionGroupsListTests.swift +++ b/Tests/ASCCommandTests/Commands/SubscriptionGroups/SubscriptionGroupsListTests.swift @@ -26,6 +26,7 @@ struct SubscriptionGroupsListTests { "delete" : "asc subscription-groups delete --group-id grp-1", "listLocalizations" : "asc subscription-group-localizations list --group-id grp-1", "listSubscriptions" : "asc subscriptions list --group-id grp-1", + "listVersions" : "asc subscription-groups versions list --group-id grp-1", "update" : "asc subscription-groups update --group-id grp-1 --reference-name " }, "appId" : "app-1", diff --git a/Tests/ASCCommandTests/Commands/Subscriptions/SubscriptionVersionsListTests.swift b/Tests/ASCCommandTests/Commands/Subscriptions/SubscriptionVersionsListTests.swift new file mode 100644 index 00000000..8f747cdc --- /dev/null +++ b/Tests/ASCCommandTests/Commands/Subscriptions/SubscriptionVersionsListTests.swift @@ -0,0 +1,35 @@ +import Mockable +import Testing +@testable import ASCCommand +@testable import Domain + +@Suite +struct SubscriptionVersionsListTests { + + @Test func `listed subscription versions in review do not offer adding them to a submission`() async throws { + let mockRepo = MockProductVersionRepository() + given(mockRepo).listSubscriptionVersions(subscriptionId: .value("sub-1")).willReturn([ + ProductVersion(id: "sv-1", productId: "sub-1", kind: .subscription, version: 2, state: .waitingForReview), + ]) + + let cmd = try SubscriptionVersionsList.parse(["--subscription-id", "sub-1", "--pretty"]) + let output = try await cmd.execute(repo: mockRepo) + + #expect(output == """ + { + "data" : [ + { + "affordances" : { + "listVersions" : "asc subscriptions versions list --subscription-id sub-1" + }, + "id" : "sv-1", + "kind" : "SUBSCRIPTION", + "productId" : "sub-1", + "state" : "WAITING_FOR_REVIEW", + "version" : 2 + } + ] + } + """) + } +} diff --git a/Tests/ASCCommandTests/Commands/Subscriptions/SubscriptionsCreateTests.swift b/Tests/ASCCommandTests/Commands/Subscriptions/SubscriptionsCreateTests.swift index b967ec4e..c8c6dfab 100644 --- a/Tests/ASCCommandTests/Commands/Subscriptions/SubscriptionsCreateTests.swift +++ b/Tests/ASCCommandTests/Commands/Subscriptions/SubscriptionsCreateTests.swift @@ -47,6 +47,7 @@ struct SubscriptionsCreateTests { "listOfferCodes" : "asc subscription-offer-codes list --subscription-id sub-new", "listPricePoints" : "asc subscriptions price-points list --subscription-id sub-new", "listPromotionalOffers" : "asc subscription-promotional-offers list --subscription-id sub-new", + "listVersions" : "asc subscriptions versions list --subscription-id sub-new", "listWinBackOffers" : "asc win-back-offers list --subscription-id sub-new", "setPrices" : "asc subscriptions prices set-batch --price = --subscription-id sub-new", "update" : "asc subscriptions update --name --subscription-id sub-new", diff --git a/Tests/ASCCommandTests/Commands/Subscriptions/SubscriptionsListTests.swift b/Tests/ASCCommandTests/Commands/Subscriptions/SubscriptionsListTests.swift index ad96e749..f5e44f6e 100644 --- a/Tests/ASCCommandTests/Commands/Subscriptions/SubscriptionsListTests.swift +++ b/Tests/ASCCommandTests/Commands/Subscriptions/SubscriptionsListTests.swift @@ -43,6 +43,7 @@ struct SubscriptionsListTests { "listOfferCodes" : "asc subscription-offer-codes list --subscription-id sub-1", "listPricePoints" : "asc subscriptions price-points list --subscription-id sub-1", "listPromotionalOffers" : "asc subscription-promotional-offers list --subscription-id sub-1", + "listVersions" : "asc subscriptions versions list --subscription-id sub-1", "listWinBackOffers" : "asc win-back-offers list --subscription-id sub-1", "setPrices" : "asc subscriptions prices set-batch --price = --subscription-id sub-1", "update" : "asc subscriptions update --name --subscription-id sub-1", diff --git a/Tests/ASCCommandTests/Commands/Versions/VersionsSubmitTests.swift b/Tests/ASCCommandTests/Commands/Versions/VersionsSubmitTests.swift index d71b085d..4361bfb8 100644 --- a/Tests/ASCCommandTests/Commands/Versions/VersionsSubmitTests.swift +++ b/Tests/ASCCommandTests/Commands/Versions/VersionsSubmitTests.swift @@ -38,4 +38,110 @@ struct VersionsSubmitTests { } """) } + + // MARK: - --with-products + + private func planner(iapRepo: MockInAppPurchaseRepository, versionRepo: MockProductVersionRepository) -> SubmissionPlanner { + let groups = MockSubscriptionGroupRepository() + given(groups).listSubscriptionGroups(appId: .any, limit: .any).willReturn(PaginatedResponse(data: [])) + return SubmissionPlanner(iapRepo: iapRepo, groupRepo: groups, + subscriptionRepo: MockSubscriptionRepository(), productVersionRepo: versionRepo) + } + + private func readyLifetimeIAP() -> (MockInAppPurchaseRepository, MockProductVersionRepository) { + let iaps = MockInAppPurchaseRepository() + given(iaps).listInAppPurchases(appId: .any, limit: .any).willReturn(PaginatedResponse(data: [ + InAppPurchase(id: "iap-1", appId: "app-1", referenceName: "Lifetime", productId: "com.app.lifetime", + type: .nonConsumable, state: .readyToSubmit), + ])) + let versions = MockProductVersionRepository() + given(versions).listInAppPurchaseVersions(iapId: .any).willReturn([ + ProductVersion(id: "iv-1", productId: "iap-1", kind: .inAppPurchase, version: 1, state: .prepareForSubmission), + ]) + return (iaps, versions) + } + + private func appVersionRepo() -> MockVersionRepository { + let repo = MockVersionRepository() + given(repo).getVersion(id: .value("v-1")).willReturn( + AppStoreVersion(id: "v-1", appId: "app-1", versionString: "1.0", platform: .iOS, state: .prepareForSubmission) + ) + return repo + } + + @Test func `a dry run lists what would go to review with the app version and submits nothing`() async throws { + let (iaps, versions) = readyLifetimeIAP() + let submissions = MockSubmissionRepository() + + let cmd = try VersionsSubmit.parse(["--version-id", "v-1", "--with-products", "--dry-run", "--pretty"]) + let output = try await cmd.executeWithProducts( + submissionRepo: submissions, versionRepo: appVersionRepo(), planner: planner(iapRepo: iaps, versionRepo: versions) + ) + + #expect(output == """ + { + "data" : [ + { + "affordances" : { + "getVersion" : "asc versions get --version-id v-1" + }, + "kind" : "APP_STORE_VERSION", + "name" : "1.0", + "productId" : "app-1", + "versionId" : "v-1" + }, + { + "affordances" : { + "listVersions" : "asc iap versions list --iap-id iap-1" + }, + "kind" : "IN_APP_PURCHASE_VERSION", + "name" : "Lifetime", + "productId" : "iap-1", + "versionId" : "iv-1" + } + ] + } + """) + verify(submissions).createSubmission(appId: .any, platform: .any).called(0) + verify(submissions).submit(submissionId: .any).called(0) + } + + @Test func `with products the app version and ready products are submitted together`() async throws { + let (iaps, versions) = readyLifetimeIAP() + let submissions = MockSubmissionRepository() + given(submissions).createSubmission(appId: .any, platform: .any).willReturn( + ReviewSubmission(id: "sub-1", appId: "app-1", platform: .iOS, state: .readyForReview) + ) + given(submissions).addItem(submissionId: .any, target: .any).willReturn( + ReviewSubmissionItem(id: "item-1", submissionId: "sub-1", state: .readyForReview) + ) + given(submissions).submit(submissionId: .value("sub-1")).willReturn( + ReviewSubmission(id: "sub-1", appId: "app-1", platform: .iOS, state: .waitingForReview) + ) + + let cmd = try VersionsSubmit.parse(["--version-id", "v-1", "--with-products", "--pretty"]) + let output = try await cmd.executeWithProducts( + submissionRepo: submissions, versionRepo: appVersionRepo(), planner: planner(iapRepo: iaps, versionRepo: versions) + ) + + #expect(output == """ + { + "data" : [ + { + "affordances" : { + "getSubmission" : "asc review-submissions get --submission-id sub-1", + "listItems" : "asc review-submissions items list --submission-id sub-1", + "listVersions" : "asc versions list --app-id app-1" + }, + "appId" : "app-1", + "id" : "sub-1", + "platform" : "IOS", + "state" : "WAITING_FOR_REVIEW" + } + ] + } + """) + verify(submissions).addItem(submissionId: .value("sub-1"), target: .value(.appStoreVersion("v-1"))).called(1) + verify(submissions).addItem(submissionId: .value("sub-1"), target: .value(.inAppPurchaseVersion("iv-1"))).called(1) + } } diff --git a/Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift b/Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift index 30935cc0..32c1776b 100644 --- a/Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift +++ b/Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift @@ -1151,4 +1151,82 @@ struct RESTRoutesTests { #expect(normalized.contains("/api/v1/apps/42/experiments")) } + + // MARK: - Product versions + + @Test func `product version lists return REST links to siblings and to add to a submission`() async throws { + let mockRepo = MockProductVersionRepository() + given(mockRepo).listInAppPurchaseVersions(iapId: .any).willReturn([ + ProductVersion(id: "iv-1", productId: "iap-1", kind: .inAppPurchase, version: 1, state: .prepareForSubmission), + ]) + given(mockRepo).listSubscriptionVersions(subscriptionId: .any).willReturn([ + ProductVersion(id: "sv-1", productId: "sub-1", kind: .subscription, version: 1, state: .prepareForSubmission), + ]) + given(mockRepo).listSubscriptionGroupVersions(groupId: .any).willReturn([ + ProductVersion(id: "gv-1", productId: "grp-1", kind: .subscriptionGroup, version: 1, state: .prepareForSubmission), + ]) + + let iap = try await IAPVersionsList.parse(["--iap-id", "iap-1"]).execute(repo: mockRepo, affordanceMode: .rest) + let sub = try await SubscriptionVersionsList.parse(["--subscription-id", "sub-1"]).execute(repo: mockRepo, affordanceMode: .rest) + let group = try await SubscriptionGroupVersionsList.parse(["--group-id", "grp-1"]).execute(repo: mockRepo, affordanceMode: .rest) + let all = [iap, sub, group].map { $0.replacingOccurrences(of: "\\/", with: "/") } + + #expect(all.allSatisfy { $0.contains("\"_links\"") }) + #expect(all[0].contains("/api/v1/iap/iap-1/versions")) + #expect(all[1].contains("/api/v1/subscriptions/sub-1/versions")) + #expect(all[2].contains("/api/v1/subscription-groups/grp-1/versions")) + #expect(all.allSatisfy { $0.contains("/api/v1/review-submissions//items") }) + } + + // MARK: - Building a review submission + + @Test func `a draft submission links to adding items and submitting, and a new item links to removing it`() async throws { + let mockRepo = MockSubmissionRepository() + given(mockRepo).createSubmission(appId: .any, platform: .any).willReturn( + ReviewSubmission(id: "sub-1", appId: "app-1", platform: .iOS, state: .readyForReview) + ) + given(mockRepo).addItem(submissionId: .any, target: .any).willReturn( + ReviewSubmissionItem(id: "item-1", submissionId: "sub-1", state: .readyForReview, + linkedResourceId: "iv-1", linkedResourceType: .inAppPurchaseVersion) + ) + + let draft = try await ReviewSubmissionsCreate.parse(["--app-id", "app-1"]) + .execute(repo: mockRepo, affordanceMode: .rest).replacingOccurrences(of: "\\/", with: "/") + let item = try await ReviewSubmissionItemsAdd.parse(["--submission-id", "sub-1", "--iap-version-id", "iv-1"]) + .execute(repo: mockRepo, affordanceMode: .rest).replacingOccurrences(of: "\\/", with: "/") + + #expect(draft.contains("\"_links\"")) + #expect(draft.contains("/api/v1/review-submissions/sub-1/items")) + #expect(draft.contains("/api/v1/review-submissions/sub-1/submit")) + #expect(item.contains("/api/v1/review-submissions/items/item-1")) + #expect(item.contains("\"DELETE\"")) + } + + @Test func `a dry run over REST links each planned product to its versions`() async throws { + let versionRepo = MockVersionRepository() + given(versionRepo).getVersion(id: .any).willReturn( + AppStoreVersion(id: "v-1", appId: "app-1", versionString: "1.0", platform: .iOS, state: .prepareForSubmission) + ) + let iaps = MockInAppPurchaseRepository() + given(iaps).listInAppPurchases(appId: .any, limit: .any).willReturn(PaginatedResponse(data: [ + InAppPurchase(id: "iap-1", appId: "app-1", referenceName: "Lifetime", productId: "com.app.lifetime", + type: .nonConsumable, state: .readyToSubmit), + ])) + let groups = MockSubscriptionGroupRepository() + given(groups).listSubscriptionGroups(appId: .any, limit: .any).willReturn(PaginatedResponse(data: [])) + let productVersions = MockProductVersionRepository() + given(productVersions).listInAppPurchaseVersions(iapId: .any).willReturn([ + ProductVersion(id: "iv-1", productId: "iap-1", kind: .inAppPurchase, version: 1, state: .prepareForSubmission), + ]) + let planner = SubmissionPlanner(iapRepo: iaps, groupRepo: groups, + subscriptionRepo: MockSubscriptionRepository(), productVersionRepo: productVersions) + + let output = try await VersionsSubmit.parse(["--version-id", "v-1", "--dry-run"]).executeWithProducts( + submissionRepo: MockSubmissionRepository(), versionRepo: versionRepo, planner: planner, affordanceMode: .rest + ).replacingOccurrences(of: "\\/", with: "/") + + #expect(output.contains("\"_links\"")) + #expect(output.contains("/api/v1/versions/v-1")) + #expect(output.contains("/api/v1/iap/iap-1/versions")) + } } diff --git a/Tests/DomainTests/Apps/InAppPurchases/InAppPurchaseTests.swift b/Tests/DomainTests/Apps/InAppPurchases/InAppPurchaseTests.swift index f0e61cde..332eaba0 100644 --- a/Tests/DomainTests/Apps/InAppPurchases/InAppPurchaseTests.swift +++ b/Tests/DomainTests/Apps/InAppPurchases/InAppPurchaseTests.swift @@ -306,4 +306,10 @@ struct InAppPurchaseTests { #expect(iap.apiLinks["setPrice"]?.href == "/api/v1/iap/iap-1/prices/set") #expect(iap.apiLinks["setPrice"]?.method == "POST") } + + @Test func `an in-app purchase points at its review versions`() { + let iap = MockRepositoryFactory.makeInAppPurchase(id: "iap-1") + #expect(iap.affordances["listVersions"] == "asc iap versions list --iap-id iap-1") + #expect(iap.apiLinks["listVersions"]?.href == "/api/v1/iap/iap-1/versions") + } } diff --git a/Tests/DomainTests/Apps/Performance/PerfPowerMetricTests.swift b/Tests/DomainTests/Apps/Performance/PerfPowerMetricTests.swift index 65bfc173..c17a608d 100644 --- a/Tests/DomainTests/Apps/Performance/PerfPowerMetricTests.swift +++ b/Tests/DomainTests/Apps/Performance/PerfPowerMetricTests.swift @@ -38,6 +38,7 @@ struct PerformanceMetricTests { #expect(PerformanceMetricCategory.battery.rawValue == "BATTERY") #expect(PerformanceMetricCategory.termination.rawValue == "TERMINATION") #expect(PerformanceMetricCategory.animation.rawValue == "ANIMATION") + #expect(PerformanceMetricCategory.storage.rawValue == "STORAGE") } @Test func `metric affordances for app parent include list app metrics`() { diff --git a/Tests/DomainTests/Apps/ProductVersions/ProductVersionTests.swift b/Tests/DomainTests/Apps/ProductVersions/ProductVersionTests.swift new file mode 100644 index 00000000..e405613a --- /dev/null +++ b/Tests/DomainTests/Apps/ProductVersions/ProductVersionTests.swift @@ -0,0 +1,78 @@ +import Testing +@testable import Domain + +@Suite +struct ProductVersionTests { + + // MARK: - State + + @Test func `a version being prepared can be added to a review submission`() { + let version = MockRepositoryFactory.makeProductVersion(state: .prepareForSubmission) + #expect(version.isSubmittable) + #expect(!version.isInReview) + } + + @Test func `a rejected version can be added to a review submission again`() { + #expect(MockRepositoryFactory.makeProductVersion(state: .rejected).isSubmittable) + #expect(MockRepositoryFactory.makeProductVersion(state: .developerRejected).isSubmittable) + } + + @Test func `a version waiting for or in review is in review and not submittable`() { + for state in [ProductVersionState.waitingForReview, .inReview] { + let version = MockRepositoryFactory.makeProductVersion(state: state) + #expect(version.isInReview) + #expect(!version.isSubmittable) + } + } + + @Test func `an accepted or approved version is approved`() { + #expect(MockRepositoryFactory.makeProductVersion(state: .approved).isApproved) + #expect(MockRepositoryFactory.makeProductVersion(state: .accepted).isApproved) + #expect(!MockRepositoryFactory.makeProductVersion(state: .approved).isSubmittable) + } + + @Test func `version states use Apple's raw values`() { + #expect(ProductVersionState.prepareForSubmission.rawValue == "PREPARE_FOR_SUBMISSION") + #expect(ProductVersionState.readyForReview.rawValue == "READY_FOR_REVIEW") + #expect(ProductVersionState.replacedWithNewVersion.rawValue == "REPLACED_WITH_NEW_VERSION") + #expect(ProductVersionKind.inAppPurchase.rawValue == "IN_APP_PURCHASE") + #expect(ProductVersionKind.subscription.rawValue == "SUBSCRIPTION") + #expect(ProductVersionKind.subscriptionGroup.rawValue == "SUBSCRIPTION_GROUP") + } + + // MARK: - Affordances + + @Test func `each kind of version points at the command listing its product's versions`() { + let iap = MockRepositoryFactory.makeProductVersion(productId: "iap-1", kind: .inAppPurchase) + let sub = MockRepositoryFactory.makeProductVersion(productId: "sub-1", kind: .subscription) + let group = MockRepositoryFactory.makeProductVersion(productId: "grp-1", kind: .subscriptionGroup) + #expect(iap.affordances["listVersions"] == "asc iap versions list --iap-id iap-1") + #expect(sub.affordances["listVersions"] == "asc subscriptions versions list --subscription-id sub-1") + #expect(group.affordances["listVersions"] == "asc subscription-groups versions list --group-id grp-1") + } + + @Test func `a submittable version offers adding itself to a review submission`() { + let iap = MockRepositoryFactory.makeProductVersion(id: "iv-1", kind: .inAppPurchase) + let sub = MockRepositoryFactory.makeProductVersion(id: "sv-1", kind: .subscription) + let group = MockRepositoryFactory.makeProductVersion(id: "gv-1", kind: .subscriptionGroup) + #expect(iap.affordances["addToSubmission"] + == "asc review-submissions items add --iap-version-id iv-1 --submission-id ") + #expect(sub.affordances["addToSubmission"] + == "asc review-submissions items add --submission-id --subscription-version-id sv-1") + #expect(group.affordances["addToSubmission"] + == "asc review-submissions items add --submission-id --subscription-group-version-id gv-1") + } + + @Test func `a version already in review does not offer adding it to a submission`() { + let version = MockRepositoryFactory.makeProductVersion(state: .waitingForReview) + #expect(version.affordances["addToSubmission"] == nil) + } + + @Test func `version REST links list siblings and add to a submission`() { + let version = MockRepositoryFactory.makeProductVersion(id: "sv-1", productId: "sub-1", kind: .subscription) + #expect(version.apiLinks["listVersions"]?.href == "/api/v1/subscriptions/sub-1/versions") + #expect(version.apiLinks["listVersions"]?.method == "GET") + #expect(version.apiLinks["addToSubmission"]?.href == "/api/v1/review-submissions//items") + #expect(version.apiLinks["addToSubmission"]?.method == "POST") + } +} diff --git a/Tests/DomainTests/Apps/Subscriptions/SubscriptionTests.swift b/Tests/DomainTests/Apps/Subscriptions/SubscriptionTests.swift index e2045604..b32c6ed3 100644 --- a/Tests/DomainTests/Apps/Subscriptions/SubscriptionTests.swift +++ b/Tests/DomainTests/Apps/Subscriptions/SubscriptionTests.swift @@ -266,4 +266,16 @@ struct SubscriptionTests { #expect(ready.apiLinks["submit"]?.method == "POST") #expect(missing.apiLinks["submit"] == nil) } + + @Test func `a subscription points at its review versions`() { + let subscription = MockRepositoryFactory.makeSubscription(id: "sub-1") + #expect(subscription.affordances["listVersions"] == "asc subscriptions versions list --subscription-id sub-1") + #expect(subscription.apiLinks["listVersions"]?.href == "/api/v1/subscriptions/sub-1/versions") + } + + @Test func `a subscription group points at its review versions`() { + let group = MockRepositoryFactory.makeSubscriptionGroup(id: "grp-1") + #expect(group.affordances["listVersions"] == "asc subscription-groups versions list --group-id grp-1") + #expect(group.apiLinks["listVersions"]?.href == "/api/v1/subscription-groups/grp-1/versions") + } } diff --git a/Tests/DomainTests/Submissions/ReviewItemTargetTests.swift b/Tests/DomainTests/Submissions/ReviewItemTargetTests.swift new file mode 100644 index 00000000..61489d93 --- /dev/null +++ b/Tests/DomainTests/Submissions/ReviewItemTargetTests.swift @@ -0,0 +1,18 @@ +import Testing +@testable import Domain + +@Suite +struct ReviewItemTargetTests { + + @Test func `exactly one version id picks what the item sends to review`() { + #expect(ReviewItemTarget(versionId: "v-1") == .appStoreVersion("v-1")) + #expect(ReviewItemTarget(iapVersionId: "iv-1") == .inAppPurchaseVersion("iv-1")) + #expect(ReviewItemTarget(subscriptionVersionId: "sv-1") == .subscriptionVersion("sv-1")) + #expect(ReviewItemTarget(subscriptionGroupVersionId: "gv-1") == .subscriptionGroupVersion("gv-1")) + } + + @Test func `no version id or more than one is not a valid item`() { + #expect(ReviewItemTarget() == nil) + #expect(ReviewItemTarget(versionId: "v-1", iapVersionId: "iv-1") == nil) + } +} diff --git a/Tests/DomainTests/Submissions/ReviewSubmissionItemTests.swift b/Tests/DomainTests/Submissions/ReviewSubmissionItemTests.swift index ac7b2aea..c71b6ecf 100644 --- a/Tests/DomainTests/Submissions/ReviewSubmissionItemTests.swift +++ b/Tests/DomainTests/Submissions/ReviewSubmissionItemTests.swift @@ -93,4 +93,22 @@ struct ReviewSubmissionItemTests { ) #expect(item.affordances["getVersion"] == nil) } + + @Test func `items can point at in-app purchase, subscription and subscription group versions`() { + #expect(ReviewSubmissionItemLinkedResource.inAppPurchaseVersion.rawValue == "IN_APP_PURCHASE_VERSION") + #expect(ReviewSubmissionItemLinkedResource.subscriptionVersion.rawValue == "SUBSCRIPTION_VERSION") + #expect(ReviewSubmissionItemLinkedResource.subscriptionGroupVersion.rawValue == "SUBSCRIPTION_GROUP_VERSION") + } + + @Test func `an item not yet submitted can be removed from its submission`() { + let item = MockRepositoryFactory.makeReviewSubmissionItem(id: "item-1", state: .readyForReview) + #expect(item.affordances["remove"] == "asc review-submissions items remove --item-id item-1") + #expect(item.apiLinks["remove"]?.href == "/api/v1/review-submissions/items/item-1") + #expect(item.apiLinks["remove"]?.method == "DELETE") + } + + @Test func `a reviewed item cannot be removed`() { + let item = MockRepositoryFactory.makeReviewSubmissionItem(state: .approved) + #expect(item.affordances["remove"] == nil) + } } diff --git a/Tests/DomainTests/Submissions/ReviewSubmissionTests.swift b/Tests/DomainTests/Submissions/ReviewSubmissionTests.swift index cc1e2017..3aa3535a 100644 --- a/Tests/DomainTests/Submissions/ReviewSubmissionTests.swift +++ b/Tests/DomainTests/Submissions/ReviewSubmissionTests.swift @@ -80,4 +80,31 @@ struct ReviewSubmissionTests { func `state displayName is human readable`(state: ReviewSubmissionState, expected: String) { #expect(state.displayName == expected) } + + // MARK: - Draft submissions + + @Test func `a draft or rejected submission can still take items and be submitted`() { + #expect(MockRepositoryFactory.makeReviewSubmission(state: .readyForReview).isEditable) + #expect(MockRepositoryFactory.makeReviewSubmission(state: .unresolvedIssues).isEditable) + for state in [ReviewSubmissionState.waitingForReview, .inReview, .canceling, .completing, .complete] { + #expect(!MockRepositoryFactory.makeReviewSubmission(state: state).isEditable) + } + } + + @Test func `a draft submission offers adding an item and submitting it`() { + let submission = MockRepositoryFactory.makeReviewSubmission(id: "sub-1", state: .readyForReview) + #expect(submission.affordances["addItem"] + == "asc review-submissions items add --submission-id sub-1 --version-id ") + #expect(submission.affordances["submit"] == "asc review-submissions submit --submission-id sub-1") + #expect(submission.apiLinks["addItem"]?.href == "/api/v1/review-submissions/sub-1/items") + #expect(submission.apiLinks["addItem"]?.method == "POST") + #expect(submission.apiLinks["submit"]?.href == "/api/v1/review-submissions/sub-1/submit") + #expect(submission.apiLinks["submit"]?.method == "POST") + } + + @Test func `a submission already sent to review offers neither adding items nor submitting`() { + let submission = MockRepositoryFactory.makeReviewSubmission(state: .waitingForReview) + #expect(submission.affordances["addItem"] == nil) + #expect(submission.affordances["submit"] == nil) + } } diff --git a/Tests/DomainTests/Submissions/SubmissionPlannerTests.swift b/Tests/DomainTests/Submissions/SubmissionPlannerTests.swift new file mode 100644 index 00000000..bc31fc9b --- /dev/null +++ b/Tests/DomainTests/Submissions/SubmissionPlannerTests.swift @@ -0,0 +1,154 @@ +import Foundation +import Mockable +import Testing +@testable import Domain + +@Suite +struct SubmissionPlannerTests { + + private let version = MockRepositoryFactory.makeVersion( + id: "v-1", appId: "app-1", versionString: "1.0", platform: .iOS, state: .prepareForSubmission + ) + + private struct Repos { + let iaps = MockInAppPurchaseRepository() + let groups = MockSubscriptionGroupRepository() + let subscriptions = MockSubscriptionRepository() + let versions = MockProductVersionRepository() + + var planner: SubmissionPlanner { + SubmissionPlanner(iapRepo: iaps, groupRepo: groups, subscriptionRepo: subscriptions, productVersionRepo: versions) + } + + func noProducts() { + given(iaps).listInAppPurchases(appId: .any, limit: .any).willReturn(PaginatedResponse(data: [])) + given(groups).listSubscriptionGroups(appId: .any, limit: .any).willReturn(PaginatedResponse(data: [])) + } + } + + // MARK: - Planning + + @Test func `with no ready products the plan holds just the app version`() async throws { + let repos = Repos() + repos.noProducts() + + let plan = try await repos.planner.plan(for: version) + + #expect(plan.appId == "app-1") + #expect(plan.platform == .iOS) + #expect(plan.items == [PlannedReviewItem(kind: .appStoreVersion, versionId: "v-1", productId: "app-1", name: "1.0")]) + } + + @Test func `a ready in-app purchase joins the plan after the app version`() async throws { + let repos = Repos() + given(repos.iaps).listInAppPurchases(appId: .value("app-1"), limit: .any).willReturn(PaginatedResponse(data: [ + MockRepositoryFactory.makeInAppPurchase(id: "iap-1", referenceName: "Lifetime", state: .readyToSubmit), + MockRepositoryFactory.makeInAppPurchase(id: "iap-2", referenceName: "Draft", state: .missingMetadata), + ])) + given(repos.groups).listSubscriptionGroups(appId: .any, limit: .any).willReturn(PaginatedResponse(data: [])) + given(repos.versions).listInAppPurchaseVersions(iapId: .value("iap-1")).willReturn([ + MockRepositoryFactory.makeProductVersion(id: "iv-1", productId: "iap-1", kind: .inAppPurchase, state: .prepareForSubmission), + ]) + + let plan = try await repos.planner.plan(for: version) + + #expect(plan.items.map(\.kind) == [.appStoreVersion, .inAppPurchaseVersion]) + #expect(plan.items[1] == PlannedReviewItem(kind: .inAppPurchaseVersion, versionId: "iv-1", productId: "iap-1", name: "Lifetime")) + } + + @Test func `a ready subscription joins the plan after its group's version`() async throws { + let repos = Repos() + given(repos.iaps).listInAppPurchases(appId: .any, limit: .any).willReturn(PaginatedResponse(data: [])) + given(repos.groups).listSubscriptionGroups(appId: .value("app-1"), limit: .any).willReturn(PaginatedResponse(data: [ + MockRepositoryFactory.makeSubscriptionGroup(id: "grp-1", referenceName: "Pro"), + ])) + given(repos.subscriptions).listSubscriptions(groupId: .value("grp-1"), limit: .any).willReturn(PaginatedResponse(data: [ + MockRepositoryFactory.makeSubscription(id: "sub-1", groupId: "grp-1", name: "Monthly", state: .readyToSubmit), + ])) + given(repos.versions).listSubscriptionGroupVersions(groupId: .value("grp-1")).willReturn([ + MockRepositoryFactory.makeProductVersion(id: "gv-1", productId: "grp-1", kind: .subscriptionGroup), + ]) + given(repos.versions).listSubscriptionVersions(subscriptionId: .value("sub-1")).willReturn([ + MockRepositoryFactory.makeProductVersion(id: "sv-1", productId: "sub-1", kind: .subscription), + ]) + + let plan = try await repos.planner.plan(for: version) + + #expect(plan.items == [ + PlannedReviewItem(kind: .appStoreVersion, versionId: "v-1", productId: "app-1", name: "1.0"), + PlannedReviewItem(kind: .subscriptionGroupVersion, versionId: "gv-1", productId: "grp-1", name: "Pro"), + PlannedReviewItem(kind: .subscriptionVersion, versionId: "sv-1", productId: "sub-1", name: "Monthly"), + ]) + } + + @Test func `a group with no ready subscription is left out`() async throws { + let repos = Repos() + given(repos.iaps).listInAppPurchases(appId: .any, limit: .any).willReturn(PaginatedResponse(data: [])) + given(repos.groups).listSubscriptionGroups(appId: .any, limit: .any).willReturn(PaginatedResponse(data: [ + MockRepositoryFactory.makeSubscriptionGroup(id: "grp-1"), + ])) + given(repos.subscriptions).listSubscriptions(groupId: .any, limit: .any).willReturn(PaginatedResponse(data: [ + MockRepositoryFactory.makeSubscription(id: "sub-1", state: .missingMetadata), + ])) + + let plan = try await repos.planner.plan(for: version) + + #expect(plan.items.map(\.kind) == [.appStoreVersion]) + } + + @Test func `a product whose version is already in review is left out`() async throws { + let repos = Repos() + given(repos.iaps).listInAppPurchases(appId: .any, limit: .any).willReturn(PaginatedResponse(data: [ + MockRepositoryFactory.makeInAppPurchase(id: "iap-1", state: .readyToSubmit), + ])) + given(repos.groups).listSubscriptionGroups(appId: .any, limit: .any).willReturn(PaginatedResponse(data: [])) + given(repos.versions).listInAppPurchaseVersions(iapId: .any).willReturn([ + MockRepositoryFactory.makeProductVersion(kind: .inAppPurchase, state: .waitingForReview), + ]) + + let plan = try await repos.planner.plan(for: version) + + #expect(plan.items.map(\.kind) == [.appStoreVersion]) + } + + // MARK: - Submitting + + @Test func `submitting a plan adds every item to the app's draft and sends it`() async throws { + let plan = SubmissionPlan(appId: "app-1", platform: .iOS, items: [ + PlannedReviewItem(kind: .appStoreVersion, versionId: "v-1", productId: "app-1", name: "1.0"), + PlannedReviewItem(kind: .inAppPurchaseVersion, versionId: "iv-1", productId: "iap-1", name: "Lifetime"), + ]) + let repo = MockSubmissionRepository() + given(repo).createSubmission(appId: .any, platform: .any).willReturn( + MockRepositoryFactory.makeReviewSubmission(id: "sub-1", appId: "app-1", state: .readyForReview) + ) + given(repo).addItem(submissionId: .any, target: .any).willReturn(MockRepositoryFactory.makeReviewSubmissionItem()) + given(repo).submit(submissionId: .value("sub-1")).willReturn( + MockRepositoryFactory.makeReviewSubmission(id: "sub-1", appId: "app-1", state: .waitingForReview) + ) + + let submission = try await plan.submit(repo: repo) + + #expect(submission.state == .waitingForReview) + verify(repo).createSubmission(appId: .value("app-1"), platform: .value(.iOS)).called(1) + verify(repo).addItem(submissionId: .value("sub-1"), target: .value(.appStoreVersion("v-1"))).called(1) + verify(repo).addItem(submissionId: .value("sub-1"), target: .value(.inAppPurchaseVersion("iv-1"))).called(1) + } + + @Test func `if Apple refuses the app version no products are added and nothing is submitted`() async throws { + struct Refused: Error {} + let plan = SubmissionPlan(appId: "app-1", platform: .iOS, items: [ + PlannedReviewItem(kind: .appStoreVersion, versionId: "v-1", productId: "app-1", name: "1.0"), + PlannedReviewItem(kind: .inAppPurchaseVersion, versionId: "iv-1", productId: "iap-1", name: "Lifetime"), + ]) + let repo = MockSubmissionRepository() + given(repo).createSubmission(appId: .any, platform: .any).willReturn( + MockRepositoryFactory.makeReviewSubmission(id: "sub-1", state: .readyForReview) + ) + given(repo).addItem(submissionId: .any, target: .value(.appStoreVersion("v-1"))).willThrow(Refused()) + + await #expect(throws: Refused.self) { try await plan.submit(repo: repo) } + verify(repo).addItem(submissionId: .any, target: .value(.inAppPurchaseVersion("iv-1"))).called(0) + verify(repo).submit(submissionId: .any).called(0) + } +} diff --git a/Tests/DomainTests/TestHelpers/MockRepositoryFactory.swift b/Tests/DomainTests/TestHelpers/MockRepositoryFactory.swift index 7e991ae9..8eeee2bb 100644 --- a/Tests/DomainTests/TestHelpers/MockRepositoryFactory.swift +++ b/Tests/DomainTests/TestHelpers/MockRepositoryFactory.swift @@ -191,6 +191,16 @@ struct MockRepositoryFactory { ReviewSubmission(id: id, appId: appId, platform: platform, state: state, submittedDate: submittedDate) } + static func makeProductVersion( + id: String = "pv-1", + productId: String = "product-1", + kind: ProductVersionKind = .inAppPurchase, + version: Int? = 1, + state: ProductVersionState = .prepareForSubmission + ) -> ProductVersion { + ProductVersion(id: id, productId: productId, kind: kind, version: version, state: state) + } + static func makeReviewSubmissionItem( id: String = "item-1", submissionId: String = "sub-1", diff --git a/Tests/InfrastructureTests/Apps/Performance/SDKPerfMetricsRepositoryTests.swift b/Tests/InfrastructureTests/Apps/Performance/SDKPerfMetricsRepositoryTests.swift index a0bfac06..bb5c3db4 100644 --- a/Tests/InfrastructureTests/Apps/Performance/SDKPerfMetricsRepositoryTests.swift +++ b/Tests/InfrastructureTests/Apps/Performance/SDKPerfMetricsRepositoryTests.swift @@ -226,4 +226,36 @@ struct SDKPerfMetricsRepositoryTests { #expect(result.isEmpty) } + + @Test func `storage metrics from Apple show as the storage category`() async throws { + let stub = StubAPIClient() + stub.willReturn(makeXcodeMetrics(categoryIdentifier: .storage, metricIdentifier: "storageUsed")) + + let repo = SDKPerfMetricsRepository(client: stub) + let result = try await repo.listAppMetrics(appId: "app-1", metricType: nil) + + #expect(result.map(\.category) == [.storage]) + } + + @Test func `filtering app metrics by storage asks Apple for storage metrics only`() async throws { + let stub = StubAPIClient() + stub.willReturn(makeXcodeMetrics(categoryIdentifier: .storage, metricIdentifier: "storageUsed")) + + let repo = SDKPerfMetricsRepository(client: stub) + _ = try await repo.listAppMetrics(appId: "app-1", metricType: .storage) + + let query = Dictionary(uniqueKeysWithValues: (stub.lastQuery ?? []).map { ($0.0, $0.1 ?? "") }) + #expect(query["filter[metricType]"] == "STORAGE") + } + + @Test func `filtering build metrics by storage asks Apple for storage metrics only`() async throws { + let stub = StubAPIClient() + stub.willReturn(makeXcodeMetrics(categoryIdentifier: .storage, metricIdentifier: "storageUsed")) + + let repo = SDKPerfMetricsRepository(client: stub) + _ = try await repo.listBuildMetrics(buildId: "build-1", metricType: .storage) + + let query = Dictionary(uniqueKeysWithValues: (stub.lastQuery ?? []).map { ($0.0, $0.1 ?? "") }) + #expect(query["filter[metricType]"] == "STORAGE") + } } diff --git a/Tests/InfrastructureTests/Apps/ProductVersions/SDKProductVersionRepositoryTests.swift b/Tests/InfrastructureTests/Apps/ProductVersions/SDKProductVersionRepositoryTests.swift new file mode 100644 index 00000000..62238c7f --- /dev/null +++ b/Tests/InfrastructureTests/Apps/ProductVersions/SDKProductVersionRepositoryTests.swift @@ -0,0 +1,84 @@ +@preconcurrency import AppStoreConnect_Swift_SDK +import Testing +@testable import Infrastructure +@testable import Domain + +@Suite +struct SDKProductVersionRepositoryTests { + + @Test func `in-app purchase versions carry their IAP id, version number and state`() async throws { + let stub = StubAPIClient() + stub.willReturn(InAppPurchaseVersionsResponse( + data: [ + InAppPurchaseVersion(type: .inAppPurchaseVersions, id: "iv-1", + attributes: .init(version: 1, state: .prepareForSubmission)), + ], + links: .init(this: "") + )) + + let repo = SDKProductVersionRepository(client: stub) + let versions = try await repo.listInAppPurchaseVersions(iapId: "iap-7") + + #expect(versions == [ + ProductVersion(id: "iv-1", productId: "iap-7", kind: .inAppPurchase, version: 1, state: .prepareForSubmission), + ]) + #expect(stub.lastPath == "/v2/inAppPurchases/iap-7/versions") + } + + @Test func `subscription versions carry their subscription id, version number and state`() async throws { + let stub = StubAPIClient() + stub.willReturn(SubscriptionVersionsResponse( + data: [ + SubscriptionVersion(type: .subscriptionVersions, id: "sv-2", + attributes: .init(version: 2, state: .approved)), + ], + links: .init(this: "") + )) + + let repo = SDKProductVersionRepository(client: stub) + let versions = try await repo.listSubscriptionVersions(subscriptionId: "sub-7") + + #expect(versions == [ + ProductVersion(id: "sv-2", productId: "sub-7", kind: .subscription, version: 2, state: .approved), + ]) + #expect(stub.lastPath == "/v1/subscriptions/sub-7/versions") + } + + @Test func `subscription group versions carry their group id, version number and state`() async throws { + let stub = StubAPIClient() + stub.willReturn(SubscriptionGroupVersionsResponse( + data: [ + SubscriptionGroupVersion(type: .subscriptionGroupVersions, id: "gv-1", + attributes: .init(version: 1, state: .waitingForReview)), + ], + links: .init(this: "") + )) + + let repo = SDKProductVersionRepository(client: stub) + let versions = try await repo.listSubscriptionGroupVersions(groupId: "grp-7") + + #expect(versions == [ + ProductVersion(id: "gv-1", productId: "grp-7", kind: .subscriptionGroup, version: 1, state: .waitingForReview), + ]) + #expect(stub.lastPath == "/v1/subscriptionGroups/grp-7/versions") + } + + @Test func `version lists include every version beyond the first page`() async throws { + func page(_ range: Range, nextCursor: String?) -> SubscriptionVersionsResponse { + SubscriptionVersionsResponse( + data: range.map { SubscriptionVersion(type: .subscriptionVersions, id: "sv-\($0)", + attributes: .init(version: $0, state: .replacedWithNewVersion)) }, + links: .init(this: ""), + meta: .init(paging: .init(total: 75, limit: 50, nextCursor: nextCursor)) + ) + } + let stub = StubAPIClient() + stub.willReturnPages([page(0..<50, nextCursor: "page-2"), page(50..<75, nextCursor: nil)]) + + let repo = SDKProductVersionRepository(client: stub) + let versions = try await repo.listSubscriptionVersions(subscriptionId: "sub-7") + + #expect(versions.count == 75) + #expect(versions.last?.id == "sv-74") + } +} diff --git a/Tests/InfrastructureTests/Submissions/SDKSubmissionRepositoryTests.swift b/Tests/InfrastructureTests/Submissions/SDKSubmissionRepositoryTests.swift index b824f11a..23d9d558 100644 --- a/Tests/InfrastructureTests/Submissions/SDKSubmissionRepositoryTests.swift +++ b/Tests/InfrastructureTests/Submissions/SDKSubmissionRepositoryTests.swift @@ -289,4 +289,208 @@ struct SDKSubmissionRepositoryTests { #expect(results.first?.submittedDate == date) #expect(results.first?.state == .inReview) } + + @Test func `listing items asks Apple which resource each item points at`() async throws { + // Apple only returns an item's relationship linkage when asked via `include`. + let stub = StubAPIClient() + stub.willReturn(ReviewSubmissionItemsResponse(data: [], links: .init(this: ""))) + + let repo = OpenAPISubmissionRepository(client: stub) + _ = try await repo.listSubmissionItems(submissionId: "sub-1") + + let query = Dictionary(uniqueKeysWithValues: (stub.lastQuery ?? []).map { ($0.0, $0.1 ?? "") }) + let included = Set((query["include"] ?? "").split(separator: ",").map(String.init)) + #expect(included.isSuperset(of: [ + "appStoreVersion", "appCustomProductPageVersion", + "appStoreVersionExperimentV2", "appEvent", "backgroundAssetVersion", + "gameCenterAchievementVersion", "gameCenterActivityVersion", "gameCenterChallengeVersion", + "gameCenterLeaderboardSetVersion", "gameCenterLeaderboardVersion", + "inAppPurchaseVersion", "subscriptionVersion", "subscriptionGroupVersion", + ])) + // Apple rejects v1 and v2 experiments in the same request (400 PARAMETER_ERROR.INVALID). + #expect(!included.contains("appStoreVersionExperiment")) + } + + @Test func `product version items show which product version they point at`() async throws { + let stub = StubAPIClient() + stub.willReturn(ReviewSubmissionItemsResponse( + data: [ + ReviewSubmissionItem( + type: .reviewSubmissionItems, id: "item-iap", attributes: .init(state: .readyForReview), + relationships: .init(inAppPurchaseVersion: .init(data: .init(type: .inAppPurchaseVersions, id: "iapv-1"))) + ), + ReviewSubmissionItem( + type: .reviewSubmissionItems, id: "item-sub", attributes: .init(state: .readyForReview), + relationships: .init(subscriptionVersion: .init(data: .init(type: .subscriptionVersions, id: "subv-1"))) + ), + ReviewSubmissionItem( + type: .reviewSubmissionItems, id: "item-group", attributes: .init(state: .readyForReview), + relationships: .init(subscriptionGroupVersion: .init(data: .init(type: .subscriptionGroupVersions, id: "grpv-1"))) + ), + ], + links: .init(this: "") + )) + + let repo = OpenAPISubmissionRepository(client: stub) + let items = try await repo.listSubmissionItems(submissionId: "sub-1") + + #expect(items.map(\.linkedResourceType) == [.inAppPurchaseVersion, .subscriptionVersion, .subscriptionGroupVersion]) + #expect(items.map(\.linkedResourceId) == ["iapv-1", "subv-1", "grpv-1"]) + } + + // MARK: - Building a submission + + @Test func `creating a submission reuses the app's open draft for that platform`() async throws { + let stub = StubAPIClient() + stub.willReturn(ReviewSubmissionsResponse( + data: [ReviewSubmission(type: .reviewSubmissions, id: "draft-1", + attributes: .init(platform: .ios, state: .readyForReview))], + links: .init(this: "") + )) + + let repo = OpenAPISubmissionRepository(client: stub) + let submission = try await repo.createSubmission(appId: "app-1", platform: .iOS) + + #expect(submission == Domain.ReviewSubmission(id: "draft-1", appId: "app-1", platform: .iOS, state: .readyForReview)) + #expect(!stub.requests.contains { $0.method == "POST" }) + } + + @Test func `creating a submission creates a draft when the app has none open`() async throws { + let stub = StubAPIClient() + stub.willReturn(ReviewSubmissionsResponse(data: [], links: .init(this: ""))) + stub.willReturn(ReviewSubmissionResponse( + data: ReviewSubmission(type: .reviewSubmissions, id: "new-1", + attributes: .init(platform: .ios, state: .readyForReview)), + links: .init(this: "") + )) + + let repo = OpenAPISubmissionRepository(client: stub) + let submission = try await repo.createSubmission(appId: "app-1", platform: .iOS) + + #expect(submission == Domain.ReviewSubmission(id: "new-1", appId: "app-1", platform: .iOS, state: .readyForReview)) + let post = stub.requests.first { $0.method == "POST" } + #expect(post?.path == "/v1/reviewSubmissions") + #expect(post?.body == #"{"data":{"attributes":{"platform":"IOS"},"relationships":{"app":{"data":{"id":"app-1","type":"apps"}}},"type":"reviewSubmissions"}}"#) + } + + @Test func `adding each kind of version sends it as the item's relationship`() async throws { + let cases: [(ReviewItemTarget, String, Domain.ReviewSubmissionItemLinkedResource)] = [ + (.appStoreVersion("v-1"), #""appStoreVersion":{"data":{"id":"v-1","type":"appStoreVersions"}}"#, .appStoreVersion), + (.inAppPurchaseVersion("iv-1"), #""inAppPurchaseVersion":{"data":{"id":"iv-1","type":"inAppPurchaseVersions"}}"#, .inAppPurchaseVersion), + (.subscriptionVersion("sv-1"), #""subscriptionVersion":{"data":{"id":"sv-1","type":"subscriptionVersions"}}"#, .subscriptionVersion), + (.subscriptionGroupVersion("gv-1"), #""subscriptionGroupVersion":{"data":{"id":"gv-1","type":"subscriptionGroupVersions"}}"#, .subscriptionGroupVersion), + ] + for (target, relationship, linkedType) in cases { + let stub = StubAPIClient() + stub.willReturn(ReviewSubmissionItemResponse( + data: ReviewSubmissionItem(type: .reviewSubmissionItems, id: "item-1", attributes: .init(state: .readyForReview)), + links: .init(this: "") + )) + + let repo = OpenAPISubmissionRepository(client: stub) + let item = try await repo.addItem(submissionId: "sub-1", target: target) + + let post = stub.requests.first { $0.method == "POST" } + #expect(post?.path == "/v1/reviewSubmissionItems") + #expect(post?.body?.contains(relationship) == true) + #expect(post?.body?.contains(#""reviewSubmission":{"data":{"id":"sub-1","type":"reviewSubmissions"}}"#) == true) + #expect(item == Domain.ReviewSubmissionItem( + id: "item-1", submissionId: "sub-1", state: .readyForReview, + linkedResourceId: target.id, linkedResourceType: linkedType + )) + } + } + + @Test func `removing an item deletes it from its submission`() async throws { + let stub = StubAPIClient() + + let repo = OpenAPISubmissionRepository(client: stub) + try await repo.removeItem(itemId: "item-1") + + #expect(stub.requests.map { "\($0.method) \($0.path)" } == ["DELETE /v1/reviewSubmissionItems/item-1"]) + } + + @Test func `submitting sends the draft to review and returns its new state`() async throws { + let stub = StubAPIClient() + stub.willReturn(ReviewSubmissionResponse( + data: ReviewSubmission(type: .reviewSubmissions, id: "sub-1", + attributes: .init(platform: .ios, state: .waitingForReview), + relationships: .init(app: .init(data: .init(type: .apps, id: "app-1")))), + links: .init(this: "") + )) + + let repo = OpenAPISubmissionRepository(client: stub) + let submission = try await repo.submit(submissionId: "sub-1") + + let patch = stub.requests.first { $0.method == "PATCH" } + #expect(patch?.path == "/v1/reviewSubmissions/sub-1") + #expect(patch?.body == #"{"data":{"attributes":{"submitted":true},"id":"sub-1","type":"reviewSubmissions"}}"#) + #expect(submission == Domain.ReviewSubmission(id: "sub-1", appId: "app-1", platform: .iOS, state: .waitingForReview)) + } + + // MARK: - Apple's reasons for refusing + + /// The 409 Apple returns when an app version isn't ready: the headline says to look at + /// associated errors, and the real reasons sit in `meta.associatedErrors`. + private func appVersionNotReviewable() -> APIProvider.Error { + func error(_ code: String, _ detail: String) -> ResponseError { + ResponseError(status: "409", code: code, title: "The request entity is not valid.", detail: detail) + } + return .requestFailure(409, ErrorResponse(errors: [ + ResponseError( + status: "409", code: "STATE_ERROR.ENTITY_STATE_INVALID", title: "The request cannot be fulfilled.", + detail: "This resource cannot be reviewed, please check associated errors to see why.", + meta: .init(associatedErrors: [ + "/v2/appPrices/": [error("STATE_ERROR.APP_PRICING_REQUIRED", + "App is not eligible for submission until pricing has been set.")], + "/v1/appScreenshots/": [error("STATE_ERROR.SCREENSHOT_REQUIRED.APP_IPAD_PRO_3GEN_129", + "A screenshot for one of the following types is required but was not provided: APP_IPAD_PRO_3GEN_129")], + ]) + ), + ]), nil) + } + + @Test func `when Apple refuses an item the error lists each reason it gave`() async throws { + let stub = StubAPIClient() + stub.errorToThrow = appVersionNotReviewable() + + let repo = OpenAPISubmissionRepository(client: stub) + await #expect { + _ = try await repo.addItem(submissionId: "sub-1", target: .appStoreVersion("v-1")) + } throws: { error in + String(describing: error) == """ + Apple refused the review submission: This resource cannot be reviewed, please check associated errors to see why. + - A screenshot for one of the following types is required but was not provided: APP_IPAD_PRO_3GEN_129 + - App is not eligible for submission until pricing has been set. + """ + } + } + + @Test func `when Apple refuses to submit the error lists each reason it gave`() async throws { + let stub = StubAPIClient() + stub.errorToThrow = appVersionNotReviewable() + + let repo = OpenAPISubmissionRepository(client: stub) + await #expect { + _ = try await repo.submit(submissionId: "sub-1") + } throws: { error in + String(describing: error).hasPrefix("Apple refused the review submission: ") + && String(describing: error).contains(" - App is not eligible for submission until pricing has been set.") + } + } + + @Test func `an Apple server error is reported as it is, not as a refusal`() async throws { + let stub = StubAPIClient() + stub.errorToThrow = APIProvider.Error.requestFailure(500, ErrorResponse(errors: [ + ResponseError(status: "500", code: "UNEXPECTED_ERROR", title: "An unexpected error occurred.", + detail: "An unexpected error occurred on the server side."), + ]), nil) + + let repo = OpenAPISubmissionRepository(client: stub) + await #expect { + _ = try await repo.addItem(submissionId: "sub-1", target: .appStoreVersion("v-1")) + } throws: { error in + !(error is ReviewSubmissionError) && String(describing: error).contains("UNEXPECTED_ERROR") + } + } } diff --git a/Tests/InfrastructureTests/TestHelpers/StubAPIClient.swift b/Tests/InfrastructureTests/TestHelpers/StubAPIClient.swift index 90145873..55242716 100644 --- a/Tests/InfrastructureTests/TestHelpers/StubAPIClient.swift +++ b/Tests/InfrastructureTests/TestHelpers/StubAPIClient.swift @@ -31,6 +31,9 @@ final class StubAPIClient: APIClient, @unchecked Sendable { let body: String? } + /// When set, every request throws this error (simulates an ASC failure response). + var errorToThrow: (any Error)? + func willReturn(_ response: T) { stubsByType[String(describing: T.self)] = response lastStub = response @@ -47,6 +50,7 @@ final class StubAPIClient: APIClient, @unchecked Sendable { requests.append(RecordedRequest( method: endpoint.method, path: endpoint.path, query: endpoint.query, body: Self.encodedBody(of: endpoint) )) + if let errorToThrow { throw errorToThrow } let key = String(describing: T.self) if var pages = pagesByType[key], !pages.isEmpty, let page = pages.removeFirst() as? T { pagesByType[key] = pages @@ -60,6 +64,9 @@ final class StubAPIClient: APIClient, @unchecked Sendable { func request(_ endpoint: Request) async throws { voidRequestCalled = true + requests.append(RecordedRequest( + method: endpoint.method, path: endpoint.path, query: endpoint.query, body: Self.encodedBody(of: endpoint) + )) } /// `Request.body` is internal to the SDK, so read it reflectively and JSON-encode it. diff --git a/docs/features/performance.md b/docs/features/performance.md index ac990e41..b55cbb1d 100644 --- a/docs/features/performance.md +++ b/docs/features/performance.md @@ -12,7 +12,7 @@ List performance metrics for an app or a specific build. |------|----------|-------------| | `--app-id` | One of app-id/build-id | App ID to fetch metrics for | | `--build-id` | One of app-id/build-id | Build ID to fetch metrics for | -| `--metric-type` | No | Filter: HANG, LAUNCH, MEMORY, DISK, BATTERY, TERMINATION, ANIMATION | +| `--metric-type` | No | Filter: HANG, LAUNCH, MEMORY, DISK, BATTERY, TERMINATION, ANIMATION, STORAGE | | `--output` | No | Output format: json (default), table, markdown | | `--pretty` | No | Pretty-print JSON output | @@ -197,7 +197,7 @@ Flattened representation of one metric from the deeply nested `XcodeMetrics` API | `parentId` | `String` | App ID or Build ID (injected by infrastructure) | | `parentType` | `PerfMetricParentType` | `.app` or `.build` | | `platform` | `String?` | e.g. `"IOS"` | -| `category` | `PerformanceMetricCategory` | HANG, LAUNCH, MEMORY, DISK, BATTERY, TERMINATION, ANIMATION | +| `category` | `PerformanceMetricCategory` | HANG, LAUNCH, MEMORY, DISK, BATTERY, TERMINATION, ANIMATION, STORAGE | | `metricIdentifier` | `String` | e.g. `"launchTime"`, `"peakMemory"` | | `unit` | `String?` | e.g. `"s"`, `"MB"` | | `latestValue` | `Double?` | Most recent data point value | diff --git a/docs/features/review-submissions.md b/docs/features/review-submissions.md index 842df098..724f4bd4 100644 --- a/docs/features/review-submissions.md +++ b/docs/features/review-submissions.md @@ -1,6 +1,8 @@ # Review Submissions -Inspect App Store review submissions: state, rejected items, and per-item drill-in affordances. A *review submission* is the top-level record Apple's review queue operates on; it packages one or more `ReviewSubmissionItem`s, each pointing at a reviewable resource (typically an `AppStoreVersion`). +Inspect App Store review submissions: state, rejected items, and per-item drill-in affordances. A *review submission* is the top-level record Apple's review queue operates on; it packages one or more `ReviewSubmissionItem`s, each pointing at a reviewable resource — an `AppStoreVersion`, or a version of an in-app purchase, subscription or subscription group. + +Building and sending a submission (`review-submissions create`, `items add`, `items remove`, `submit`) and `versions submit --with-products` are documented in [submit-with-products.md](submit-with-products.md). When a submission's state is `UNRESOLVED_ISSUES`, the per-item state pinpoints *which* attached resource Apple rejected. The reviewer's free-text reasoning is **not exposed via the public ASC API** — it lives only in the App Store Connect Resolution Center web UI. The CLI surfaces the state machine, not the narrative. @@ -106,6 +108,10 @@ asc review-submissions items list --submission-id sub-1 --state REJECTED --prett | `GET` | `/api/v1/apps/{appId}/review-submissions` | `--state` → `?state=`, `--limit` → `?limit=` | List submissions for an app | | `GET` | `/api/v1/review-submissions/{id}` | — | Get a single submission | | `GET` | `/api/v1/review-submissions/{id}/items` | `--state` → `?state=` | List items in a submission (optionally filtered) | +| `POST` | `/api/v1/apps/{appId}/review-submissions` | `--platform` → body `platform` | Open (or reuse) a draft — see [submit-with-products.md](submit-with-products.md) | +| `POST` | `/api/v1/review-submissions/{id}/items` | version flag → body key | Add an item | +| `DELETE` | `/api/v1/review-submissions/items/{itemId}` | — | Remove an item | +| `POST` | `/api/v1/review-submissions/{id}/submit` | — | Submit the draft | ```bash curl http://localhost:8080/api/v1/review-submissions/sub-1 @@ -241,7 +247,7 @@ Affordances: ### `ReviewSubmissionItemLinkedResource` -Enum of resource types Apple can attach to a submission item: `APP_STORE_VERSION`, `APP_CUSTOM_PRODUCT_PAGE_VERSION`, `APP_STORE_VERSION_EXPERIMENT`, `APP_EVENT`, `BACKGROUND_ASSET_VERSION`, `GAME_CENTER_ACHIEVEMENT_VERSION`, `GAME_CENTER_ACTIVITY_VERSION`, `GAME_CENTER_CHALLENGE_VERSION`, `GAME_CENTER_LEADERBOARD_SET_VERSION`, `GAME_CENTER_LEADERBOARD_VERSION`. `APP_STORE_VERSION` is by far the common case. +Enum of resource types Apple can attach to a submission item: `APP_STORE_VERSION`, `APP_CUSTOM_PRODUCT_PAGE_VERSION`, `APP_STORE_VERSION_EXPERIMENT`, `APP_EVENT`, `BACKGROUND_ASSET_VERSION`, `GAME_CENTER_ACHIEVEMENT_VERSION`, `GAME_CENTER_ACTIVITY_VERSION`, `GAME_CENTER_CHALLENGE_VERSION`, `GAME_CENTER_LEADERBOARD_SET_VERSION`, `GAME_CENTER_LEADERBOARD_VERSION`, `IN_APP_PURCHASE_VERSION`, `SUBSCRIPTION_VERSION`, `SUBSCRIPTION_GROUP_VERSION`. `APP_STORE_VERSION` is by far the common case. ### `SubmissionRepository` @@ -252,6 +258,10 @@ public protocol SubmissionRepository: Sendable { func listSubmissions(appId: String, states: [ReviewSubmissionState]?, limit: Int?) async throws -> [ReviewSubmission] func getSubmission(id: String) async throws -> ReviewSubmission func listSubmissionItems(submissionId: String) async throws -> [ReviewSubmissionItem] + func createSubmission(appId: String, platform: AppStorePlatform) async throws -> ReviewSubmission + func addItem(submissionId: String, target: ReviewItemTarget) async throws -> ReviewSubmissionItem + func removeItem(itemId: String) async throws + func submit(submissionId: String) async throws -> ReviewSubmission } ``` @@ -308,9 +318,11 @@ Wiring: |-----------|----------|----------|-------------------| | List submissions | `GET /v1/reviewSubmissions?filter[app]=…&filter[state]=…&limit=…` | `APIEndpoint.v1.reviewSubmissions.get(parameters:)` | `listSubmissions(appId:states:limit:)` | | Get submission | `GET /v1/reviewSubmissions/{id}?include=app` | `APIEndpoint.v1.reviewSubmissions.id(id).get(parameters:)` | `getSubmission(id:)` | -| List items | `GET /v1/reviewSubmissions/{id}/items` | `APIEndpoint.v1.reviewSubmissions.id(id).items.get(parameters:)` | `listSubmissionItems(submissionId:)` | +| List items | `GET /v1/reviewSubmissions/{id}/items?include=appStoreVersion,…,inAppPurchaseVersion,subscriptionVersion,subscriptionGroupVersion` | `APIEndpoint.v1.reviewSubmissions.id(id).items.get(parameters:)` | `listSubmissionItems(submissionId:)` | | Submit version | `POST /v1/reviewSubmissions` + `POST /v1/reviewSubmissionItems` + `PATCH /v1/reviewSubmissions/{id}` | — | `submitVersion(versionId:)` | +Apple only returns an item's linked resource when the request asks for it with `include=`, so `items list` includes every reviewable kind (v2 experiments only — Apple rejects v1 and v2 experiments in one request). + `filterApp` is required by Apple's API for the top-level list, so `--app-id` is required on `asc review-submissions list`. The SDK returns the parent app only inside `relationships.app.data.id`. Domain models always carry `appId`/`submissionId`, so the infrastructure mapper injects the value from the request parameter rather than reading the relationship. diff --git a/docs/features/submit-with-products.md b/docs/features/submit-with-products.md new file mode 100644 index 00000000..2abe1403 --- /dev/null +++ b/docs/features/submit-with-products.md @@ -0,0 +1,401 @@ +# Submit Products with an App Version + +Send in-app purchases, subscriptions and subscription groups to App Review **in the same review submission as an app version** — the way Apple requires first-time products to be reviewed — using only the public App Store Connect API and your API key. + +Apple versions products the way it versions apps: every IAP, subscription and subscription group has *versions* (`/v2/inAppPurchases/{id}/versions`, `/v1/subscriptions/{id}/versions`, `/v1/subscriptionGroups/{id}/versions`). A version in `PREPARE_FOR_SUBMISSION` is sent to review by adding it as an item to a review submission, next to the app version. This replaces the iris (cookie-auth) workaround for "the first IAP must be submitted with a new app version". + +Three layers of commands, from one-shot to fine-grained: + +| Goal | Command | +|------|---------| +| Submit an app version and every ready product together | `asc versions submit --version-id --with-products` | +| See what that would send, without submitting | `asc versions submit --version-id --with-products --dry-run` | +| Build a submission item by item | `asc review-submissions create` → `items add` → `submit` | +| Find a product's submittable version | `asc iap versions list` / `asc subscriptions versions list` / `asc subscription-groups versions list` | + +Requires appstoreconnect-swift-sdk **4.4.3** or later (product versions arrived in 4.4.x). + +--- + +## CLI Usage + +### `asc versions submit --with-products` + +``` +asc versions submit --version-id [--with-products] [--dry-run] +``` + +| Flag | Required | Description | +|------|----------|-------------| +| `--version-id` | Yes | App Store version to submit | +| `--with-products` | No | Also submit every IAP and subscription that is `READY_TO_SUBMIT` and has a submittable version, plus the version of each subscription group one of them belongs to | +| `--dry-run` | No | List the items that would be submitted and submit nothing (implies `--with-products`) | + +Without either flag the command behaves as before (the app version alone). + +What goes in, in this order: +1. the app version; +2. each `READY_TO_SUBMIT` in-app purchase's first submittable version; +3. for each subscription group with at least one `READY_TO_SUBMIT` subscription: the group's submittable version, then those subscriptions' submittable versions. + +Products still `MISSING_METADATA`, or whose version is already in review or approved, are left out. The app's open draft for the platform is reused if there is one. If Apple refuses an item, the command stops **before submitting**; items added so far stay in the draft (see `asc review-submissions items list`). + +```bash +asc versions submit --version-id 5b8c81cc-b230-48ae-858e-b3c598ba5bfa --with-products --dry-run --output table +``` + +``` +Kind Version ID Product ID Name +-------------------------- ------------------------------------ ---------- ------------------- +APP_STORE_VERSION 5b8c81cc-b230-48ae-858e-b3c598ba5bfa 6792459661 1.0 +IN_APP_PURCHASE_VERSION c196c754-caa1-4969-a65b-1c76db83439e 6815067726 Unveil Pro Lifetime +SUBSCRIPTION_GROUP_VERSION 83709e0a-c2fd-4cbf-bbef-0a8cadabf29e 22406463 Unveil Pro +SUBSCRIPTION_VERSION 2da66776-9b90-46e0-9ceb-38d7e06570d8 6815067714 Unveil Pro Monthly +SUBSCRIPTION_VERSION 384e2202-dc4c-4132-9ac3-beb15973f3c2 6815067865 Unveil Pro Yearly +``` + +Dry-run JSON (one item shown): + +```json +{ + "data" : [ + { + "affordances" : { + "listVersions" : "asc iap versions list --iap-id 6815067726" + }, + "kind" : "IN_APP_PURCHASE_VERSION", + "name" : "Unveil Pro Lifetime", + "productId" : "6815067726", + "versionId" : "c196c754-caa1-4969-a65b-1c76db83439e" + } + ] +} +``` + +A real run prints the submission (`state: WAITING_FOR_REVIEW`). + +When Apple refuses, the error lists the reasons it gave in `meta.associatedErrors` rather than only its generic headline: + +``` +Error: Apple refused the review submission: This resource cannot be reviewed, please check associated errors to see why. + - A screenshot for one of the following types is required but was not provided: APP_IPAD_PRO_3GEN_129 + - App is not eligible for submission until pricing has been set. +``` + +Apple server errors (5xx) are shown as they are. + +What each common refusal needs, and whether `asc` can fix it: + +| Reason | Fix | +|--------|-----| +| `contentRightsDeclaration` required | `asc apps update --app-id --content-rights-declaration DOES_NOT_USE_THIRD_PARTY_CONTENT` (or `USES_THIRD_PARTY_CONTENT`) | +| Pricing not set | App pricing is in the public API (`/v1/appPriceSchedules`) but `asc` has no command yet — set it in App Store Connect (Pricing and Availability) | +| App Privacy data usages not published | **No public API.** App Privacy answers exist only behind the App Store Connect web session (the `/v1/appDataUsages` path in the error isn't reachable with an API key) — publish them in App Store Connect (App Privacy) | +| Required screenshot (e.g. `APP_IPAD_PRO_3GEN_129`) missing | `asc screenshot-sets` / `asc screenshots` for that display type | + +Apple sometimes answers `500 UNEXPECTED_ERROR` instead of this refusal when adding an app version to an older, empty draft; a fresh draft returns the real reasons. + +### `asc iap versions list` · `asc subscriptions versions list` · `asc subscription-groups versions list` + +``` +asc iap versions list --iap-id +asc subscriptions versions list --subscription-id +asc subscription-groups versions list --group-id +``` + +```bash +asc subscription-groups versions list --group-id 22406463 --pretty +``` + +```json +{ + "data" : [ + { + "affordances" : { + "addToSubmission" : "asc review-submissions items add --submission-id --subscription-group-version-id 83709e0a-c2fd-4cbf-bbef-0a8cadabf29e", + "listVersions" : "asc subscription-groups versions list --group-id 22406463" + }, + "id" : "83709e0a-c2fd-4cbf-bbef-0a8cadabf29e", + "kind" : "SUBSCRIPTION_GROUP", + "productId" : "22406463", + "state" : "PREPARE_FOR_SUBMISSION", + "version" : 1 + } + ] +} +``` + +``` +ID Product ID Kind Version State +------------------------------------ ---------- --------------- ------- ---------------------- +c196c754-caa1-4969-a65b-1c76db83439e 6815067726 IN_APP_PURCHASE 1 PREPARE_FOR_SUBMISSION +``` + +`addToSubmission` appears only while the version is submittable (`PREPARE_FOR_SUBMISSION`, `REJECTED`, `DEVELOPER_REJECTED`). IAPs, subscriptions and subscription groups all expose a `listVersions` affordance. + +### `asc review-submissions create` + +``` +asc review-submissions create --app-id [--platform ios|macos|tvos|visionos] +``` + +Opens a draft review submission for the app and platform (default `ios`), or returns the open one — Apple allows a single open submission (`READY_FOR_REVIEW` or `UNRESOLVED_ISSUES`) per app and platform. A draft exposes `addItem` and `submit` affordances. + +### `asc review-submissions items add` + +``` +asc review-submissions items add --submission-id \ + (--version-id | --iap-version-id | --subscription-version-id | --subscription-group-version-id ) +``` + +Exactly one version flag. Prints the new item with a `remove` affordance. + +### `asc review-submissions items remove` + +``` +asc review-submissions items remove --item-id +``` + +Removes an item that hasn't been submitted (item state `READY_FOR_REVIEW`). Prints nothing. + +### `asc review-submissions submit` + +``` +asc review-submissions submit --submission-id +``` + +Sends the draft, with all its items, to App Review. + +--- + +## REST Endpoints + +| Method | Path | CLI equivalent | Query / body | +|--------|------|----------------|--------------| +| `POST` | `/api/v1/versions/{versionId}/submit` | `versions submit` | `--with-products` → `?with-products=true`, `--dry-run` → `?dry-run=true` | +| `GET` | `/api/v1/iap/{iapId}/versions` | `iap versions list` | — | +| `GET` | `/api/v1/subscriptions/{subscriptionId}/versions` | `subscriptions versions list` | — | +| `GET` | `/api/v1/subscription-groups/{groupId}/versions` | `subscription-groups versions list` | — | +| `POST` | `/api/v1/apps/{appId}/review-submissions` | `review-submissions create` | body `{"platform": "ios"}` | +| `POST` | `/api/v1/review-submissions/{id}/items` | `review-submissions items add` | body with exactly one of `version-id`, `iap-version-id`, `subscription-version-id`, `subscription-group-version-id` | +| `DELETE` | `/api/v1/review-submissions/items/{itemId}` | `review-submissions items remove` | — returns `{"removed":true}` | +| `POST` | `/api/v1/review-submissions/{id}/submit` | `review-submissions submit` | — | + +Body keys and query names are the CLI flag names. + +```bash +curl -X POST 'http://localhost:8420/api/v1/versions/5b8c81cc-b230-48ae-858e-b3c598ba5bfa/submit?with-products=true&dry-run=true' +curl -X POST http://localhost:8420/api/v1/review-submissions/487610a2-b816-45fc-bdb0-5dfe601cb8b8/items \ + -d '{"iap-version-id": "c196c754-caa1-4969-a65b-1c76db83439e"}' +``` + +Responses carry `_links` from the same affordances (`addToSubmission` → `POST /api/v1/review-submissions//items`, `remove` → `DELETE /api/v1/review-submissions/items/{id}`, `submit` → `POST /api/v1/review-submissions/{id}/submit`). + +--- + +## Typical Workflow + +```bash +VERSION=5b8c81cc-b230-48ae-858e-b3c598ba5bfa + +# 1. Products must be READY_TO_SUBMIT (metadata, pricing, review screenshot complete) +asc iap list --app-id 6792459661 --output table +asc subscriptions list --group-id 22406463 --output table + +# 2. See exactly what will go to review +asc versions submit --version-id $VERSION --with-products --dry-run --output table + +# 3. Submit the app version and the products together +asc versions submit --version-id $VERSION --with-products + +# Step by step instead: +SUB=$(asc review-submissions create --app-id 6792459661 | jq -r '.data[0].id') +asc review-submissions items add --submission-id $SUB --version-id $VERSION +asc review-submissions items add --submission-id $SUB --iap-version-id c196c754-caa1-4969-a65b-1c76db83439e +asc review-submissions items list --submission-id $SUB --output table +asc review-submissions submit --submission-id $SUB +``` + +--- + +## Architecture + +``` +ASCCommand Infrastructure Domain +────────── ────────────── ────── +versions submit --with-products [--dry-run] ─┐ +VersionSubmissionController ├─▶ (SubmissionPlanner, in Domain) ───▶ SubmissionPlanner.plan(for:) + │ SubmissionPlan.submit(repo:) +iap / subscriptions / subscription-groups │ + versions list, ProductVersionsController ──┼─▶ SDKProductVersionRepository ──────▶ ProductVersionRepository + │ GET /v2/inAppPurchases/{id}/versions ProductVersion + │ GET /v1/subscriptions/{id}/versions + │ GET /v1/subscriptionGroups/{id}/versions +review-submissions create / items add | │ + remove / submit, ReviewSubmissionsController┴─▶ OpenAPISubmissionRepository ──────▶ SubmissionRepository + POST /v1/reviewSubmissions ReviewItemTarget + POST|DELETE /v1/reviewSubmissionItems ReviewSubmissionError + PATCH /v1/reviewSubmissions/{id} +``` + +Dependencies flow `ASCCommand → Infrastructure → Domain`. The planning rules (which products are ready, group before its subscriptions, app version first) live in the Domain `SubmissionPlanner`, which composes the IAP, subscription group, subscription and product version repositories. `SubmissionPlan.submit(repo:)` owns the order of writes and stops before submitting on the first refusal. + +--- + +## Domain Models + +### `ProductVersion` + +| Field | Type | Notes | +|-------|------|-------| +| `id` | `String` | Product version id | +| `productId` | `String` | Parent IAP, subscription or group id — injected from the request | +| `kind` | `ProductVersionKind` | `IN_APP_PURCHASE`, `SUBSCRIPTION`, `SUBSCRIPTION_GROUP` | +| `version` | `Int?` | Version number | +| `state` | `ProductVersionState` | See below | + +Computed: `isSubmittable`, `isInReview`, `isApproved`. Affordances: `listVersions` (always), `addToSubmission` (when submittable). + +### `ProductVersionState` + +`PREPARE_FOR_SUBMISSION`, `READY_FOR_REVIEW`, `WAITING_FOR_REVIEW`, `IN_REVIEW`, `ACCEPTED`, `APPROVED`, `REPLACED_WITH_NEW_VERSION`, `REJECTED`, `DEVELOPER_REJECTED`. + +| Boolean | True for | +|---------|----------| +| `isSubmittable` | `PREPARE_FOR_SUBMISSION`, `REJECTED`, `DEVELOPER_REJECTED` | +| `isInReview` | `WAITING_FOR_REVIEW`, `IN_REVIEW` | +| `isApproved` | `ACCEPTED`, `APPROVED` | + +### `ProductVersionRepository` + +```swift +@Mockable +public protocol ProductVersionRepository: Sendable { + func listInAppPurchaseVersions(iapId: String) async throws -> [ProductVersion] + func listSubscriptionVersions(subscriptionId: String) async throws -> [ProductVersion] + func listSubscriptionGroupVersions(groupId: String) async throws -> [ProductVersion] +} +``` + +### `ReviewItemTarget` + +What an item sends to review: `.appStoreVersion(id)`, `.inAppPurchaseVersion(id)`, `.subscriptionVersion(id)`, `.subscriptionGroupVersion(id)`. `init?(versionId:iapVersionId:subscriptionVersionId:subscriptionGroupVersionId:)` accepts exactly one id (shared by the CLI flags and the REST body). + +### `SubmissionRepository` (additions) + +```swift +func createSubmission(appId: String, platform: AppStorePlatform) async throws -> ReviewSubmission // reuses the open draft +func addItem(submissionId: String, target: ReviewItemTarget) async throws -> ReviewSubmissionItem +func removeItem(itemId: String) async throws +func submit(submissionId: String) async throws -> ReviewSubmission +``` + +`ReviewSubmission.isEditable` (`READY_FOR_REVIEW`, `UNRESOLVED_ISSUES`) gates its `addItem` and `submit` affordances; an item in `READY_FOR_REVIEW` offers `remove`. + +### `SubmissionPlanner`, `SubmissionPlan`, `PlannedReviewItem` + +`SubmissionPlanner(iapRepo:groupRepo:subscriptionRepo:productVersionRepo:).plan(for: AppStoreVersion) -> SubmissionPlan` applies the rules above. `SubmissionPlan` has `appId`, `platform`, `items: [PlannedReviewItem]` and `submit(repo:)`. `PlannedReviewItem` has `kind` (`ReviewSubmissionItemLinkedResource`), `versionId`, `productId`, `name`; affordances `getVersion` (app version) or `listVersions` (product). + +### `ReviewSubmissionError` + +`.refused(message:reasons:)` — thrown by `addItem` and `submit` when Apple answers 4xx; `reasons` are the details from `meta.associatedErrors`, sorted by path. + +--- + +## File Map + +``` +Sources/ +├── Domain/ +│ ├── Apps/ProductVersions/ +│ │ ├── ProductVersion.swift # model, kind, state, affordances +│ │ ├── ProductVersion+RESTRoutes.swift # iap/subscriptions/subscription-groups versions routes +│ │ └── ProductVersionRepository.swift +│ └── Submissions/ +│ ├── ReviewItemTarget.swift +│ ├── ReviewSubmissionError.swift +│ └── SubmissionPlan.swift # SubmissionPlanner, SubmissionPlan, PlannedReviewItem +├── Infrastructure/ +│ ├── Apps/ProductVersions/SDKProductVersionRepository.swift +│ └── Submissions/OpenAPISubmissionRepository.swift # createSubmission, addItem, removeItem, submit +└── ASCCommand/Commands/ + ├── IAP/IAPVersionsCommand.swift, IAPVersionsList.swift + ├── Subscriptions/SubscriptionVersionsCommand.swift, SubscriptionVersionsList.swift + ├── SubscriptionGroups/SubscriptionGroupVersionsCommand.swift, SubscriptionGroupVersionsList.swift + ├── ReviewSubmissions/ReviewSubmissionsCreate.swift, ReviewSubmissionsSubmit.swift, + │ ReviewSubmissionItemsAdd.swift, ReviewSubmissionItemsRemove.swift + └── Versions/VersionsSubmit.swift # --with-products, --dry-run + +Tests/ +├── DomainTests/Apps/ProductVersions/ProductVersionTests.swift +├── DomainTests/Submissions/ReviewItemTargetTests.swift, SubmissionPlannerTests.swift +├── InfrastructureTests/Apps/ProductVersions/SDKProductVersionRepositoryTests.swift +├── InfrastructureTests/Submissions/SDKSubmissionRepositoryTests.swift +└── ASCCommandTests/Commands/ + ├── IAP/IAPVersionsListTests.swift, Subscriptions/SubscriptionVersionsListTests.swift, + │ SubscriptionGroups/SubscriptionGroupVersionsListTests.swift + ├── ReviewSubmissions/ReviewSubmissionsBuildTests.swift + ├── Versions/VersionsSubmitTests.swift + └── Web/RESTRoutesTests.swift +``` + +| Wiring file | Purpose | +|-------------|---------| +| `Sources/ASCCommand/Commands/Web/Controllers/ProductVersionsController.swift` | REST: the three `…/versions` routes | +| `Sources/ASCCommand/Commands/Web/Controllers/ReviewSubmissionsController.swift` | REST: create, items add/remove, submit | +| `Sources/ASCCommand/Commands/Web/Controllers/VersionSubmissionController.swift` | REST: `POST /versions/:id/submit` | +| `Sources/ASCCommand/Commands/Web/RESTRoutes.swift` | Constructs the three controllers | +| `Sources/ASCCommand/ClientProvider.swift` / `Sources/Infrastructure/Client/ClientFactory.swift` | `makeProductVersionRepository` | +| `Sources/Domain/Shared/RESTPathResolver.swift` | Touches `_productVersionRoutes`; `add` resolves like `create` | +| `Sources/Domain/Shared/Affordance.swift` | `remove` maps to `DELETE` | + +--- + +## API Reference + +| Operation | Endpoint | SDK call | Repository method | +|-----------|----------|----------|-------------------| +| IAP versions | `GET /v2/inAppPurchases/{id}/versions?limit=200` | `APIEndpoint.v2.inAppPurchases.id(id).versions.get` | `listInAppPurchaseVersions(iapId:)` | +| Subscription versions | `GET /v1/subscriptions/{id}/versions?limit=200` | `APIEndpoint.v1.subscriptions.id(id).versions.get` | `listSubscriptionVersions(subscriptionId:)` | +| Group versions | `GET /v1/subscriptionGroups/{id}/versions?limit=200` | `APIEndpoint.v1.subscriptionGroups.id(id).versions.get` | `listSubscriptionGroupVersions(groupId:)` | +| Open draft | `GET /v1/reviewSubmissions?filter[app]&filter[platform]&filter[state]=READY_FOR_REVIEW,UNRESOLVED_ISSUES`, else `POST /v1/reviewSubmissions` | `APIEndpoint.v1.reviewSubmissions.get` / `.post` | `createSubmission(appId:platform:)` | +| Add item | `POST /v1/reviewSubmissionItems` (relationship `appStoreVersion`, `inAppPurchaseVersion`, `subscriptionVersion` or `subscriptionGroupVersion`) | `APIEndpoint.v1.reviewSubmissionItems.post` | `addItem(submissionId:target:)` | +| Remove item | `DELETE /v1/reviewSubmissionItems/{id}` | `APIEndpoint.v1.reviewSubmissionItems.id(id).delete` | `removeItem(itemId:)` | +| Submit | `PATCH /v1/reviewSubmissions/{id}` `{submitted: true}`, then `GET …?include=app` | `.patch` / `.get` | `submit(submissionId:)` | + +Apple does not allow deleting a review submission, and cancels only submitted ones; an unused draft stays `READY_FOR_REVIEW` and is reused by the next `create`. + +--- + +## Testing + +```swift +@Test func `a ready subscription joins the plan after its group's version`() async throws { + // … groups → [Pro], subscriptions → [Monthly (READY_TO_SUBMIT)], versions submittable + let plan = try await repos.planner.plan(for: version) + #expect(plan.items == [ + PlannedReviewItem(kind: .appStoreVersion, versionId: "v-1", productId: "app-1", name: "1.0"), + PlannedReviewItem(kind: .subscriptionGroupVersion, versionId: "gv-1", productId: "grp-1", name: "Pro"), + PlannedReviewItem(kind: .subscriptionVersion, versionId: "sv-1", productId: "sub-1", name: "Monthly"), + ]) +} +``` + +```bash +swift test --filter 'ProductVersion|SubmissionPlanner|ReviewItemTarget|SDKSubmissionRepository|ReviewSubmissionsBuild|VersionsSubmit' +``` + +--- + +## Extending + +- **Paging products.** The planner reads up to 200 IAPs, groups and subscriptions per parent (one page); follow `nextCursor` if an app ever exceeds that. +- **Other reviewable resources.** Custom product pages, in-app events and Game Center versions can already be items; add a `ReviewItemTarget` case and a flag: + +```swift +case appEvent(String) // ReviewItemTarget +// ReviewSubmissionItemsAdd +@Option(name: .long) var appEventId: String? +``` + +- **Readiness.** `asc versions check-readiness` doesn't yet check what Apple checks on submission (required device screenshot sizes, content rights, App Privacy answers, pricing); `ReviewSubmissionError.refused` surfaces those today. diff --git a/examples/hello-plugin/Package.resolved b/examples/hello-plugin/Package.resolved index da40b253..0114c8dd 100644 --- a/examples/hello-plugin/Package.resolved +++ b/examples/hello-plugin/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "5ca13c8b8a658d2ed3f3750375f2fed88c8500c30529875302bf8a24069f9951", + "originHash" : "1c0017dd249b8365ff145be806d3385291406f210d0c96cf16e2cf5601a7866e", "pins" : [ { "identity" : "appstoreconnect-swift-sdk", "kind" : "remoteSourceControl", "location" : "https://github.com/AvdLee/appstoreconnect-swift-sdk.git", "state" : { - "revision" : "cbc9b7f0ef2b38cd66a53e885ad47c1c483c4b72", - "version" : "4.3.0" + "revision" : "55fceaba611dcf7f4e7f897addcd8fb90867fa01", + "version" : "4.4.3" } }, { @@ -19,6 +19,15 @@ "version" : "1.33.1" } }, + { + "identity" : "big-num", + "kind" : "remoteSourceControl", + "location" : "https://github.com/adam-fowler/big-num", + "state" : { + "revision" : "9059dcab8dd001b8143bc512b4477d079e97957f", + "version" : "2.0.3" + } + }, { "identity" : "compress-nio", "kind" : "remoteSourceControl", @@ -33,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/hummingbird-project/hummingbird.git", "state" : { - "revision" : "d1ce7bbd2f1b17f22031ca4c0daeb39eff07a92e", - "version" : "2.21.1" + "revision" : "bb1f427eca980a1280a83aee0079cd4371455de6", + "version" : "2.27.0" } }, { @@ -42,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/hummingbird-project/hummingbird-websocket.git", "state" : { - "revision" : "716c54294152c6d3301a6239a1d74db57cbcd6dc", - "version" : "2.6.0" + "revision" : "89c66b8d6655209268c44f619afcf771d8d83ec9", + "version" : "2.7.0" } }, { @@ -51,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Kolos65/Mockable", "state" : { - "revision" : "bd20509c34ca3205003677747ad8dcb70d18b7ca", - "version" : "0.6.2" + "revision" : "0b239389c5fa6666fb30dc97fbb23fc3ca851a85", + "version" : "0.6.4" } }, { @@ -60,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/steipete/SweetCookieKit.git", "state" : { - "revision" : "4d5b71ffbb296937dc5ee8472f64721bca771cf0", - "version" : "0.4.0" + "revision" : "aa9fbefd7d4fb6335a3cd71fd6210f9a35535185", + "version" : "0.5.3" } }, { @@ -78,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", - "version" : "1.7.1" + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" } }, { @@ -195,8 +204,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "8c0f217f01000dd30f60d6e536569ad4e74291f9", - "version" : "1.11.0" + "revision" : "9c6fb14227f55d8f711ce3847dc2f419fb0ecacb", + "version" : "1.15.1" } }, { @@ -213,8 +222,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-metrics.git", "state" : { - "revision" : "f17c111cec972c2a4922cef38cf64f76f7e87886", - "version" : "2.8.0" + "revision" : "087e8074afa97040c3b870c8664fe5482fb87cc4", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-mustache", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hummingbird-project/swift-mustache.git", + "state" : { + "revision" : "0b91b3127baee1d97c45a7390a49779256b4295d", + "version" : "2.1.0" } }, { @@ -222,8 +240,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "558f24a4647193b5a0e2104031b71c55d31ff83a", - "version" : "2.97.1" + "revision" : "21de5f08c1a166a6dd293d0e587ad977bf8dac5d", + "version" : "2.103.0" } }, { @@ -231,8 +249,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-extras.git", "state" : { - "revision" : "abcf5312eb8ed2fb11916078aef7c46b06f20813", - "version" : "1.33.0" + "revision" : "41449336c8ecfadac6b4b5be75f9c3c306e61ced", + "version" : "1.35.1" } }, { @@ -240,8 +258,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-http2.git", "state" : { - "revision" : "6d8d596f0a9bfebb925733003731fe2d749b7e02", - "version" : "1.42.0" + "revision" : "0f3e54e29c944c2e835ad52159da7d9e1c94ac69", + "version" : "1.46.0" } }, { @@ -289,6 +307,15 @@ "version" : "2.11.0" } }, + { + "identity" : "swift-srp", + "kind" : "remoteSourceControl", + "location" : "https://github.com/adam-fowler/swift-srp.git", + "state" : { + "revision" : "1345dfeff4d1bc54fc36257325371df3d1d7a813", + "version" : "2.4.0" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", @@ -312,8 +339,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/hummingbird-project/swift-websocket.git", "state" : { - "revision" : "ca48d46c25f8fa948d37eaa480c73172182cf90f", - "version" : "1.5.0" + "revision" : "126df9655565068bd97838c072c1db11f9fd42ee", + "version" : "1.6.1" } }, { @@ -321,8 +348,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/steipete/TauTUI.git", "state" : { - "revision" : "d30c095b65885adb7dc5042a9f5012e574cdc73b", - "version" : "0.1.5" + "revision" : "c452fee5a932bf8c17ff81ce9e8fdb0071a16a5b", + "version" : "0.2.2" } }, { diff --git a/examples/hello-plugin/Package.swift b/examples/hello-plugin/Package.swift index 56622bf8..c21d42af 100644 --- a/examples/hello-plugin/Package.swift +++ b/examples/hello-plugin/Package.swift @@ -9,7 +9,7 @@ let package = Package( ], dependencies: [ .package(path: "../../"), // asc-cli - .package(url: "https://github.com/hummingbird-project/hummingbird.git", exact: "2.21.1"), + .package(url: "https://github.com/hummingbird-project/hummingbird.git", exact: "2.27.0"), ], targets: [ .target(