From d1cee22d1cff4102d9459cad173a5748c23d23d0 Mon Sep 17 00:00:00 2001 From: Aleksei Zelentsov Date: Tue, 21 Jul 2026 17:14:28 +0300 Subject: [PATCH 1/2] fix: request ownership relationship linkages --- CHANGELOG.md | 11 +++ README.md | 10 +-- Sources/asc-mcp/Core/ServerVersion.swift | 2 +- .../Resources/OperationManifest/manifest.json | 6 +- .../OperationManifest/tools/apps.json | 42 +++++++++-- .../tools/review_attachments.json | 42 ++++++----- .../tools/review_submissions.json | 4 +- .../OperationManifest/tools/versions.json | 28 ++++++- .../AppLifecycleWorker+Handlers.swift | 16 +++- .../AppsWorker/AppsWorker+Handlers.swift | 18 ++++- .../ReviewAttachmentsWorker+Handlers.swift | 13 +++- .../ReviewSubmissionsWorker+Handlers.swift | 5 +- ...iewSubmissionsWorker+ToolDefinitions.swift | 2 +- .../BuildUploadsManifestContractTests.swift | 6 +- .../TestFlightManifestContractTests.swift | 6 +- .../XcodeCloudManifestContractTests.swift | 6 +- .../AppLifecycleReliabilityTests.swift | 42 +++++++++++ .../AppLifecycleWorkerContractTests.swift | 22 ++++-- ...psVersionsOptionalInputContractTests.swift | 1 + .../Workers/AppsWorkerReliabilityTests.swift | 74 +++++++++++++++++-- .../CompletePaginationScopeTests.swift | 6 ++ .../InternalPaginationScopeTests.swift | 2 +- .../MarketingOptionalInputContractTests.swift | 4 +- .../MarketingPaginationScopeTests.swift | 2 + ...ewAttachmentsV319SafetyContractTests.swift | 6 +- ...iewAttachmentsWorkerReliabilityTests.swift | 38 ++++++---- ...ReviewSubmissionsWorkerContractTests.swift | 8 +- 27 files changed, 322 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f24bc4e..b81ab0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [4.1.3] - 2026-07-21 + +### Fixed + +- Restore App Store version metadata reads, localization listing and updates, review-detail resolution, review-attachment reads, review-submission listing and preflight, and legacy age-rating lookup by explicitly requesting the parent relationship linkage that Apple can omit from sparse responses unless `include` is supplied. +- Preserve strict app and version ownership validation: missing or mismatched linkage still fails locally instead of weakening cross-resource containment checks. Thanks to [@muenzpraeger](https://github.com/muenzpraeger) for the live report and regression analysis in [#6](https://github.com/zelentsov-dev/asc-mcp/pull/6). + +### Compatibility + +- The public catalog remains at 502 tools with no renamed inputs or removed response fields. Existing callers require no configuration changes. + ## [4.1.2] - 2026-07-21 ### Changed diff --git a/README.md b/README.md index 071d725..f226966 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ ```bash # 1. Install via Mint brew install mint -mint install zelentsov-dev/asc-mcp@v4.1.2 +mint install zelentsov-dev/asc-mcp@v4.1.3 # 2. Add to Claude Code with env vars (simplest setup) claude mcp add asc-mcp \ @@ -88,7 +88,7 @@ Or use a JSON config file — see [Configuration](#configuration) below. brew install mint # Install asc-mcp from GitHub -mint install zelentsov-dev/asc-mcp@v4.1.2 +mint install zelentsov-dev/asc-mcp@v4.1.3 # Register in Claude Code claude mcp add asc-mcp -- ~/.mint/bin/asc-mcp @@ -99,13 +99,13 @@ To install a specific branch or tag: ```bash mint install zelentsov-dev/asc-mcp@main # main branch mint install zelentsov-dev/asc-mcp@develop # develop branch -mint install zelentsov-dev/asc-mcp@v4.1.2 # specific tag +mint install zelentsov-dev/asc-mcp@v4.1.3 # specific tag ``` To update to the latest version: ```bash -mint install zelentsov-dev/asc-mcp@v4.1.2 --force +mint install zelentsov-dev/asc-mcp@v4.1.3 --force ``` ### Migrating from v4.0.x @@ -451,7 +451,7 @@ swift run asc-mcp openapi-contract-check \ The manifest is pinned to Apple API 4.4.1 by version, SHA-256, path count, and operation count. It currently maps 476 Apple operations, explicitly defers 424, and scopes out 363, covering all 1,263 operations without overlap. CI fails when the Apple document changes, a mapped operation moves or disappears, a public tool or worker drifts from the manifest, an input field loses its binding, response lineage becomes invalid, or a deferred decision expires. Unexposed optional Apple parameters are warnings so they remain visible in the generated backlog. -Manifest schema v2 also accounts for every optional Apple query and request-body input as publicly bound, internally controlled, intentionally omitted with a reviewed reason, or still unclassified. The checked-in `optionalInputCoveragePin` records the exact current totals and a SHA-256 digest of the sorted input identities and dispositions; `--strict` rejects a missing pin or any count- or identity-level drift. The pin makes phased remediation auditable and regression-safe, but it is not a claim that every optional Apple input is already public. The v4.1.2 pin is 2,905 total: 1,108 bound, 40 internally controlled, 1,757 intentionally omitted, and 0 unclassified. Its identity SHA-256 is `2e5eb2ebc1f4ae368dcb26fc8cd9de895866e27c6c22fd96f58e4e573e4af368`. +Manifest schema v2 also accounts for every optional Apple query and request-body input as publicly bound, internally controlled, intentionally omitted with a reviewed reason, or still unclassified. The checked-in `optionalInputCoveragePin` records the exact current totals and a SHA-256 digest of the sorted input identities and dispositions; `--strict` rejects a missing pin or any count- or identity-level drift. The pin makes phased remediation auditable and regression-safe, but it is not a claim that every optional Apple input is already public. The v4.1.3 pin is 2,905 total: 1,122 bound, 40 internally controlled, 1,743 intentionally omitted, and 0 unclassified. Its identity SHA-256 is `c975f4e4eebb62ec87864a73fbf72bb8841f644108e54e6ffb25168bcf2a2766`. `--strict` is the merge- and tag-time release gate. Every declared `target` or `broken` tool remains an error in reports, and a regression test pins their exact state. The current baseline has no `target` or `broken` implementations and no implementation drift, so any implementation that leaves `asBuilt`, any structural contract error, or any optional-input coverage drift blocks both merges and releases. `--structural-strict` remains available only for local phased remediation work. diff --git a/Sources/asc-mcp/Core/ServerVersion.swift b/Sources/asc-mcp/Core/ServerVersion.swift index fbde19e..74607d8 100644 --- a/Sources/asc-mcp/Core/ServerVersion.swift +++ b/Sources/asc-mcp/Core/ServerVersion.swift @@ -1,5 +1,5 @@ import Foundation enum ServerVersion { - static let current = "4.1.2" + static let current = "4.1.3" } diff --git a/Sources/asc-mcp/Resources/OperationManifest/manifest.json b/Sources/asc-mcp/Resources/OperationManifest/manifest.json index 227f597..8ff407d 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/manifest.json +++ b/Sources/asc-mcp/Resources/OperationManifest/manifest.json @@ -8,11 +8,11 @@ }, "optionalInputCoveragePin": { "total": 2905, - "bound": 1108, + "bound": 1122, "internalControl": 40, - "intentionallyOmitted": 1757, + "intentionallyOmitted": 1743, "unclassified": 0, - "identitySHA256": "2e5eb2ebc1f4ae368dcb26fc8cd9de895866e27c6c22fd96f58e4e573e4af368" + "identitySHA256": "c975f4e4eebb62ec87864a73fbf72bb8841f644108e54e6ffb25168bcf2a2766" }, "optionalParameterFamilyRules": [ { diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/apps.json b/Sources/asc-mcp/Resources/OperationManifest/tools/apps.json index 3b29ea6..bb0bfba 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/apps.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/apps.json @@ -135,7 +135,6 @@ "role": "supporting", "condition": "version_id is supplied", "optionalParameterClassifications": [ - { "location": "query", "appleName": "include", "disposition": "intentionallyOmitted", "reason": "The ownership and selection check uses a fixed sparse App Store version projection and does not consume included relationships.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appStoreVersionExperiments]", "disposition": "intentionallyOmitted", "reason": "App Store version experiments are not included by this metadata lookup.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appStoreVersionExperimentsV2]", "disposition": "intentionallyOmitted", "reason": "App Store version experiments are not included by this metadata lookup.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appStoreVersionLocalizations]", "disposition": "intentionallyOmitted", "reason": "Localizations are fetched through their dedicated paginated related-resource operation.", "reviewAtSpec": "4.4.1" } @@ -146,6 +145,12 @@ "location": "query", "appleName": "fields[appStoreVersions]", "fixedValue": ["app", "platform", "versionString", "appVersionState", "appStoreState"] + }, + { + "sourceKind": "fixed", + "location": "query", + "appleName": "include", + "fixedValue": ["app"] } ] }, @@ -187,7 +192,6 @@ "path": "/v1/appStoreVersions/{id}/appStoreVersionLocalizations", "role": "primary", "optionalParameterClassifications": [ - { "location": "query", "appleName": "include", "disposition": "intentionallyOmitted", "reason": "The metadata aggregate reads base localizations and loads media through dedicated paginated operations.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appPreviewSets]", "disposition": "intentionallyOmitted", "reason": "Preview sets are loaded through their dedicated paginated related-resource operation.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appScreenshotSets]", "disposition": "intentionallyOmitted", "reason": "Screenshot sets are loaded through their dedicated paginated related-resource operation.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[searchKeywords]", "disposition": "intentionallyOmitted", "reason": "Search keyword relationships are outside the metadata aggregate response.", "reviewAtSpec": "4.4.1" } @@ -209,6 +213,12 @@ "appleName": "fields[appStoreVersionLocalizations]", "fixedValue": ["description", "locale", "keywords", "marketingUrl", "promotionalText", "supportUrl", "whatsNew", "appStoreVersion"] }, + { + "sourceKind": "fixed", + "location": "query", + "appleName": "include", + "fixedValue": ["appStoreVersion"] + }, { "sourceKind": "fixed", "location": "query", @@ -406,7 +416,6 @@ "path": "/v1/appStoreVersions/{id}", "role": "supporting", "optionalParameterClassifications": [ - { "location": "query", "appleName": "include", "disposition": "intentionallyOmitted", "reason": "The ownership check reads only the fixed App relationship projection.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appStoreVersionExperiments]", "disposition": "intentionallyOmitted", "reason": "App Store version experiments are not included in the ownership check.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appStoreVersionExperimentsV2]", "disposition": "intentionallyOmitted", "reason": "App Store version experiments are not included in the ownership check.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appStoreVersionLocalizations]", "disposition": "intentionallyOmitted", "reason": "Localizations are fetched by the dedicated primary operation.", "reviewAtSpec": "4.4.1" } @@ -417,6 +426,12 @@ "location": "query", "appleName": "fields[appStoreVersions]", "fixedValue": ["app"] + }, + { + "sourceKind": "fixed", + "location": "query", + "appleName": "include", + "fixedValue": ["app"] } ] }, @@ -426,7 +441,6 @@ "path": "/v1/appStoreVersions/{id}/appStoreVersionLocalizations", "role": "primary", "optionalParameterClassifications": [ - { "location": "query", "appleName": "include", "disposition": "intentionallyOmitted", "reason": "The list returns the base localization projection and does not expand relationships.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appPreviewSets]", "disposition": "intentionallyOmitted", "reason": "Preview sets are not included by the localization list.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appScreenshotSets]", "disposition": "intentionallyOmitted", "reason": "Screenshot sets are not included by the localization list.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[searchKeywords]", "disposition": "intentionallyOmitted", "reason": "Search keywords are not included by the localization list.", "reviewAtSpec": "4.4.1" } @@ -437,6 +451,12 @@ "location": "query", "appleName": "fields[appStoreVersionLocalizations]", "fixedValue": ["locale", "description", "whatsNew", "keywords", "promotionalText", "supportUrl", "marketingUrl", "appStoreVersion"] + }, + { + "sourceKind": "fixed", + "location": "query", + "appleName": "include", + "fixedValue": ["appStoreVersion"] } ] } @@ -715,7 +735,6 @@ "path": "/v1/appStoreVersions/{id}", "role": "supporting", "optionalParameterClassifications": [ - { "location": "query", "appleName": "include", "disposition": "intentionallyOmitted", "reason": "The write preflight reads only the fixed version ownership and state projection.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appStoreVersionExperiments]", "disposition": "intentionallyOmitted", "reason": "App Store version experiments are not included in the write preflight.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appStoreVersionExperimentsV2]", "disposition": "intentionallyOmitted", "reason": "App Store version experiments are not included in the write preflight.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appStoreVersionLocalizations]", "disposition": "intentionallyOmitted", "reason": "The target localization is resolved through the dedicated filtered operation.", "reviewAtSpec": "4.4.1" } @@ -726,6 +745,12 @@ "location": "query", "appleName": "fields[appStoreVersions]", "fixedValue": ["app", "platform", "versionString", "appVersionState", "appStoreState"] + }, + { + "sourceKind": "fixed", + "location": "query", + "appleName": "include", + "fixedValue": ["app"] } ] }, @@ -736,7 +761,6 @@ "role": "supporting", "optionalParameterClassifications": [ { "location": "query", "appleName": "limit", "disposition": "internalControl", "reason": "The exact-locale write lookup fixes the page size to one before patching the resolved localization.", "reviewAtSpec": "4.4.1" }, - { "location": "query", "appleName": "include", "disposition": "intentionallyOmitted", "reason": "The exact-locale write lookup uses a fixed base localization projection and does not expand relationships.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appPreviewSets]", "disposition": "intentionallyOmitted", "reason": "Preview sets are not included in the write lookup.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appScreenshotSets]", "disposition": "intentionallyOmitted", "reason": "Screenshot sets are not included in the write lookup.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[searchKeywords]", "disposition": "intentionallyOmitted", "reason": "Search keywords are not included in the write lookup.", "reviewAtSpec": "4.4.1" } @@ -747,6 +771,12 @@ "location": "query", "appleName": "fields[appStoreVersionLocalizations]", "fixedValue": ["locale", "appStoreVersion"] + }, + { + "sourceKind": "fixed", + "location": "query", + "appleName": "include", + "fixedValue": ["appStoreVersion"] } ] } diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/review_attachments.json b/Sources/asc-mcp/Resources/OperationManifest/tools/review_attachments.json index f2de874..cb65a07 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/review_attachments.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/review_attachments.json @@ -59,15 +59,6 @@ "method": "get", "path": "/v1/appStoreReviewAttachments/{id}", "role": "primary", - "optionalParameterClassifications": [ - { - "location": "query", - "appleName": "include", - "disposition": "intentionallyOmitted", - "reason": "The attachment projection exposes its review-detail relationship ID without expanding reviewer contact or demo-account data.", - "reviewAtSpec": "4.4.1" - } - ], "inputs": [ { "sourceKind": "fixed", @@ -80,6 +71,18 @@ "assetDeliveryState", "appStoreReviewDetail" ] + }, + { + "sourceKind": "fixed", + "location": "query", + "appleName": "include", + "fixedValue": ["appStoreReviewDetail"] + }, + { + "sourceKind": "fixed", + "location": "query", + "appleName": "fields[appStoreReviewDetails]", + "fixedValue": ["appStoreVersion"] } ] } @@ -133,15 +136,6 @@ "method": "get", "path": "/v1/appStoreReviewDetails/{id}/appStoreReviewAttachments", "role": "primary", - "optionalParameterClassifications": [ - { - "location": "query", - "appleName": "include", - "disposition": "intentionallyOmitted", - "reason": "The list projects each attachment's review-detail relationship ID without repeating reviewer contact or demo-account data in included resources.", - "reviewAtSpec": "4.4.1" - } - ], "inputs": [ { "sourceKind": "fixed", @@ -154,6 +148,18 @@ "assetDeliveryState", "appStoreReviewDetail" ] + }, + { + "sourceKind": "fixed", + "location": "query", + "appleName": "include", + "fixedValue": ["appStoreReviewDetail"] + }, + { + "sourceKind": "fixed", + "location": "query", + "appleName": "fields[appStoreReviewDetails]", + "fixedValue": ["appStoreVersion"] } ] } diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/review_submissions.json b/Sources/asc-mcp/Resources/OperationManifest/tools/review_submissions.json index ff93773..7a577ca 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/review_submissions.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/review_submissions.json @@ -236,7 +236,7 @@ { "toolField": "app_id", "sourceKind": "parameter", "operationId": "reviewSubmissions_getCollection", "location": "query", "appleName": "filter[app]" }, { "toolField": "states", "sourceKind": "parameter", "operationId": "reviewSubmissions_getCollection", "location": "query", "appleName": "filter[state]" }, { "toolField": "platforms", "sourceKind": "parameter", "operationId": "reviewSubmissions_getCollection", "location": "query", "appleName": "filter[platform]" }, - { "toolField": "include", "sourceKind": "parameter", "operationId": "reviewSubmissions_getCollection", "location": "query", "appleName": "include" }, + { "toolField": "include", "sourceKind": "derived", "operationId": "reviewSubmissions_getCollection", "location": "query", "appleName": "include", "derivedFrom": ["include"], "localRole": "Preserve the caller-selected relationships and always add app so every returned submission can be validated against the required app_id filter." }, { "toolField": "item_limit", "sourceKind": "parameter", "operationId": "reviewSubmissions_getCollection", "location": "query", "appleName": "limit[items]" }, { "toolField": "limit", "sourceKind": "parameter", "operationId": "reviewSubmissions_getCollection", "location": "query", "appleName": "limit" }, { "toolField": "next_url", "sourceKind": "local", "localRole": "Validate the exact origin, collection path, complete originating query, query allowlist, and non-empty cursor." } @@ -268,7 +268,7 @@ { "outputField": "total", "operationId": "reviewSubmissions_getCollection", "jsonPointer": "/meta/paging/total" } ] }, - "note": "App ownership is mandatory; scalar, strict CSV, and unique-array filters are validated before the request, and continuation URLs preserve every originating query value. Apple collection responses are bounded by the effective requested limit, require scoped self/next links, preserve cursor consistency, and reject invalid or duplicate resource identities." + "note": "App ownership is mandatory; scalar, strict CSV, and unique-array filters are validated before the request, and the effective include always contains app even when the caller selects a narrower relationship subset. Continuation URLs preserve every effective originating query value. Apple collection responses are bounded by the effective requested limit, require scoped self/next links, preserve cursor consistency, and reject invalid or duplicate resource identities." }, { "tool": "review_submissions_list_items", diff --git a/Sources/asc-mcp/Resources/OperationManifest/tools/versions.json b/Sources/asc-mcp/Resources/OperationManifest/tools/versions.json index 469f396..362b000 100644 --- a/Sources/asc-mcp/Resources/OperationManifest/tools/versions.json +++ b/Sources/asc-mcp/Resources/OperationManifest/tools/versions.json @@ -743,7 +743,6 @@ "role": "supporting", "condition": "Determines whether to create review details or update the existing resource.", "optionalParameterClassifications": [ - { "location": "query", "appleName": "include", "disposition": "intentionallyOmitted", "reason": "The lookup requests relationship linkage through a sparse fieldset and does not expand the parent version resource.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appStoreReviewAttachments]", "disposition": "intentionallyOmitted", "reason": "Review attachments are managed by ReviewAttachmentsWorker and are not included in this existence lookup.", "reviewAtSpec": "4.4.1" } ], "inputs": [ @@ -752,6 +751,12 @@ "location": "query", "appleName": "fields[appStoreReviewDetails]", "fixedValue": ["appStoreVersion"] + }, + { + "sourceKind": "fixed", + "location": "query", + "appleName": "include", + "fixedValue": ["appStoreVersion"] } ] }, @@ -977,7 +982,6 @@ "role": "supporting", "condition": "Always validates the version's owning app before creating a review submission.", "optionalParameterClassifications": [ - { "location": "query", "appleName": "include", "disposition": "intentionallyOmitted", "reason": "The submission preflight uses a fixed sparse version projection and does not expand relationships.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appStoreVersionExperiments]", "disposition": "intentionallyOmitted", "reason": "Legacy experiments are not included by the submission preflight.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appStoreVersionExperimentsV2]", "disposition": "intentionallyOmitted", "reason": "V2 experiments are not included by the submission preflight.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appStoreVersionLocalizations]", "disposition": "intentionallyOmitted", "reason": "Localizations are not included by the submission preflight.", "reviewAtSpec": "4.4.1" } @@ -988,6 +992,12 @@ "location": "query", "appleName": "fields[appStoreVersions]", "fixedValue": ["app", "platform", "versionString", "appVersionState"] + }, + { + "sourceKind": "fixed", + "location": "query", + "appleName": "include", + "fixedValue": ["app"] } ] }, @@ -1249,7 +1259,6 @@ "role": "supporting", "condition": "Used only when app_info_id is omitted; resolves the owning app and current appVersionState from legacy version_id.", "optionalParameterClassifications": [ - { "location": "query", "appleName": "include", "disposition": "intentionallyOmitted", "reason": "Legacy resolution uses a fixed sparse version projection and does not expand relationships.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appStoreVersionExperiments]", "disposition": "intentionallyOmitted", "reason": "Legacy experiments are not included during App Info resolution.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appStoreVersionExperimentsV2]", "disposition": "intentionallyOmitted", "reason": "V2 experiments are not included during App Info resolution.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appStoreVersionLocalizations]", "disposition": "intentionallyOmitted", "reason": "Localizations are not included during App Info resolution.", "reviewAtSpec": "4.4.1" } @@ -1260,6 +1269,12 @@ "location": "query", "appleName": "fields[appStoreVersions]", "fixedValue": ["app", "appVersionState"] + }, + { + "sourceKind": "fixed", + "location": "query", + "appleName": "include", + "fixedValue": ["app"] } ] }, @@ -1270,7 +1285,6 @@ "role": "supporting", "condition": "Used only for legacy version_id resolution; requires Apple's page links, strictly paginates the complete App Info collection, and selects exactly one AppInfo whose state equals the version state or an explicit documented state mapping.", "optionalParameterClassifications": [ - { "location": "query", "appleName": "include", "disposition": "intentionallyOmitted", "reason": "Legacy resolution reads only App Info state and does not expand relationships.", "reviewAtSpec": "4.4.1" }, { "location": "query", "appleName": "limit[appInfoLocalizations]", "disposition": "intentionallyOmitted", "reason": "App Info localizations are not included during age-rating declaration resolution.", "reviewAtSpec": "4.4.1" } ], "inputs": [ @@ -1289,6 +1303,12 @@ "appleName": "fields[appInfos]", "fixedValue": ["state", "app"] }, + { + "sourceKind": "fixed", + "location": "query", + "appleName": "include", + "fixedValue": ["app"] + }, { "sourceKind": "fixed", "location": "query", diff --git a/Sources/asc-mcp/Workers/AppLifecycleWorker/AppLifecycleWorker+Handlers.swift b/Sources/asc-mcp/Workers/AppLifecycleWorker/AppLifecycleWorker+Handlers.swift index 361a3cd..8e601f4 100644 --- a/Sources/asc-mcp/Workers/AppLifecycleWorker/AppLifecycleWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/AppLifecycleWorker/AppLifecycleWorker+Handlers.swift @@ -618,7 +618,10 @@ extension AppLifecycleWorker { do { let versionResponse = try await httpClient.get( "/v1/appStoreVersions/\(try ASCPathSegment.encode(versionId))", - parameters: ["fields[appStoreVersions]": "app,platform,versionString,appVersionState"], + parameters: [ + "fields[appStoreVersions]": "app,platform,versionString,appVersionState", + "include": "app" + ], as: ASCAppStoreVersionResponse.self ) try validateVersionResource( @@ -1509,7 +1512,10 @@ extension AppLifecycleWorker { } else if let versionId { let versionResponse = try await httpClient.get( "/v1/appStoreVersions/\(try ASCPathSegment.encode(versionId))", - parameters: ["fields[appStoreVersions]": "app,appVersionState"], + parameters: [ + "fields[appStoreVersions]": "app,appVersionState", + "include": "app" + ], as: ASCAppStoreVersionResponse.self ) try validateVersionResource( @@ -1533,6 +1539,7 @@ extension AppLifecycleWorker { let appInfoPath = "/v1/apps/\(try ASCPathSegment.encode(app.id))/appInfos" let appInfoParameters = [ "fields[appInfos]": "state,app", + "include": "app", "limit": "200" ] let appInfoScope = PaginationScope.strict( @@ -1761,7 +1768,10 @@ private extension AppLifecycleWorker { do { let response = try await httpClient.get( "/v1/appStoreVersions/\(try ASCPathSegment.encode(versionId))/appStoreReviewDetail", - parameters: ["fields[appStoreReviewDetails]": "appStoreVersion"], + parameters: [ + "fields[appStoreReviewDetails]": "appStoreVersion", + "include": "appStoreVersion" + ], as: SingleResourceResponse.self ) try validateSingleResource( diff --git a/Sources/asc-mcp/Workers/AppsWorker/AppsWorker+Handlers.swift b/Sources/asc-mcp/Workers/AppsWorker/AppsWorker+Handlers.swift index c415251..0ab5632 100644 --- a/Sources/asc-mcp/Workers/AppsWorker/AppsWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/AppsWorker/AppsWorker+Handlers.swift @@ -127,6 +127,7 @@ extension AppsWorker { let path = "/v1/appStoreVersions/\(try ASCPathSegment.encode(versionId))/appStoreVersionLocalizations" var parameters = [ "fields[appStoreVersionLocalizations]": "description,locale,keywords,marketingUrl,promotionalText,supportUrl,whatsNew,appStoreVersion", + "include": "appStoreVersion", "limit": "200" ] if let locale { @@ -669,7 +670,10 @@ extension AppsWorker { // Use provided version_id — fetch its details let versionResponse: ASCAppStoreVersionResponse = try await httpClient.get( "/v1/appStoreVersions/\(try ASCPathSegment.encode(versionId))", - parameters: ["fields[appStoreVersions]": "app,platform,versionString,appVersionState,appStoreState"], + parameters: [ + "fields[appStoreVersions]": "app,platform,versionString,appVersionState,appStoreState", + "include": "app" + ], as: ASCAppStoreVersionResponse.self ) let v = versionResponse.data @@ -976,7 +980,10 @@ extension AppsWorker { // enforces the exact editable-state rules on the PATCH request. let versionResponse: ASCAppStoreVersionResponse = try await httpClient.get( "/v1/appStoreVersions/\(try ASCPathSegment.encode(versionId))", - parameters: ["fields[appStoreVersions]": "app,platform,versionString,appVersionState,appStoreState"], + parameters: [ + "fields[appStoreVersions]": "app,platform,versionString,appVersionState,appStoreState", + "include": "app" + ], as: ASCAppStoreVersionResponse.self ) @@ -991,6 +998,7 @@ extension AppsWorker { parameters: [ "filter[locale]": locale, "fields[appStoreVersionLocalizations]": "locale,appStoreVersion", + "include": "appStoreVersion", "limit": "1" ], as: ASCAppStoreVersionLocalizationsResponse.self @@ -1264,6 +1272,7 @@ extension AppsWorker { let effectiveLimit = try validatedLimit(arguments["limit"], defaultValue: 200) var localizationParameters = [ "fields[appStoreVersionLocalizations]": localizationFields, + "include": "appStoreVersion", "limit": String(effectiveLimit) ] if let locales = try stringListQueryValue("locales", from: arguments) { @@ -1271,7 +1280,10 @@ extension AppsWorker { } let versionResponse: ASCAppStoreVersionResponse = try await httpClient.get( "/v1/appStoreVersions/\(try ASCPathSegment.encode(versionId))", - parameters: ["fields[appStoreVersions]": "app"], + parameters: [ + "fields[appStoreVersions]": "app", + "include": "app" + ], as: ASCAppStoreVersionResponse.self ) guard versionBelongsToApp(versionResponse.data, appId: appId) else { diff --git a/Sources/asc-mcp/Workers/ReviewAttachmentsWorker/ReviewAttachmentsWorker+Handlers.swift b/Sources/asc-mcp/Workers/ReviewAttachmentsWorker/ReviewAttachmentsWorker+Handlers.swift index cd750aa..4c27afb 100644 --- a/Sources/asc-mcp/Workers/ReviewAttachmentsWorker/ReviewAttachmentsWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/ReviewAttachmentsWorker/ReviewAttachmentsWorker+Handlers.swift @@ -125,18 +125,21 @@ extension ReviewAttachmentsWorker { do { let attachmentID = try reviewAttachmentIdentifier("attachment_id", from: arguments) + let query = [ + "fields[appStoreReviewAttachments]": reviewAttachmentReadFields, + "fields[appStoreReviewDetails]": "appStoreVersion", + "include": "appStoreReviewDetail" + ] let response: ASCReviewAttachmentResponse = try await httpClient.get( reviewAttachmentEndpoint(attachmentID), - parameters: ["fields[appStoreReviewAttachments]": reviewAttachmentReadFields], + parameters: query, as: ASCReviewAttachmentResponse.self ) try validateReviewAttachmentResponse( response, expectedID: attachmentID, requireReviewDetailLineage: true, - requiredQuery: [ - "fields[appStoreReviewAttachments]": reviewAttachmentReadFields - ], + requiredQuery: query, httpClient: httpClient, context: "review attachment get response" ) @@ -218,6 +221,8 @@ extension ReviewAttachmentsWorker { let effectiveLimit = try reviewAttachmentLimit(arguments["limit"]) let query = [ "fields[appStoreReviewAttachments]": reviewAttachmentReadFields, + "fields[appStoreReviewDetails]": "appStoreVersion", + "include": "appStoreReviewDetail", "limit": String(effectiveLimit) ] let path = "/v1/appStoreReviewDetails/\(try ASCPathSegment.encode(reviewDetailID, field: "review_detail_id"))/appStoreReviewAttachments" diff --git a/Sources/asc-mcp/Workers/ReviewSubmissionsWorker/ReviewSubmissionsWorker+Handlers.swift b/Sources/asc-mcp/Workers/ReviewSubmissionsWorker/ReviewSubmissionsWorker+Handlers.swift index 4ae4b94..97ebab2 100644 --- a/Sources/asc-mcp/Workers/ReviewSubmissionsWorker/ReviewSubmissionsWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/ReviewSubmissionsWorker/ReviewSubmissionsWorker+Handlers.swift @@ -98,11 +98,14 @@ extension ReviewSubmissionsWorker { field: "platforms", allowedValues: Set(ASCReviewSubmissionPlatform.allCases.map(\.rawValue)) ) - let includes = try stringList( + var includes = try stringList( arguments["include"], field: "include", allowedValues: Set(Self.submissionIncludes) ) ?? Self.submissionIncludes + if !includes.contains("app") { + includes.insert("app", at: 0) + } let limit = try boundedInteger(arguments["limit"], field: "limit", maximum: 200, defaultValue: 25) let itemLimit = try boundedInteger( arguments["item_limit"], diff --git a/Sources/asc-mcp/Workers/ReviewSubmissionsWorker/ReviewSubmissionsWorker+ToolDefinitions.swift b/Sources/asc-mcp/Workers/ReviewSubmissionsWorker/ReviewSubmissionsWorker+ToolDefinitions.swift index de3f81b..d341bb6 100644 --- a/Sources/asc-mcp/Workers/ReviewSubmissionsWorker/ReviewSubmissionsWorker+ToolDefinitions.swift +++ b/Sources/asc-mcp/Workers/ReviewSubmissionsWorker/ReviewSubmissionsWorker+ToolDefinitions.swift @@ -20,7 +20,7 @@ extension ReviewSubmissionsWorker { values: ASCReviewSubmissionPlatform.allCases.map(\.rawValue) ), "include": stringListSchema( - description: "Apple relationships to include for recovery inspection", + description: "Apple relationships to include for recovery inspection; app is always added for ownership validation", values: Self.submissionIncludes ), "item_limit": integerSchema( diff --git a/Tests/ASCMCPTests/Tooling/BuildUploadsManifestContractTests.swift b/Tests/ASCMCPTests/Tooling/BuildUploadsManifestContractTests.swift index 6debc22..115c6c8 100644 --- a/Tests/ASCMCPTests/Tooling/BuildUploadsManifestContractTests.swift +++ b/Tests/ASCMCPTests/Tooling/BuildUploadsManifestContractTests.swift @@ -77,11 +77,11 @@ struct BuildUploadsManifestContractTests { let pin = try #require(manifest.index.optionalInputCoveragePin) #expect(pin.total == 2_905) - #expect(pin.bound == 1_108) + #expect(pin.bound == 1_122) #expect(pin.internalControl == 40) - #expect(pin.intentionallyOmitted == 1_757) + #expect(pin.intentionallyOmitted == 1_743) #expect(pin.unclassified == 0) - #expect(pin.identitySHA256 == "2e5eb2ebc1f4ae368dcb26fc8cd9de895866e27c6c22fd96f58e4e573e4af368") + #expect(pin.identitySHA256 == "c975f4e4eebb62ec87864a73fbf72bb8841f644108e54e6ffb25168bcf2a2766") } @Test("operation methods paths statuses and effects are exact") diff --git a/Tests/ASCMCPTests/Tooling/TestFlightManifestContractTests.swift b/Tests/ASCMCPTests/Tooling/TestFlightManifestContractTests.swift index bde7fbf..d5c1964 100644 --- a/Tests/ASCMCPTests/Tooling/TestFlightManifestContractTests.swift +++ b/Tests/ASCMCPTests/Tooling/TestFlightManifestContractTests.swift @@ -70,11 +70,11 @@ struct TestFlightManifestContractTests { let pin = try #require(manifest.index.optionalInputCoveragePin) #expect(pin.total == 2_905) - #expect(pin.bound == 1_108) + #expect(pin.bound == 1_122) #expect(pin.internalControl == 40) - #expect(pin.intentionallyOmitted == 1_757) + #expect(pin.intentionallyOmitted == 1_743) #expect(pin.unclassified == 0) - #expect(pin.identitySHA256 == "2e5eb2ebc1f4ae368dcb26fc8cd9de895866e27c6c22fd96f58e4e573e4af368") + #expect(pin.identitySHA256 == "c975f4e4eebb62ec87864a73fbf72bb8841f644108e54e6ffb25168bcf2a2766") } @Test("TestFlight operation methods paths and success statuses are exact") diff --git a/Tests/ASCMCPTests/Tooling/XcodeCloudManifestContractTests.swift b/Tests/ASCMCPTests/Tooling/XcodeCloudManifestContractTests.swift index d5dc1fa..b776201 100644 --- a/Tests/ASCMCPTests/Tooling/XcodeCloudManifestContractTests.swift +++ b/Tests/ASCMCPTests/Tooling/XcodeCloudManifestContractTests.swift @@ -316,11 +316,11 @@ struct XcodeCloudManifestContractTests { let pin = try #require(manifest.index.optionalInputCoveragePin) #expect(pin.total == 2_905) - #expect(pin.bound == 1_108) + #expect(pin.bound == 1_122) #expect(pin.internalControl == 40) - #expect(pin.intentionallyOmitted == 1_757) + #expect(pin.intentionallyOmitted == 1_743) #expect(pin.unclassified == 0) - #expect(pin.identitySHA256 == "2e5eb2ebc1f4ae368dcb26fc8cd9de895866e27c6c22fd96f58e4e573e4af368") + #expect(pin.identitySHA256 == "c975f4e4eebb62ec87864a73fbf72bb8841f644108e54e6ffb25168bcf2a2766") } @Test("selected projections distinguish relationship self from related URLs") diff --git a/Tests/ASCMCPTests/Workers/AppLifecycleReliabilityTests.swift b/Tests/ASCMCPTests/Workers/AppLifecycleReliabilityTests.swift index 350e311..0a397c5 100644 --- a/Tests/ASCMCPTests/Workers/AppLifecycleReliabilityTests.swift +++ b/Tests/ASCMCPTests/Workers/AppLifecycleReliabilityTests.swift @@ -16,6 +16,12 @@ struct AppLifecycleReliabilityTests { ) == .array([ .string("app"), .string("platform"), .string("versionString"), .string("appVersionState") ])) + #expect(try lifecycleReliabilityFixedQuery( + manifest, + tool: "app_versions_submit_for_review", + operationID: "appStoreVersions_getInstance", + appleName: "include" + ) == .array([.string("app")])) #expect(try lifecycleReliabilityFixedQuery( manifest, tool: "app_versions_list", @@ -48,6 +54,24 @@ struct AppLifecycleReliabilityTests { ) == .array([ .string("appStoreVersion") ])) + #expect(try lifecycleReliabilityFixedQuery( + manifest, + tool: "app_versions_set_review_details", + operationID: "appStoreVersions_appStoreReviewDetail_getToOneRelated", + appleName: "include" + ) == .array([.string("appStoreVersion")])) + #expect(try lifecycleReliabilityFixedQuery( + manifest, + tool: "app_versions_update_age_rating", + operationID: "appStoreVersions_getInstance", + appleName: "include" + ) == .array([.string("app")])) + #expect(try lifecycleReliabilityFixedQuery( + manifest, + tool: "app_versions_update_age_rating", + operationID: "apps_appInfos_getToManyRelated", + appleName: "include" + ) == .array([.string("app")])) #expect(try lifecycleReliabilityFixedQuery( manifest, tool: "app_versions_update_age_rating", @@ -572,6 +596,24 @@ struct AppLifecycleReliabilityTests { #expect(request.httpMethod == "GET") let query = URLComponents(url: try #require(request.url), resolvingAgainstBaseURL: false)?.queryItems ?? [] #expect(query.first(where: { $0.name == "fields[appStoreVersions]" })?.value == "app,platform,versionString,appVersionState") + #expect(query.first(where: { $0.name == "include" })?.value == "app") + } + + @Test("submit fails closed when Apple omits app linkage") + func submitRejectsMissingOwnershipLinkage() async throws { + let transport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: #"{"data":{"type":"appStoreVersions","id":"ver-1","attributes":{"platform":"IOS"}}}"#) + ]) + let worker = try await makeLifecycleReliabilityWorker(transport) + + let result = try await worker.handleTool(.init( + name: "app_versions_submit_for_review", + arguments: ["version_id": .string("ver-1"), "app_id": .string("app-1")] + )) + + #expect(result.isError == true) + #expect(await transport.requestCount() == 1) + #expect((await transport.recordedRequests()).allSatisfy { $0.httpMethod == "GET" }) } } diff --git a/Tests/ASCMCPTests/Workers/AppLifecycleWorkerContractTests.swift b/Tests/ASCMCPTests/Workers/AppLifecycleWorkerContractTests.swift index 45bbfaf..e1a8b06 100644 --- a/Tests/ASCMCPTests/Workers/AppLifecycleWorkerContractTests.swift +++ b/Tests/ASCMCPTests/Workers/AppLifecycleWorkerContractTests.swift @@ -119,6 +119,8 @@ struct AppLifecycleWorkerContractTests { #expect(requests[0].url?.path == "/v1/appStoreVersions/ver-1/appStoreReviewDetail") #expect(URLComponents(url: try #require(requests[0].url), resolvingAgainstBaseURL: false)? .queryItems?.first(where: { $0.name == "fields[appStoreReviewDetails]" })?.value == "appStoreVersion") + #expect(URLComponents(url: try #require(requests[0].url), resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "include" })?.value == "appStoreVersion") #expect(requests[1].url?.path == "/v1/appStoreReviewDetails/review-1") let body = try #require(await transport.recordedBodyStrings().last) #expect(body.contains(#""notes":"Updated""#)) @@ -582,6 +584,10 @@ struct AppLifecycleWorkerContractTests { ]) #expect(URLComponents(url: try #require(requests[1].url), resolvingAgainstBaseURL: false)? .queryItems?.first(where: { $0.name == "fields[appInfos]" })?.value == "state,app") + #expect(URLComponents(url: try #require(requests[1].url), resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "include" })?.value == "app") + #expect(URLComponents(url: try #require(requests[0].url), resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "include" })?.value == "app") let patchBody = try #require(await transport.recordedBodyStrings().last) #expect(patchBody.contains(#""id":"age-1""#)) #expect(patchBody.contains(#""socialMedia":true"#)) @@ -595,7 +601,7 @@ struct AppLifecycleWorkerContractTests { @Test("legacy age rating scans every App Info page before selection") func ageRatingScansEveryAppInfoPage() async throws { - let nextURL = "https://api.example.test/v1/apps/app-1/appInfos?fields%5BappInfos%5D=state%2Capp&limit=200&cursor=page-2" + let nextURL = "https://api.example.test/v1/apps/app-1/appInfos?fields%5BappInfos%5D=state%2Capp&include=app&limit=200&cursor=page-2" let transport = TestHTTPTransport(responses: [ .init(statusCode: 200, body: versionWithAppBody(id: "ver-1", state: "PREPARE_FOR_SUBMISSION")), .init(statusCode: 200, body: """ @@ -604,7 +610,7 @@ struct AppLifecycleWorkerContractTests { { "type": "appInfos", "id": "info-live", "attributes": { "state": "ACCEPTED" }, "relationships": { "app": { "data": { "type": "apps", "id": "app-1" } } } } ], "links": { - "self": "https://api.example.test/v1/apps/app-1/appInfos?fields%5BappInfos%5D=state%2Capp&limit=200", + "self": "https://api.example.test/v1/apps/app-1/appInfos?fields%5BappInfos%5D=state%2Capp&include=app&limit=200", "next": "\(nextURL)" } } @@ -615,7 +621,7 @@ struct AppLifecycleWorkerContractTests { { "type": "appInfos", "id": "info-next", "attributes": { "state": "PREPARE_FOR_SUBMISSION" }, "relationships": { "app": { "data": { "type": "apps", "id": "app-1" } } } } ], "links": { - "self": "https://api.example.test/v1/apps/app-1/appInfos?fields%5BappInfos%5D=state%2Capp&limit=200&cursor=page-2" + "self": "https://api.example.test/v1/apps/app-1/appInfos?fields%5BappInfos%5D=state%2Capp&include=app&limit=200&cursor=page-2" } } """), @@ -647,7 +653,7 @@ struct AppLifecycleWorkerContractTests { @Test("legacy age rating rejects ambiguity found on a later App Info page") func ageRatingRejectsLaterPageAmbiguity() async throws { - let nextURL = "https://api.example.test/v1/apps/app-1/appInfos?fields%5BappInfos%5D=state%2Capp&limit=200&cursor=page-2" + let nextURL = "https://api.example.test/v1/apps/app-1/appInfos?fields%5BappInfos%5D=state%2Capp&include=app&limit=200&cursor=page-2" let transport = TestHTTPTransport(responses: [ .init(statusCode: 200, body: versionWithAppBody(id: "ver-1", state: "ACCEPTED")), .init(statusCode: 200, body: """ @@ -656,7 +662,7 @@ struct AppLifecycleWorkerContractTests { { "type": "appInfos", "id": "info-accepted", "attributes": { "state": "ACCEPTED" }, "relationships": { "app": { "data": { "type": "apps", "id": "app-1" } } } } ], "links": { - "self": "https://api.example.test/v1/apps/app-1/appInfos?fields%5BappInfos%5D=state%2Capp&limit=200", + "self": "https://api.example.test/v1/apps/app-1/appInfos?fields%5BappInfos%5D=state%2Capp&include=app&limit=200", "next": "\(nextURL)" } } @@ -667,7 +673,7 @@ struct AppLifecycleWorkerContractTests { { "type": "appInfos", "id": "info-accepted-2", "attributes": { "state": "ACCEPTED" }, "relationships": { "app": { "data": { "type": "apps", "id": "app-1" } } } } ], "links": { - "self": "https://api.example.test/v1/apps/app-1/appInfos?fields%5BappInfos%5D=state%2Capp&limit=200&cursor=page-2" + "self": "https://api.example.test/v1/apps/app-1/appInfos?fields%5BappInfos%5D=state%2Capp&include=app&limit=200&cursor=page-2" } } """) @@ -754,7 +760,8 @@ struct AppLifecycleWorkerContractTests { versionWithAppBody(type: "apps", id: "ver-1", state: "PREPARE_FOR_SUBMISSION"), versionWithAppBody(id: "ver-other", state: "PREPARE_FOR_SUBMISSION"), versionWithAppBody(id: "ver-1", state: "PREPARE_FOR_SUBMISSION", appType: "users"), - versionWithAppBody(id: "ver-1", state: "PREPARE_FOR_SUBMISSION", appId: " ") + versionWithAppBody(id: "ver-1", state: "PREPARE_FOR_SUBMISSION", appId: " "), + #"{"data":{"type":"appStoreVersions","id":"ver-1","attributes":{"appVersionState":"PREPARE_FOR_SUBMISSION"}}}"# ] for response in versionResponses { @@ -777,6 +784,7 @@ struct AppLifecycleWorkerContractTests { let appInfoResponses = [ #"{"data":[{"type":"apps","id":"info-1","attributes":{"state":"PREPARE_FOR_SUBMISSION"}}],"links":{"self":"https://api.example.test/v1/apps/app-1/appInfos"}}"#, #"{"data":[{"type":"appInfos","id":"","attributes":{"state":"PREPARE_FOR_SUBMISSION"}}],"links":{"self":"https://api.example.test/v1/apps/app-1/appInfos"}}"#, + #"{"data":[{"type":"appInfos","id":"info-1","attributes":{"state":"PREPARE_FOR_SUBMISSION"}}],"links":{"self":"https://api.example.test/v1/apps/app-1/appInfos"}}"#, #"{"data":[{"type":"appInfos","id":"info-1","attributes":{"state":"PREPARE_FOR_SUBMISSION"},"relationships":{"app":{"data":{"type":"apps","id":"app-other"}}}}],"links":{"self":"https://api.example.test/v1/apps/app-1/appInfos"}}"#, #"{"data":[{"type":"appInfos","id":"info-1","attributes":{"state":"ACCEPTED"},"relationships":{"app":{"data":{"type":"apps","id":"app-1"}}}},{"type":"appInfos","id":"info-1","attributes":{"state":"PREPARE_FOR_SUBMISSION"},"relationships":{"app":{"data":{"type":"apps","id":"app-1"}}}}],"links":{"self":"https://api.example.test/v1/apps/app-1/appInfos"}}"# ] diff --git a/Tests/ASCMCPTests/Workers/AppsVersionsOptionalInputContractTests.swift b/Tests/ASCMCPTests/Workers/AppsVersionsOptionalInputContractTests.swift index 34b5b1a..3e0c068 100644 --- a/Tests/ASCMCPTests/Workers/AppsVersionsOptionalInputContractTests.swift +++ b/Tests/ASCMCPTests/Workers/AppsVersionsOptionalInputContractTests.swift @@ -134,6 +134,7 @@ struct AppsVersionsOptionalInputContractTests { "cursor": "page-2", "fields[appStoreVersionLocalizations]": "locale,description,whatsNew,keywords,promotionalText,supportUrl,marketingUrl,appStoreVersion", "filter[locale]": "en-US,de-DE", + "include": "appStoreVersion", "limit": "75" ] let nextURL = optionalInputURL( diff --git a/Tests/ASCMCPTests/Workers/AppsWorkerReliabilityTests.swift b/Tests/ASCMCPTests/Workers/AppsWorkerReliabilityTests.swift index 666f8ef..4295da2 100644 --- a/Tests/ASCMCPTests/Workers/AppsWorkerReliabilityTests.swift +++ b/Tests/ASCMCPTests/Workers/AppsWorkerReliabilityTests.swift @@ -160,9 +160,11 @@ struct AppsWorkerReliabilityTests { func manifestRecordsFixedQueries() throws { let manifest = try ASCOperationManifestBundle.loadBundled() - #expect(try appsReliabilityFixedQueries(manifest, "apps_get_metadata", "appStoreVersions_getInstance")["fields[appStoreVersions]"] == .array([ + let metadataVersion = try appsReliabilityFixedQueries(manifest, "apps_get_metadata", "appStoreVersions_getInstance") + #expect(metadataVersion["fields[appStoreVersions]"] == .array([ .string("app"), .string("platform"), .string("versionString"), .string("appVersionState"), .string("appStoreState") ])) + #expect(metadataVersion["include"] == .array([.string("app")])) let versionCollection = try appsReliabilityFixedQueries(manifest, "apps_get_metadata", "apps_appStoreVersions_getToManyRelated") #expect(versionCollection["fields[appStoreVersions]"] == .array([ .string("platform"), .string("versionString"), .string("appVersionState"), .string("appStoreState"), .string("createdDate") @@ -174,6 +176,7 @@ struct AppsWorkerReliabilityTests { .string("description"), .string("locale"), .string("keywords"), .string("marketingUrl"), .string("promotionalText"), .string("supportUrl"), .string("whatsNew"), .string("appStoreVersion") ])) + #expect(localizations["include"] == .array([.string("appStoreVersion")])) #expect(localizations["limit"] == .integer(200)) let previews = try appsReliabilityFixedQueries(manifest, "apps_get_metadata", "appStoreVersionLocalizations_appPreviewSets_getToManyRelated") @@ -187,17 +190,25 @@ struct AppsWorkerReliabilityTests { #expect(screenshots["limit[appScreenshots]"] == .integer(50)) #expect(try appsReliabilityFixedQueries(manifest, "apps_list_versions", "apps_appStoreVersions_getToManyRelated") == versionCollection) - #expect(try appsReliabilityFixedQueries(manifest, "apps_list_localizations", "appStoreVersions_getInstance")["fields[appStoreVersions]"] == .array([.string("app")])) - #expect(try appsReliabilityFixedQueries(manifest, "apps_list_localizations", "appStoreVersions_appStoreVersionLocalizations_getToManyRelated")["fields[appStoreVersionLocalizations]"] == .array([ + let localizationVersion = try appsReliabilityFixedQueries(manifest, "apps_list_localizations", "appStoreVersions_getInstance") + #expect(localizationVersion["fields[appStoreVersions]"] == .array([.string("app")])) + #expect(localizationVersion["include"] == .array([.string("app")])) + let localizationList = try appsReliabilityFixedQueries(manifest, "apps_list_localizations", "appStoreVersions_appStoreVersionLocalizations_getToManyRelated") + #expect(localizationList["fields[appStoreVersionLocalizations]"] == .array([ .string("locale"), .string("description"), .string("whatsNew"), .string("keywords"), .string("promotionalText"), .string("supportUrl"), .string("marketingUrl"), .string("appStoreVersion") ])) - #expect(try appsReliabilityFixedQueries(manifest, "apps_update_metadata", "appStoreVersions_getInstance")["fields[appStoreVersions]"] == .array([ + #expect(localizationList["include"] == .array([.string("appStoreVersion")])) + let metadataUpdateVersion = try appsReliabilityFixedQueries(manifest, "apps_update_metadata", "appStoreVersions_getInstance") + #expect(metadataUpdateVersion["fields[appStoreVersions]"] == .array([ .string("app"), .string("platform"), .string("versionString"), .string("appVersionState"), .string("appStoreState") ])) - #expect(try appsReliabilityFixedQueries(manifest, "apps_update_metadata", "appStoreVersions_appStoreVersionLocalizations_getToManyRelated")["fields[appStoreVersionLocalizations]"] == .array([ + #expect(metadataUpdateVersion["include"] == .array([.string("app")])) + let metadataUpdateLocalization = try appsReliabilityFixedQueries(manifest, "apps_update_metadata", "appStoreVersions_appStoreVersionLocalizations_getToManyRelated") + #expect(metadataUpdateLocalization["fields[appStoreVersionLocalizations]"] == .array([ .string("locale"), .string("appStoreVersion") ])) + #expect(metadataUpdateLocalization["include"] == .array([.string("appStoreVersion")])) let metadataMapping = try #require(manifest.mapping(for: "apps_get_metadata")) let metadataFields = Set(metadataMapping.response.fields.map(\.outputField)) @@ -330,7 +341,9 @@ struct AppsWorkerReliabilityTests { #expect(appsReliabilityQueryValue(requests[0], "fields[appStoreVersions]") == "platform,versionString,appVersionState,appStoreState,createdDate") #expect(appsReliabilityQueryValue(requests[0], "limit") == "200") #expect(appsReliabilityQueryValue(requests[1], "fields[appStoreVersions]") == "app") + #expect(appsReliabilityQueryValue(requests[1], "include") == "app") #expect(appsReliabilityQueryValue(requests[2], "fields[appStoreVersionLocalizations]") == "locale,description,whatsNew,keywords,promotionalText,supportUrl,marketingUrl,appStoreVersion") + #expect(appsReliabilityQueryValue(requests[2], "include") == "appStoreVersion") #expect(appsReliabilityQueryValue(requests[2], "limit") == "200") } @@ -418,7 +431,7 @@ struct AppsWorkerReliabilityTests { } } - @Test("localization pagination rejects a missing ownership projection") + @Test("localization pagination rejects a missing ownership include") func localizationPaginationRequiresOwnershipProjection() async throws { let transport = TestHTTPTransport(responses: [ .init(statusCode: 200, body: versionResponse(id: "ver-1", appId: "app-1")) @@ -430,7 +443,7 @@ struct AppsWorkerReliabilityTests { arguments: [ "app_id": .string("app-1"), "version_id": .string("ver-1"), - "next_url": .string("https://api.example.test/v1/appStoreVersions/ver-1/appStoreVersionLocalizations?cursor=next") + "next_url": .string("https://api.example.test/v1/appStoreVersions/ver-1/appStoreVersionLocalizations?cursor=next&fields%5BappStoreVersionLocalizations%5D=locale%2Cdescription%2CwhatsNew%2Ckeywords%2CpromotionalText%2CsupportUrl%2CmarketingUrl%2CappStoreVersion&limit=200") ] )) @@ -465,6 +478,7 @@ struct AppsWorkerReliabilityTests { #expect(query.first(where: { $0.name == "limit" })?.value == "200") #expect(query.first(where: { $0.name == "fields[appStoreVersions]" })?.value?.contains("appVersionState") == true) #expect(appsReliabilityQueryValue(requests[2], "fields[appStoreVersionLocalizations]") == "description,locale,keywords,marketingUrl,promotionalText,supportUrl,whatsNew,appStoreVersion") + #expect(appsReliabilityQueryValue(requests[2], "include") == "appStoreVersion") #expect(appsReliabilityQueryValue(requests[2], "limit") == "200") let payload = try appsReliabilityObject(result) let selected = try #require(payload["version"] as? [String: Any]) @@ -475,7 +489,7 @@ struct AppsWorkerReliabilityTests { @Test("metadata without locale follows every localization page") func metadataFollowsEveryLocalizationPage() async throws { - let nextURL = "https://api.example.test/v1/appStoreVersions/ver-1/appStoreVersionLocalizations?cursor=page-2&fields%5BappStoreVersionLocalizations%5D=description%2Clocale%2Ckeywords%2CmarketingUrl%2CpromotionalText%2CsupportUrl%2CwhatsNew%2CappStoreVersion&limit=200" + let nextURL = "https://api.example.test/v1/appStoreVersions/ver-1/appStoreVersionLocalizations?cursor=page-2&fields%5BappStoreVersionLocalizations%5D=description%2Clocale%2Ckeywords%2CmarketingUrl%2CpromotionalText%2CsupportUrl%2CwhatsNew%2CappStoreVersion&include=appStoreVersion&limit=200" let transport = TestHTTPTransport(responses: [ .init(statusCode: 200, body: versionResponse(id: "ver-1", appId: "app-1")), .init(statusCode: 200, body: localizationPage( @@ -499,6 +513,9 @@ struct AppsWorkerReliabilityTests { #expect(result.isError != true) #expect(await transport.requestCount() == 3) + let requests = await transport.recordedRequests() + #expect(appsReliabilityQueryValue(requests[0], "include") == "app") + #expect(appsReliabilityQueryValue(requests[1], "include") == "appStoreVersion") let payload = try appsReliabilityObject(result) let localizations = try #require(payload["localizations"] as? [[String: Any]]) #expect(localizations.compactMap { $0["locale"] as? String } == ["en-US", "ja"]) @@ -710,8 +727,10 @@ struct AppsWorkerReliabilityTests { #expect(attributes["marketingUrl"] is NSNull) let requests = await transport.recordedRequests() #expect(appsReliabilityQueryValue(requests[0], "fields[appStoreVersions]") == "app,platform,versionString,appVersionState,appStoreState") + #expect(appsReliabilityQueryValue(requests[0], "include") == "app") #expect(appsReliabilityQueryValue(requests[1], "fields[appStoreVersionLocalizations]") == "locale,appStoreVersion") #expect(appsReliabilityQueryValue(requests[1], "filter[locale]") == "en-US") + #expect(appsReliabilityQueryValue(requests[1], "include") == "appStoreVersion") #expect(appsReliabilityQueryValue(requests[1], "limit") == "1") } @@ -774,6 +793,45 @@ struct AppsWorkerReliabilityTests { #expect(await transport.requestCount() == 1) } + @Test("metadata update fails closed when ownership linkage is absent") + func metadataUpdateRejectsMissingOwnershipLinkage() async throws { + let versionTransport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: #"{"data":{"type":"appStoreVersions","id":"ver-1","attributes":{"platform":"IOS","versionString":"1.0","appVersionState":"PREPARE_FOR_SUBMISSION"}}}"#) + ]) + let versionWorker = try await makeAppsReliabilityWorker(versionTransport) + let versionResult = try await versionWorker.handleTool(.init( + name: "apps_update_metadata", + arguments: [ + "app_id": .string("app-1"), + "version_id": .string("ver-1"), + "locale": .string("en-US"), + "keywords": .string("example") + ] + )) + + #expect(versionResult.isError == true) + #expect(await versionTransport.requestCount() == 1) + + let localizationTransport = TestHTTPTransport(responses: [ + .init(statusCode: 200, body: versionResponse(id: "ver-1", appId: "app-1")), + .init(statusCode: 200, body: #"{"data":[{"type":"appStoreVersionLocalizations","id":"loc-1","attributes":{"locale":"en-US"}}]}"#) + ]) + let localizationWorker = try await makeAppsReliabilityWorker(localizationTransport) + let localizationResult = try await localizationWorker.handleTool(.init( + name: "apps_update_metadata", + arguments: [ + "app_id": .string("app-1"), + "version_id": .string("ver-1"), + "locale": .string("en-US"), + "keywords": .string("example") + ] + )) + + #expect(localizationResult.isError == true) + #expect(await localizationTransport.requestCount() == 2) + #expect((await localizationTransport.recordedRequests()).allSatisfy { $0.httpMethod == "GET" }) + } + @Test("metadata update rejects mismatched localization identity before patch") func metadataUpdateRejectsMismatchedLocalizationIdentity() async throws { let cases = [ diff --git a/Tests/ASCMCPTests/Workers/CompletePaginationScopeTests.swift b/Tests/ASCMCPTests/Workers/CompletePaginationScopeTests.swift index efb4fc3..04e18f6 100644 --- a/Tests/ASCMCPTests/Workers/CompletePaginationScopeTests.swift +++ b/Tests/ASCMCPTests/Workers/CompletePaginationScopeTests.swift @@ -320,6 +320,7 @@ private func completePaginationFixtures() -> [CompletePaginationFixture] { ], requiredQuery: [ "fields[appStoreVersionLocalizations]": localizationFields, + "include": "appStoreVersion", "limit": "200" ] ), @@ -332,6 +333,7 @@ private func completePaginationFixtures() -> [CompletePaginationFixture] { ], requiredQuery: [ "fields[appStoreVersionLocalizations]": localizationFields, + "include": "appStoreVersion", "limit": "73", "filter[locale]": "en-US,ru-RU" ] @@ -348,6 +350,8 @@ private func completePaginationFixtures() -> [CompletePaginationFixture] { arguments: ["review_detail_id": .string("review-detail-1")], requiredQuery: [ "fields[appStoreReviewAttachments]": attachmentFields, + "fields[appStoreReviewDetails]": "appStoreVersion", + "include": "appStoreReviewDetail", "limit": "25" ] ), @@ -358,6 +362,8 @@ private func completePaginationFixtures() -> [CompletePaginationFixture] { ], requiredQuery: [ "fields[appStoreReviewAttachments]": attachmentFields, + "fields[appStoreReviewDetails]": "appStoreVersion", + "include": "appStoreReviewDetail", "limit": "73" ] ), diff --git a/Tests/ASCMCPTests/Workers/InternalPaginationScopeTests.swift b/Tests/ASCMCPTests/Workers/InternalPaginationScopeTests.swift index c59fb8e..d1f8760 100644 --- a/Tests/ASCMCPTests/Workers/InternalPaginationScopeTests.swift +++ b/Tests/ASCMCPTests/Workers/InternalPaginationScopeTests.swift @@ -191,7 +191,7 @@ private func internalPaginationFixtures() -> [InternalPaginationFixture] { name: "app_lifecycle_age_rating_app_infos", path: "/v1/apps/app-1/appInfos", wrongPath: "/v1/apps/app-2/appInfos", - queries: [["fields[appInfos]": "state,app", "limit": "200"]] + queries: [["fields[appInfos]": "state,app", "include": "app", "limit": "200"]] ), InternalPaginationFixture( name: "apps_metadata_previews", diff --git a/Tests/ASCMCPTests/Workers/MarketingOptionalInputContractTests.swift b/Tests/ASCMCPTests/Workers/MarketingOptionalInputContractTests.swift index e6a989f..b6b5629 100644 --- a/Tests/ASCMCPTests/Workers/MarketingOptionalInputContractTests.swift +++ b/Tests/ASCMCPTests/Workers/MarketingOptionalInputContractTests.swift @@ -228,8 +228,6 @@ struct MarketingOptionalInputContractTests { "screenshots_upload_preview": [include], "promoted_get": ["query:include:internalControl"], "promoted_list": [include], - "review_attachments_get": [include], - "review_attachments_list": [include], "review_attachments_upload": [include] ] @@ -241,7 +239,7 @@ struct MarketingOptionalInputContractTests { } return classifications.isEmpty ? nil : mapping.tool }) - #expect(expected.count == 38) + #expect(expected.count == 36) #expect(classifiedTools == Set(expected.keys)) for (tool, identities) in expected { diff --git a/Tests/ASCMCPTests/Workers/MarketingPaginationScopeTests.swift b/Tests/ASCMCPTests/Workers/MarketingPaginationScopeTests.swift index ce97ed7..6125339 100644 --- a/Tests/ASCMCPTests/Workers/MarketingPaginationScopeTests.swift +++ b/Tests/ASCMCPTests/Workers/MarketingPaginationScopeTests.swift @@ -599,6 +599,8 @@ private func marketingPaginationFixtures() -> [MarketingPaginationFixture] { wrongParentPath: "/v1/appStoreReviewDetails/review-detail-2/appStoreReviewAttachments", requiredQuery: [ "fields[appStoreReviewAttachments]": "fileSize,fileName,sourceFileChecksum,assetDeliveryState,appStoreReviewDetail", + "fields[appStoreReviewDetails]": "appStoreVersion", + "include": "appStoreReviewDetail", "limit": "42" ] ) diff --git a/Tests/ASCMCPTests/Workers/ReviewAttachmentsV319SafetyContractTests.swift b/Tests/ASCMCPTests/Workers/ReviewAttachmentsV319SafetyContractTests.swift index a6e1bbd..fa29ab1 100644 --- a/Tests/ASCMCPTests/Workers/ReviewAttachmentsV319SafetyContractTests.swift +++ b/Tests/ASCMCPTests/Workers/ReviewAttachmentsV319SafetyContractTests.swift @@ -731,10 +731,10 @@ private func reviewAttachmentsV319CollectionResponse( selfLimit: Int = 25 ) -> String { let next = nextBaseURL.map { - #", "next":"\#($0)/v1/appStoreReviewDetails/review-detail-1/appStoreReviewAttachments?fields%5BappStoreReviewAttachments%5D=\#(reviewAttachmentsV319EncodedReadFields)&limit=25&cursor=next""# + #", "next":"\#($0)/v1/appStoreReviewDetails/review-detail-1/appStoreReviewAttachments?fields%5BappStoreReviewAttachments%5D=\#(reviewAttachmentsV319EncodedReadFields)&fields%5BappStoreReviewDetails%5D=appStoreVersion&include=appStoreReviewDetail&limit=25&cursor=next""# } ?? "" let links = includeLinks - ? #", "links":{"self":"\#(selfBaseURL)/v1/appStoreReviewDetails/\#(selfReviewDetailID)/appStoreReviewAttachments?fields%5BappStoreReviewAttachments%5D=\#(reviewAttachmentsV319EncodedReadFields)&limit=\#(selfLimit)"\#(next)}"# + ? #", "links":{"self":"\#(selfBaseURL)/v1/appStoreReviewDetails/\#(selfReviewDetailID)/appStoreReviewAttachments?fields%5BappStoreReviewAttachments%5D=\#(reviewAttachmentsV319EncodedReadFields)&fields%5BappStoreReviewDetails%5D=appStoreVersion&include=appStoreReviewDetail&limit=\#(selfLimit)"\#(next)}"# : "" let nextCursor = nextBaseURL == nil ? "" : #", "nextCursor":"next""# return #"{"data":[{"type":"appStoreReviewAttachments","id":"attachment-1","attributes":{"fileSize":5,"fileName":"attachment.bin","sourceFileChecksum":"5d41402abc4b2a76b9719d911017c592","assetDeliveryState":{"state":"COMPLETE"}},"relationships":{"appStoreReviewDetail":{"data":{"type":"appStoreReviewDetails","id":"\#(reviewDetailID)"}}}}]\#(links),"meta":{"paging":{"total":1,"limit":25\#(nextCursor)}}}"# @@ -743,7 +743,7 @@ private func reviewAttachmentsV319CollectionResponse( private let reviewAttachmentsV319EncodedReadFields = "fileSize%2CfileName%2CsourceFileChecksum%2CassetDeliveryState%2CappStoreReviewDetail" private let reviewAttachmentsV319ReadQuery = - "fields%5BappStoreReviewAttachments%5D=\(reviewAttachmentsV319EncodedReadFields)" + "fields%5BappStoreReviewAttachments%5D=\(reviewAttachmentsV319EncodedReadFields)&fields%5BappStoreReviewDetails%5D=appStoreVersion&include=appStoreReviewDetail" private func reviewAttachmentsV319Object(_ value: Value?) throws -> [String: Value] { guard case .object(let object) = value else { diff --git a/Tests/ASCMCPTests/Workers/ReviewAttachmentsWorkerReliabilityTests.swift b/Tests/ASCMCPTests/Workers/ReviewAttachmentsWorkerReliabilityTests.swift index a6dba80..4b966fe 100644 --- a/Tests/ASCMCPTests/Workers/ReviewAttachmentsWorkerReliabilityTests.swift +++ b/Tests/ASCMCPTests/Workers/ReviewAttachmentsWorkerReliabilityTests.swift @@ -37,7 +37,8 @@ struct ReviewAttachmentsWorkerReliabilityTests { #expect(request.url?.path == "/v1/appStoreReviewAttachments/attachment-1") let query = reviewAttachmentsQuery(request) #expect(query["fields[appStoreReviewAttachments]"] == reviewAttachmentsReadFields) - #expect(query["include"] == nil) + #expect(query["fields[appStoreReviewDetails]"] == "appStoreVersion") + #expect(query["include"] == "appStoreReviewDetail") let root = try reviewAttachmentsObject(result.structuredContent) let attachment = try reviewAttachmentsObject(root["attachment"]) #expect(attachment["appStoreReviewDetailId"] == .string("review-detail-1")) @@ -60,8 +61,9 @@ struct ReviewAttachmentsWorkerReliabilityTests { let request = try #require(await transport.recordedRequests().first) let query = reviewAttachmentsQuery(request) #expect(query["fields[appStoreReviewAttachments]"] == reviewAttachmentsReadFields) + #expect(query["fields[appStoreReviewDetails]"] == "appStoreVersion") #expect(query["limit"] == "25") - #expect(query["include"] == nil) + #expect(query["include"] == "appStoreReviewDetail") let root = try reviewAttachmentsObject(result.structuredContent) #expect(root["count"] == .int(1)) #expect(root["total"] == .int(41)) @@ -108,6 +110,8 @@ struct ReviewAttachmentsWorkerReliabilityTests { let request = try #require(await transport.recordedRequests().first) let query = reviewAttachmentsQuery(request) #expect(query["fields[appStoreReviewAttachments]"] == reviewAttachmentsReadFields) + #expect(query["fields[appStoreReviewDetails]"] == "appStoreVersion") + #expect(query["include"] == "appStoreReviewDetail") #expect(query["limit"] == "73") #expect(query["cursor"] == "next") } @@ -195,7 +199,7 @@ struct ReviewAttachmentsWorkerReliabilityTests { #expect(collection.meta?.paging?.total == 41) } - @Test("manifest records three include omissions and fixed safe projections") + @Test("manifest records fixed lineage includes and safe projections") func manifestRecordsReadInvocations() throws { let manifest = try ASCOperationManifestBundle.loadBundled() let readInvocations = [ @@ -203,13 +207,7 @@ struct ReviewAttachmentsWorkerReliabilityTests { try #require(manifest.mapping(for: "review_attachments_list")?.operations.first) ] - var reasons: Set = [] for invocation in readInvocations { - let include = try #require(invocation.optionalParameterClassifications?.first { - $0.location == "query" && $0.appleName == "include" - }) - #expect(include.disposition == .intentionallyOmitted) - reasons.insert(include.reason) let fields = try #require(invocation.inputs?.first { $0.sourceKind == .fixed && $0.location == "query" @@ -218,6 +216,18 @@ struct ReviewAttachmentsWorkerReliabilityTests { #expect(fields == .array(reviewAttachmentsReadFields.split(separator: ",").map { .string(String($0)) })) + let include = try #require(invocation.inputs?.first { + $0.sourceKind == .fixed + && $0.location == "query" + && $0.appleName == "include" + }?.fixedValue) + #expect(include == .array([.string("appStoreReviewDetail")])) + let reviewDetailFields = try #require(invocation.inputs?.first { + $0.sourceKind == .fixed + && $0.location == "query" + && $0.appleName == "fields[appStoreReviewDetails]" + }?.fixedValue) + #expect(reviewDetailFields == .array([.string("appStoreVersion")])) } let reconciliation = try #require(manifest.mapping(for: "review_attachments_upload")?.operations.first { @@ -230,8 +240,6 @@ struct ReviewAttachmentsWorkerReliabilityTests { #expect(reconciliation.inputs?.contains { $0.location == "query" && $0.appleName == "fields[appStoreReviewAttachments]" } != true) - reasons.insert(reconciliationInclude.reason) - #expect(reasons.count == 3) let relationshipWaiver = try #require(manifest.index.waivers.first { $0.operationID == "appStoreReviewDetails_appStoreReviewAttachments_getToManyRelationship" @@ -290,6 +298,8 @@ private func reviewAttachmentsNextURL(fields: String, limit: Int) -> String { components.queryItems = [ URLQueryItem(name: "cursor", value: "next"), URLQueryItem(name: "fields[appStoreReviewAttachments]", value: fields), + URLQueryItem(name: "fields[appStoreReviewDetails]", value: "appStoreVersion"), + URLQueryItem(name: "include", value: "appStoreReviewDetail"), URLQueryItem(name: "limit", value: String(limit)) ] return components.url!.absoluteString @@ -314,7 +324,7 @@ private func reviewAttachmentsSingleResponse() -> String { } }, "links": { - "self": "https://api.example.test/v1/appStoreReviewAttachments/attachment-1?fields%5BappStoreReviewAttachments%5D=fileSize,fileName,sourceFileChecksum,assetDeliveryState,appStoreReviewDetail" + "self": "https://api.example.test/v1/appStoreReviewAttachments/attachment-1?fields%5BappStoreReviewAttachments%5D=fileSize,fileName,sourceFileChecksum,assetDeliveryState,appStoreReviewDetail&fields%5BappStoreReviewDetails%5D=appStoreVersion&include=appStoreReviewDetail" } } """ @@ -341,7 +351,7 @@ private func reviewAttachmentsCollectionResponse() -> String { } ], "links": { - "self": "https://api.example.test/v1/appStoreReviewDetails/review-detail-1/appStoreReviewAttachments?fields%5BappStoreReviewAttachments%5D=fileSize,fileName,sourceFileChecksum,assetDeliveryState,appStoreReviewDetail&limit=25" + "self": "https://api.example.test/v1/appStoreReviewDetails/review-detail-1/appStoreReviewAttachments?fields%5BappStoreReviewAttachments%5D=fileSize,fileName,sourceFileChecksum,assetDeliveryState,appStoreReviewDetail&fields%5BappStoreReviewDetails%5D=appStoreVersion&include=appStoreReviewDetail&limit=25" }, "meta": {"paging": {"total": 41, "limit": 25}} } @@ -353,7 +363,7 @@ private func reviewAttachmentsEmptyCollectionResponse() -> String { { "data": [], "links": { - "self": "https://api.example.test/v1/appStoreReviewDetails/review-detail-1/appStoreReviewAttachments?cursor=next&fields%5BappStoreReviewAttachments%5D=fileSize,fileName,sourceFileChecksum,assetDeliveryState,appStoreReviewDetail&limit=73" + "self": "https://api.example.test/v1/appStoreReviewDetails/review-detail-1/appStoreReviewAttachments?cursor=next&fields%5BappStoreReviewAttachments%5D=fileSize,fileName,sourceFileChecksum,assetDeliveryState,appStoreReviewDetail&fields%5BappStoreReviewDetails%5D=appStoreVersion&include=appStoreReviewDetail&limit=73" }, "meta": {"paging": {"total": 0, "limit": 73}} } diff --git a/Tests/ASCMCPTests/Workers/ReviewSubmissionsWorkerContractTests.swift b/Tests/ASCMCPTests/Workers/ReviewSubmissionsWorkerContractTests.swift index da15c46..0946cf5 100644 --- a/Tests/ASCMCPTests/Workers/ReviewSubmissionsWorkerContractTests.swift +++ b/Tests/ASCMCPTests/Workers/ReviewSubmissionsWorkerContractTests.swift @@ -178,7 +178,7 @@ struct ReviewSubmissionsWorkerContractTests { appID: "app-1", states: "READY_FOR_REVIEW,UNRESOLVED_ISSUES", platforms: "IOS,MAC_OS", - includes: "items,app,submittedByActor", + includes: "app,items,submittedByActor", itemLimit: 17, limit: 125 ).merging(["cursor": "next-page"]) { _, new in new } @@ -194,7 +194,7 @@ struct ReviewSubmissionsWorkerContractTests { "app_id": .string("app-1"), "states": .string("READY_FOR_REVIEW, UNRESOLVED_ISSUES"), "platforms": .array([.string("IOS"), .string("MAC_OS")]), - "include": .array([.string("items"), .string("app"), .string("submittedByActor")]), + "include": .array([.string("items"), .string("submittedByActor")]), "item_limit": .int(17), "limit": .int(125) ] @@ -208,7 +208,7 @@ struct ReviewSubmissionsWorkerContractTests { appID: "app-1", states: "READY_FOR_REVIEW,UNRESOLVED_ISSUES", platforms: "IOS,MAC_OS", - includes: "items,app,submittedByActor", + includes: "app,items,submittedByActor", itemLimit: 17, limit: 125 )) @@ -1514,7 +1514,7 @@ struct ReviewSubmissionsWorkerContractTests { appID: "app-1", states: "READY_FOR_REVIEW,IN_REVIEW", platforms: "IOS,MAC_OS", - includes: "items,submittedByActor", + includes: "app,items,submittedByActor", itemLimit: 18, limit: 90 ) From 123d79dc12d01b16b1f34ac7a8da3b3f046c9066 Mon Sep 17 00:00:00 2001 From: Aleksei Zelentsov Date: Tue, 21 Jul 2026 17:21:40 +0300 Subject: [PATCH 2/2] fix(tests): align relationship classification counts --- .../Workers/AppsVersionsOptionalInputContractTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/ASCMCPTests/Workers/AppsVersionsOptionalInputContractTests.swift b/Tests/ASCMCPTests/Workers/AppsVersionsOptionalInputContractTests.swift index 3e0c068..b44501c 100644 --- a/Tests/ASCMCPTests/Workers/AppsVersionsOptionalInputContractTests.swift +++ b/Tests/ASCMCPTests/Workers/AppsVersionsOptionalInputContractTests.swift @@ -296,9 +296,9 @@ struct AppsVersionsOptionalInputContractTests { mapping.operations.flatMap { $0.optionalParameterClassifications ?? [] } } - #expect(classifications.count == 129) + #expect(classifications.count == 119) #expect(classifications.filter { $0.disposition == .internalControl }.count == 1) - #expect(classifications.filter { $0.disposition == .intentionallyOmitted }.count == 128) + #expect(classifications.filter { $0.disposition == .intentionallyOmitted }.count == 118) #expect(classifications.allSatisfy { $0.reviewAtSpec == "4.4.1" && !$0.reason.isEmpty }) let expectedBindings: [(String, String, String?, String?)] = [