gowalk-cicd 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +94 -0
- package/README.md +417 -0
- package/action/.daemux-version +1 -0
- package/action/MIGRATION.md +232 -0
- package/action/action.yml +973 -0
- package/action/prompts/generate_dependent_fields.prompt.yml +78 -0
- package/action/prompts/generate_descriptions.prompt.yml +59 -0
- package/action/scripts/app_context_scanner.py +239 -0
- package/action/scripts/asc_build_history.py +284 -0
- package/action/scripts/asc_common.py +246 -0
- package/action/scripts/asc_metadata_applier.py +368 -0
- package/action/scripts/asc_metadata_detector.py +250 -0
- package/action/scripts/asc_version_create.py +365 -0
- package/action/scripts/asc_version_fetch.py +274 -0
- package/action/scripts/asc_version_reuse.py +177 -0
- package/action/scripts/auto_detect.py +391 -0
- package/action/scripts/autoupdate_check.sh +118 -0
- package/action/scripts/cert_factory.py +190 -0
- package/action/scripts/cfg_io.py +27 -0
- package/action/scripts/cfg_resolve.py +147 -0
- package/action/scripts/commit_bot_changes.sh +82 -0
- package/action/scripts/creds_store.py +328 -0
- package/action/scripts/keychain.py +103 -0
- package/action/scripts/lookup_app_id.py +51 -0
- package/action/scripts/manage_marketing_version.py +337 -0
- package/action/scripts/metadata_constants.py +102 -0
- package/action/scripts/mmv_decide_create.py +181 -0
- package/action/scripts/mmv_floor_check.py +265 -0
- package/action/scripts/next_build_number.py +195 -0
- package/action/scripts/pbxproj_editor.py +220 -0
- package/action/scripts/prepare_signing.py +223 -0
- package/action/scripts/profile_io.py +64 -0
- package/action/scripts/profile_manager.py +307 -0
- package/action/scripts/read_config.py +315 -0
- package/action/scripts/resolve_marketing_version.py +173 -0
- package/action/scripts/set_app_store_whats_new.py +317 -0
- package/action/scripts/team_resolver.py +130 -0
- package/action/scripts/test_app_context_scanner.py +167 -0
- package/action/scripts/test_asc_build_history.py +221 -0
- package/action/scripts/test_asc_builds_prerelease.py +219 -0
- package/action/scripts/test_asc_common_timeouts.py +121 -0
- package/action/scripts/test_asc_metadata_applier.py +850 -0
- package/action/scripts/test_asc_metadata_detector.py +445 -0
- package/action/scripts/test_auto_detect.py +432 -0
- package/action/scripts/test_cfg_resolve_credentials.py +48 -0
- package/action/scripts/test_compute_next_version.py +95 -0
- package/action/scripts/test_compute_next_version_patch_backcompat.py +41 -0
- package/action/scripts/test_compute_next_version_strictness.py +67 -0
- package/action/scripts/test_fetch_versions.py +163 -0
- package/action/scripts/test_ground_truth_floor.py +104 -0
- package/action/scripts/test_manage_marketing_version.py +134 -0
- package/action/scripts/test_manage_marketing_version_autoroll.py +149 -0
- package/action/scripts/test_manage_marketing_version_floor.py +170 -0
- package/action/scripts/test_manage_marketing_version_match.py +187 -0
- package/action/scripts/test_mmv_409_classifier.py +231 -0
- package/action/scripts/test_mmv_autobump_persist_gate.py +131 -0
- package/action/scripts/test_mmv_decide_create.py +119 -0
- package/action/scripts/test_mmv_floor_check.py +158 -0
- package/action/scripts/test_mmv_floor_crosscheck.py +132 -0
- package/action/scripts/test_mmv_helpers.py +57 -0
- package/action/scripts/test_next_build_number.py +174 -0
- package/action/scripts/test_read_config_auto_detect.py +257 -0
- package/action/scripts/test_resolve_marketing_version.py +298 -0
- package/action/scripts/test_reuse_stale_editable.py +182 -0
- package/action/scripts/test_set_app_store_whats_new.py +71 -0
- package/action/scripts/test_team_fallback_wiring.py +157 -0
- package/action/scripts/test_team_resolver.py +249 -0
- package/action/scripts/tests_common.py +167 -0
- package/action/scripts/version_utils.py +373 -0
- package/android-action/.daemux-version +1 -0
- package/android-action/action.yml +92 -0
- package/android-action/scripts/android_config.py +130 -0
- package/android-action/scripts/bitrise_deploy.py +160 -0
- package/android-action/scripts/find_bundle.py +25 -0
- package/android-action/scripts/play_preflight.py +69 -0
- package/android-action/scripts/resolve_android.py +70 -0
- package/android-action/scripts/sign_bundle.py +70 -0
- package/android-action/scripts/test_android_config.py +97 -0
- package/android-action/scripts/test_bitrise_deploy.py +124 -0
- package/android-action/scripts/test_sign_bundle.py +82 -0
- package/bin/cli.mjs +63 -0
- package/package.json +57 -0
- package/src/install.mjs +208 -0
- package/templates/deploy.yml +150 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Tests for asc_metadata_detector.py.
|
|
4
|
+
|
|
5
|
+
All HTTP is stubbed via unittest.mock; no network calls. Focus:
|
|
6
|
+
* empty_fields correctly excludes URL fields (SKIP_URL_FIELDS).
|
|
7
|
+
* locale union across appInfo + version resources.
|
|
8
|
+
* missing resource id for a locale skips its fields without crashing.
|
|
9
|
+
* no editable appInfo -> empty result, exit 0 (not an error).
|
|
10
|
+
* detector runtime error -> fail-open JSON on stdout, exit 0.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import io
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
import unittest
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from unittest import mock
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
23
|
+
|
|
24
|
+
import asc_metadata_detector as detector # noqa: E402
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _app_info_payload(state: str) -> dict:
|
|
28
|
+
return {"data": [{"id": "APP_INFO_1", "attributes": {"appStoreState": state}}]}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _loc_item(loc_id: str, locale: str, **attrs) -> dict:
|
|
32
|
+
return {"id": loc_id, "attributes": {"locale": locale, **attrs}}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FetchEditableAppInfoTests(unittest.TestCase):
|
|
36
|
+
def test_returns_editable_record(self):
|
|
37
|
+
with mock.patch.object(
|
|
38
|
+
detector, "get_json", return_value=_app_info_payload("PREPARE_FOR_SUBMISSION")
|
|
39
|
+
):
|
|
40
|
+
got = detector.fetch_editable_app_info("111", "tok")
|
|
41
|
+
self.assertEqual(got, {"id": "APP_INFO_1", "state": "PREPARE_FOR_SUBMISSION"})
|
|
42
|
+
|
|
43
|
+
def test_returns_none_when_no_editable_state(self):
|
|
44
|
+
with mock.patch.object(
|
|
45
|
+
detector, "get_json", return_value=_app_info_payload("READY_FOR_SALE")
|
|
46
|
+
):
|
|
47
|
+
got = detector.fetch_editable_app_info("111", "tok")
|
|
48
|
+
self.assertIsNone(got)
|
|
49
|
+
|
|
50
|
+
def test_returns_none_for_empty_response(self):
|
|
51
|
+
with mock.patch.object(detector, "get_json", return_value={"data": []}):
|
|
52
|
+
got = detector.fetch_editable_app_info("111", "tok")
|
|
53
|
+
self.assertIsNone(got)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BuildStateTests(unittest.TestCase):
|
|
57
|
+
def test_unions_locales_across_resources(self):
|
|
58
|
+
app_locs = {
|
|
59
|
+
"en-US": {"id": "A1", "name": "MyApp", "subtitle": None,
|
|
60
|
+
"privacyPolicyUrl": None, "privacyChoicesUrl": None},
|
|
61
|
+
"ja": {"id": "A2", "name": None, "subtitle": None,
|
|
62
|
+
"privacyPolicyUrl": None, "privacyChoicesUrl": None},
|
|
63
|
+
}
|
|
64
|
+
ver_locs = {
|
|
65
|
+
"en-US": {"id": "V1", "description": "desc", "keywords": None,
|
|
66
|
+
"promotionalText": None, "whatsNew": None,
|
|
67
|
+
"supportUrl": None, "marketingUrl": None},
|
|
68
|
+
"de-DE": {"id": "V2", "description": None, "keywords": None,
|
|
69
|
+
"promotionalText": None, "whatsNew": None,
|
|
70
|
+
"supportUrl": None, "marketingUrl": None},
|
|
71
|
+
}
|
|
72
|
+
state = detector.build_state("APP1", "VER1", app_locs, ver_locs)
|
|
73
|
+
self.assertEqual(state["locales"], ["de-DE", "en-US", "ja"])
|
|
74
|
+
self.assertEqual(state["app_info_id"], "APP1")
|
|
75
|
+
self.assertEqual(state["version_id"], "VER1")
|
|
76
|
+
|
|
77
|
+
self.assertEqual(
|
|
78
|
+
state["localizations"]["en-US"]["app_info_localization_id"], "A1"
|
|
79
|
+
)
|
|
80
|
+
self.assertEqual(
|
|
81
|
+
state["localizations"]["en-US"]["version_localization_id"], "V1"
|
|
82
|
+
)
|
|
83
|
+
# de-DE has no appInfo entry -> id is None
|
|
84
|
+
self.assertIsNone(
|
|
85
|
+
state["localizations"]["de-DE"]["app_info_localization_id"]
|
|
86
|
+
)
|
|
87
|
+
self.assertEqual(
|
|
88
|
+
state["localizations"]["de-DE"]["version_localization_id"], "V2"
|
|
89
|
+
)
|
|
90
|
+
# ja has no version entry -> id is None
|
|
91
|
+
self.assertIsNone(
|
|
92
|
+
state["localizations"]["ja"]["version_localization_id"]
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class DiffEmptyFieldsTests(unittest.TestCase):
|
|
97
|
+
def _state(self, fields_en: dict, **kwargs) -> dict:
|
|
98
|
+
defaults = {
|
|
99
|
+
"app_info_localization_id": "A1",
|
|
100
|
+
"version_localization_id": "V1",
|
|
101
|
+
}
|
|
102
|
+
defaults.update(kwargs)
|
|
103
|
+
return {
|
|
104
|
+
"localizations": {
|
|
105
|
+
"en-US": {**defaults, "fields": fields_en},
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
def test_empty_string_and_whitespace_treated_as_empty(self):
|
|
110
|
+
state = self._state({
|
|
111
|
+
"name": "", "subtitle": " ",
|
|
112
|
+
"description": None, "keywords": "kw",
|
|
113
|
+
"promotionalText": None, "whatsNew": "notes",
|
|
114
|
+
})
|
|
115
|
+
self.assertEqual(
|
|
116
|
+
sorted(detector.diff_empty_fields(state)["en-US"]),
|
|
117
|
+
["description", "name", "promotionalText", "subtitle"],
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def test_url_fields_never_reported_empty(self):
|
|
121
|
+
# supportUrl/marketingUrl/privacyPolicyUrl even when None must NOT
|
|
122
|
+
# appear in the diff -- they are in SKIP_URL_FIELDS.
|
|
123
|
+
state = self._state({
|
|
124
|
+
"name": "X", "subtitle": "Y",
|
|
125
|
+
"description": "d", "keywords": "k",
|
|
126
|
+
"promotionalText": "p", "whatsNew": "w",
|
|
127
|
+
"supportUrl": None, "marketingUrl": "",
|
|
128
|
+
"privacyPolicyUrl": None,
|
|
129
|
+
})
|
|
130
|
+
self.assertEqual(detector.diff_empty_fields(state), {})
|
|
131
|
+
|
|
132
|
+
def test_skips_app_level_when_app_info_localization_id_missing(self):
|
|
133
|
+
state = self._state(
|
|
134
|
+
{"name": None, "subtitle": None,
|
|
135
|
+
"description": None, "keywords": None,
|
|
136
|
+
"promotionalText": None, "whatsNew": None},
|
|
137
|
+
app_info_localization_id=None,
|
|
138
|
+
)
|
|
139
|
+
# name/subtitle must be skipped because app_info_localization_id is None
|
|
140
|
+
self.assertEqual(
|
|
141
|
+
sorted(detector.diff_empty_fields(state)["en-US"]),
|
|
142
|
+
["description", "keywords", "promotionalText", "whatsNew"],
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def test_skips_version_level_when_version_localization_id_missing(self):
|
|
146
|
+
state = self._state(
|
|
147
|
+
{"name": None, "subtitle": None,
|
|
148
|
+
"description": None, "keywords": None,
|
|
149
|
+
"promotionalText": None, "whatsNew": None},
|
|
150
|
+
version_localization_id=None,
|
|
151
|
+
)
|
|
152
|
+
self.assertEqual(
|
|
153
|
+
sorted(detector.diff_empty_fields(state)["en-US"]),
|
|
154
|
+
["name", "subtitle"],
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def test_fully_populated_returns_empty_dict(self):
|
|
158
|
+
state = self._state({
|
|
159
|
+
"name": "X", "subtitle": "Y",
|
|
160
|
+
"description": "d", "keywords": "k",
|
|
161
|
+
"promotionalText": "p", "whatsNew": "w",
|
|
162
|
+
})
|
|
163
|
+
self.assertEqual(detector.diff_empty_fields(state), {})
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class ExtractExistingFieldsTests(unittest.TestCase):
|
|
167
|
+
"""Phase-2 orchestration reads ``existing_fields`` as authoritative ASC
|
|
168
|
+
context (description, etc.) -- must be populated for every locale with
|
|
169
|
+
all 6 non-URL fields, regardless of which are empty."""
|
|
170
|
+
|
|
171
|
+
NON_URL = ("name", "subtitle", "description", "keywords",
|
|
172
|
+
"promotionalText", "whatsNew")
|
|
173
|
+
|
|
174
|
+
def _state(self, fields_en: dict, fields_de: dict | None = None) -> dict:
|
|
175
|
+
localizations = {
|
|
176
|
+
"en-US": {
|
|
177
|
+
"app_info_localization_id": "A1",
|
|
178
|
+
"version_localization_id": "V1",
|
|
179
|
+
"fields": fields_en,
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
if fields_de is not None:
|
|
183
|
+
localizations["de-DE"] = {
|
|
184
|
+
"app_info_localization_id": "A2",
|
|
185
|
+
"version_localization_id": "V2",
|
|
186
|
+
"fields": fields_de,
|
|
187
|
+
}
|
|
188
|
+
return {"localizations": localizations}
|
|
189
|
+
|
|
190
|
+
def test_contains_every_non_url_field_per_locale(self):
|
|
191
|
+
state = self._state({
|
|
192
|
+
"name": "MyApp", "subtitle": "Cool",
|
|
193
|
+
"description": "A desc", "keywords": "k1,k2",
|
|
194
|
+
"promotionalText": "promo", "whatsNew": "news",
|
|
195
|
+
})
|
|
196
|
+
existing = detector.extract_existing_fields(state)
|
|
197
|
+
self.assertEqual(set(existing), {"en-US"})
|
|
198
|
+
self.assertEqual(set(existing["en-US"].keys()), set(self.NON_URL))
|
|
199
|
+
|
|
200
|
+
def test_preserves_populated_asc_values(self):
|
|
201
|
+
state = self._state({
|
|
202
|
+
"name": "MyApp", "subtitle": "Cool",
|
|
203
|
+
"description": "Great app!", "keywords": "k1,k2",
|
|
204
|
+
"promotionalText": "promo text", "whatsNew": "v1 notes",
|
|
205
|
+
})
|
|
206
|
+
existing = detector.extract_existing_fields(state)["en-US"]
|
|
207
|
+
self.assertEqual(existing["name"], "MyApp")
|
|
208
|
+
self.assertEqual(existing["subtitle"], "Cool")
|
|
209
|
+
self.assertEqual(existing["description"], "Great app!")
|
|
210
|
+
self.assertEqual(existing["keywords"], "k1,k2")
|
|
211
|
+
self.assertEqual(existing["promotionalText"], "promo text")
|
|
212
|
+
self.assertEqual(existing["whatsNew"], "v1 notes")
|
|
213
|
+
|
|
214
|
+
def test_normalizes_missing_or_none_to_empty_string(self):
|
|
215
|
+
state = self._state({
|
|
216
|
+
"name": None, "subtitle": "",
|
|
217
|
+
"description": None, "keywords": "",
|
|
218
|
+
# promotionalText omitted entirely
|
|
219
|
+
"whatsNew": None,
|
|
220
|
+
})
|
|
221
|
+
existing = detector.extract_existing_fields(state)["en-US"]
|
|
222
|
+
self.assertEqual(existing["name"], "")
|
|
223
|
+
self.assertEqual(existing["subtitle"], "")
|
|
224
|
+
self.assertEqual(existing["description"], "")
|
|
225
|
+
self.assertEqual(existing["keywords"], "")
|
|
226
|
+
self.assertEqual(existing["promotionalText"], "")
|
|
227
|
+
self.assertEqual(existing["whatsNew"], "")
|
|
228
|
+
|
|
229
|
+
def test_excludes_url_fields(self):
|
|
230
|
+
state = self._state({
|
|
231
|
+
"name": "MyApp", "subtitle": "Cool",
|
|
232
|
+
"description": "d", "keywords": "k",
|
|
233
|
+
"promotionalText": "p", "whatsNew": "w",
|
|
234
|
+
"privacyPolicyUrl": "https://x", "privacyChoicesUrl": "https://y",
|
|
235
|
+
"supportUrl": "https://s", "marketingUrl": "https://m",
|
|
236
|
+
})
|
|
237
|
+
existing = detector.extract_existing_fields(state)["en-US"]
|
|
238
|
+
self.assertNotIn("privacyPolicyUrl", existing)
|
|
239
|
+
self.assertNotIn("privacyChoicesUrl", existing)
|
|
240
|
+
self.assertNotIn("supportUrl", existing)
|
|
241
|
+
self.assertNotIn("marketingUrl", existing)
|
|
242
|
+
|
|
243
|
+
def test_populates_all_locales(self):
|
|
244
|
+
state = self._state(
|
|
245
|
+
{"name": "EN", "subtitle": "S", "description": "D",
|
|
246
|
+
"keywords": "K", "promotionalText": "P", "whatsNew": "W"},
|
|
247
|
+
{"name": "DE", "subtitle": "", "description": "DE desc",
|
|
248
|
+
"keywords": "", "promotionalText": "", "whatsNew": ""},
|
|
249
|
+
)
|
|
250
|
+
existing = detector.extract_existing_fields(state)
|
|
251
|
+
self.assertEqual(set(existing), {"en-US", "de-DE"})
|
|
252
|
+
self.assertEqual(existing["de-DE"]["name"], "DE")
|
|
253
|
+
self.assertEqual(existing["de-DE"]["description"], "DE desc")
|
|
254
|
+
self.assertEqual(existing["de-DE"]["subtitle"], "")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class MainTests(unittest.TestCase):
|
|
258
|
+
def _env(self) -> dict:
|
|
259
|
+
return {
|
|
260
|
+
"ASC_KEY_ID": "k",
|
|
261
|
+
"ASC_ISSUER_ID": "i",
|
|
262
|
+
"ASC_KEY_PATH": "/tmp/key.p8",
|
|
263
|
+
"APP_STORE_APPLE_ID": "111",
|
|
264
|
+
"APP_STORE_VERSION_ID": "222",
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
def test_no_editable_app_info_emits_empty_fields(self):
|
|
268
|
+
stdout = io.StringIO()
|
|
269
|
+
with mock.patch.dict("os.environ", self._env(), clear=True), \
|
|
270
|
+
mock.patch.object(detector, "make_jwt", return_value="tok"), \
|
|
271
|
+
mock.patch.object(
|
|
272
|
+
detector, "get_json",
|
|
273
|
+
return_value=_app_info_payload("READY_FOR_SALE"),
|
|
274
|
+
), \
|
|
275
|
+
mock.patch.object(sys, "stdout", stdout):
|
|
276
|
+
rc = detector.main()
|
|
277
|
+
self.assertEqual(rc, 0)
|
|
278
|
+
payload = json.loads(stdout.getvalue())
|
|
279
|
+
self.assertEqual(payload, {"empty_fields": {}})
|
|
280
|
+
|
|
281
|
+
def test_runtime_error_is_non_fatal(self):
|
|
282
|
+
stdout = io.StringIO()
|
|
283
|
+
with mock.patch.dict("os.environ", self._env(), clear=True), \
|
|
284
|
+
mock.patch.object(detector, "make_jwt", side_effect=RuntimeError("boom")), \
|
|
285
|
+
mock.patch.object(sys, "stdout", stdout):
|
|
286
|
+
rc = detector.main()
|
|
287
|
+
self.assertEqual(rc, 0)
|
|
288
|
+
payload = json.loads(stdout.getvalue())
|
|
289
|
+
self.assertEqual(payload, {"empty_fields": {}})
|
|
290
|
+
|
|
291
|
+
def test_happy_path_emits_full_state(self):
|
|
292
|
+
env = self._env()
|
|
293
|
+
|
|
294
|
+
def fake_get_json(path, token, params=None):
|
|
295
|
+
if path.endswith("/appInfos"):
|
|
296
|
+
return _app_info_payload("PREPARE_FOR_SUBMISSION")
|
|
297
|
+
if "/appInfoLocalizations" in path:
|
|
298
|
+
return {"data": [
|
|
299
|
+
_loc_item("A1", "en-US", name="MyApp", subtitle=None,
|
|
300
|
+
privacyPolicyUrl=None, privacyChoicesUrl=None),
|
|
301
|
+
_loc_item("A2", "ja", name=None, subtitle=None,
|
|
302
|
+
privacyPolicyUrl=None, privacyChoicesUrl=None),
|
|
303
|
+
]}
|
|
304
|
+
if "/appStoreVersionLocalizations" in path:
|
|
305
|
+
return {"data": [
|
|
306
|
+
_loc_item("V1", "en-US", description="desc",
|
|
307
|
+
keywords=None, promotionalText=None,
|
|
308
|
+
whatsNew=None, supportUrl=None, marketingUrl=None),
|
|
309
|
+
_loc_item("V2", "ja", description=None,
|
|
310
|
+
keywords=None, promotionalText=None,
|
|
311
|
+
whatsNew=None, supportUrl=None, marketingUrl=None),
|
|
312
|
+
]}
|
|
313
|
+
if path.startswith("/appStoreVersions/") and "Localizations" not in path:
|
|
314
|
+
return {"data": {"id": "222", "attributes":
|
|
315
|
+
{"appStoreState": "PREPARE_FOR_SUBMISSION"}}}
|
|
316
|
+
raise AssertionError(f"unexpected path {path}")
|
|
317
|
+
|
|
318
|
+
stdout = io.StringIO()
|
|
319
|
+
with mock.patch.dict("os.environ", env, clear=True), \
|
|
320
|
+
mock.patch.object(detector, "make_jwt", return_value="tok"), \
|
|
321
|
+
mock.patch.object(detector, "get_json", side_effect=fake_get_json), \
|
|
322
|
+
mock.patch.object(sys, "stdout", stdout):
|
|
323
|
+
rc = detector.main()
|
|
324
|
+
self.assertEqual(rc, 0)
|
|
325
|
+
payload = json.loads(stdout.getvalue())
|
|
326
|
+
self.assertEqual(sorted(payload["locales"]), ["en-US", "ja"])
|
|
327
|
+
# en-US still missing subtitle, keywords, promotionalText, whatsNew
|
|
328
|
+
self.assertEqual(
|
|
329
|
+
sorted(payload["empty_fields"]["en-US"]),
|
|
330
|
+
["keywords", "promotionalText", "subtitle", "whatsNew"],
|
|
331
|
+
)
|
|
332
|
+
# ja missing everything non-URL
|
|
333
|
+
self.assertEqual(
|
|
334
|
+
sorted(payload["empty_fields"]["ja"]),
|
|
335
|
+
["description", "keywords", "name", "promotionalText",
|
|
336
|
+
"subtitle", "whatsNew"],
|
|
337
|
+
)
|
|
338
|
+
# app_store_state surfaced for downstream debugging.
|
|
339
|
+
self.assertEqual(payload["app_store_state"], "PREPARE_FOR_SUBMISSION")
|
|
340
|
+
# existing_fields surfaces authoritative ASC values for phase-2
|
|
341
|
+
# prompting -- must be present, keyed by every locale, with all 6
|
|
342
|
+
# non-URL fields (None/missing normalized to "").
|
|
343
|
+
self.assertIn("existing_fields", payload)
|
|
344
|
+
self.assertEqual(set(payload["existing_fields"]), {"en-US", "ja"})
|
|
345
|
+
self.assertEqual(
|
|
346
|
+
set(payload["existing_fields"]["en-US"]),
|
|
347
|
+
{"name", "subtitle", "description", "keywords",
|
|
348
|
+
"promotionalText", "whatsNew"},
|
|
349
|
+
)
|
|
350
|
+
self.assertEqual(payload["existing_fields"]["en-US"]["name"], "MyApp")
|
|
351
|
+
self.assertEqual(payload["existing_fields"]["en-US"]["description"], "desc")
|
|
352
|
+
self.assertEqual(payload["existing_fields"]["en-US"]["subtitle"], "")
|
|
353
|
+
self.assertEqual(payload["existing_fields"]["ja"]["description"], "")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class VersionStateGateTests(unittest.TestCase):
|
|
357
|
+
"""Exercise the editable-version-state gate added to stop 409s on
|
|
358
|
+
/appStoreVersionLocalizations PATCHes when the version is locked."""
|
|
359
|
+
|
|
360
|
+
def _env(self) -> dict:
|
|
361
|
+
return {
|
|
362
|
+
"ASC_KEY_ID": "k",
|
|
363
|
+
"ASC_ISSUER_ID": "i",
|
|
364
|
+
"ASC_KEY_PATH": "/tmp/key.p8",
|
|
365
|
+
"APP_STORE_APPLE_ID": "111",
|
|
366
|
+
"APP_STORE_VERSION_ID": "222",
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
def _make_fake_get_json(self, version_state: str):
|
|
370
|
+
def fake_get_json(path, token, params=None):
|
|
371
|
+
if path.endswith("/appInfos"):
|
|
372
|
+
return _app_info_payload("PREPARE_FOR_SUBMISSION")
|
|
373
|
+
if "/appInfoLocalizations" in path:
|
|
374
|
+
return {"data": [
|
|
375
|
+
_loc_item("A1", "en-US", name=None, subtitle=None,
|
|
376
|
+
privacyPolicyUrl=None, privacyChoicesUrl=None),
|
|
377
|
+
]}
|
|
378
|
+
if "/appStoreVersionLocalizations" in path:
|
|
379
|
+
return {"data": [
|
|
380
|
+
_loc_item("V1", "en-US", description=None,
|
|
381
|
+
keywords=None, promotionalText=None,
|
|
382
|
+
whatsNew=None, supportUrl=None, marketingUrl=None),
|
|
383
|
+
]}
|
|
384
|
+
if path.startswith("/appStoreVersions/") and "Localizations" not in path:
|
|
385
|
+
return {"data": {"id": "222", "attributes":
|
|
386
|
+
{"appStoreState": version_state}}}
|
|
387
|
+
raise AssertionError(f"unexpected path {path}")
|
|
388
|
+
return fake_get_json
|
|
389
|
+
|
|
390
|
+
def _run(self, version_state: str) -> dict:
|
|
391
|
+
stdout = io.StringIO()
|
|
392
|
+
with mock.patch.dict("os.environ", self._env(), clear=True), \
|
|
393
|
+
mock.patch.object(detector, "make_jwt", return_value="tok"), \
|
|
394
|
+
mock.patch.object(
|
|
395
|
+
detector, "get_json",
|
|
396
|
+
side_effect=self._make_fake_get_json(version_state),
|
|
397
|
+
), \
|
|
398
|
+
mock.patch.object(sys, "stdout", stdout):
|
|
399
|
+
rc = detector.main()
|
|
400
|
+
self.assertEqual(rc, 0)
|
|
401
|
+
return json.loads(stdout.getvalue())
|
|
402
|
+
|
|
403
|
+
def test_in_review_clears_version_ids_and_omits_version_fields(self):
|
|
404
|
+
# IN_REVIEW: version record exists but accepts no localization
|
|
405
|
+
# PATCHes -- Apple returns 409. Detector must zero the id so the
|
|
406
|
+
# applier only touches app-level fields (name, subtitle via
|
|
407
|
+
# appInfoLocalizations, which uses a separate editability gate).
|
|
408
|
+
payload = self._run("IN_REVIEW")
|
|
409
|
+
self.assertIsNone(
|
|
410
|
+
payload["localizations"]["en-US"]["version_localization_id"]
|
|
411
|
+
)
|
|
412
|
+
self.assertEqual(payload["empty_fields"]["en-US"], ["name", "subtitle"])
|
|
413
|
+
for f in ("description", "keywords", "promotionalText", "whatsNew"):
|
|
414
|
+
self.assertNotIn(f, payload["empty_fields"]["en-US"])
|
|
415
|
+
self.assertEqual(payload["app_store_state"], "IN_REVIEW")
|
|
416
|
+
|
|
417
|
+
def test_waiting_for_review_is_editable_and_preserves_version_fields(self):
|
|
418
|
+
# WAITING_FOR_REVIEW is in EDITABLE_VERSION_STATES per Apple's docs;
|
|
419
|
+
# the version id must NOT be cleared even though the app is queued.
|
|
420
|
+
payload = self._run("WAITING_FOR_REVIEW")
|
|
421
|
+
self.assertEqual(
|
|
422
|
+
payload["localizations"]["en-US"]["version_localization_id"], "V1"
|
|
423
|
+
)
|
|
424
|
+
self.assertIn("description", payload["empty_fields"]["en-US"])
|
|
425
|
+
|
|
426
|
+
def test_prepare_for_submission_preserves_version_level_fields(self):
|
|
427
|
+
payload = self._run("PREPARE_FOR_SUBMISSION")
|
|
428
|
+
self.assertEqual(
|
|
429
|
+
payload["localizations"]["en-US"]["version_localization_id"], "V1"
|
|
430
|
+
)
|
|
431
|
+
self.assertIn("description", payload["empty_fields"]["en-US"])
|
|
432
|
+
self.assertIn("whatsNew", payload["empty_fields"]["en-US"])
|
|
433
|
+
self.assertEqual(payload["app_store_state"], "PREPARE_FOR_SUBMISSION")
|
|
434
|
+
|
|
435
|
+
def test_ready_for_sale_clears_version_ids(self):
|
|
436
|
+
# READY_FOR_SALE is a live, locked state -- PATCHes would 409.
|
|
437
|
+
payload = self._run("READY_FOR_SALE")
|
|
438
|
+
self.assertIsNone(
|
|
439
|
+
payload["localizations"]["en-US"]["version_localization_id"]
|
|
440
|
+
)
|
|
441
|
+
self.assertNotIn("description", payload["empty_fields"].get("en-US", []))
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
if __name__ == "__main__":
|
|
445
|
+
unittest.main()
|