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.
Files changed (84) hide show
  1. package/CLAUDE.md +94 -0
  2. package/README.md +417 -0
  3. package/action/.daemux-version +1 -0
  4. package/action/MIGRATION.md +232 -0
  5. package/action/action.yml +973 -0
  6. package/action/prompts/generate_dependent_fields.prompt.yml +78 -0
  7. package/action/prompts/generate_descriptions.prompt.yml +59 -0
  8. package/action/scripts/app_context_scanner.py +239 -0
  9. package/action/scripts/asc_build_history.py +284 -0
  10. package/action/scripts/asc_common.py +246 -0
  11. package/action/scripts/asc_metadata_applier.py +368 -0
  12. package/action/scripts/asc_metadata_detector.py +250 -0
  13. package/action/scripts/asc_version_create.py +365 -0
  14. package/action/scripts/asc_version_fetch.py +274 -0
  15. package/action/scripts/asc_version_reuse.py +177 -0
  16. package/action/scripts/auto_detect.py +391 -0
  17. package/action/scripts/autoupdate_check.sh +118 -0
  18. package/action/scripts/cert_factory.py +190 -0
  19. package/action/scripts/cfg_io.py +27 -0
  20. package/action/scripts/cfg_resolve.py +147 -0
  21. package/action/scripts/commit_bot_changes.sh +82 -0
  22. package/action/scripts/creds_store.py +328 -0
  23. package/action/scripts/keychain.py +103 -0
  24. package/action/scripts/lookup_app_id.py +51 -0
  25. package/action/scripts/manage_marketing_version.py +337 -0
  26. package/action/scripts/metadata_constants.py +102 -0
  27. package/action/scripts/mmv_decide_create.py +181 -0
  28. package/action/scripts/mmv_floor_check.py +265 -0
  29. package/action/scripts/next_build_number.py +195 -0
  30. package/action/scripts/pbxproj_editor.py +220 -0
  31. package/action/scripts/prepare_signing.py +223 -0
  32. package/action/scripts/profile_io.py +64 -0
  33. package/action/scripts/profile_manager.py +307 -0
  34. package/action/scripts/read_config.py +315 -0
  35. package/action/scripts/resolve_marketing_version.py +173 -0
  36. package/action/scripts/set_app_store_whats_new.py +317 -0
  37. package/action/scripts/team_resolver.py +130 -0
  38. package/action/scripts/test_app_context_scanner.py +167 -0
  39. package/action/scripts/test_asc_build_history.py +221 -0
  40. package/action/scripts/test_asc_builds_prerelease.py +219 -0
  41. package/action/scripts/test_asc_common_timeouts.py +121 -0
  42. package/action/scripts/test_asc_metadata_applier.py +850 -0
  43. package/action/scripts/test_asc_metadata_detector.py +445 -0
  44. package/action/scripts/test_auto_detect.py +432 -0
  45. package/action/scripts/test_cfg_resolve_credentials.py +48 -0
  46. package/action/scripts/test_compute_next_version.py +95 -0
  47. package/action/scripts/test_compute_next_version_patch_backcompat.py +41 -0
  48. package/action/scripts/test_compute_next_version_strictness.py +67 -0
  49. package/action/scripts/test_fetch_versions.py +163 -0
  50. package/action/scripts/test_ground_truth_floor.py +104 -0
  51. package/action/scripts/test_manage_marketing_version.py +134 -0
  52. package/action/scripts/test_manage_marketing_version_autoroll.py +149 -0
  53. package/action/scripts/test_manage_marketing_version_floor.py +170 -0
  54. package/action/scripts/test_manage_marketing_version_match.py +187 -0
  55. package/action/scripts/test_mmv_409_classifier.py +231 -0
  56. package/action/scripts/test_mmv_autobump_persist_gate.py +131 -0
  57. package/action/scripts/test_mmv_decide_create.py +119 -0
  58. package/action/scripts/test_mmv_floor_check.py +158 -0
  59. package/action/scripts/test_mmv_floor_crosscheck.py +132 -0
  60. package/action/scripts/test_mmv_helpers.py +57 -0
  61. package/action/scripts/test_next_build_number.py +174 -0
  62. package/action/scripts/test_read_config_auto_detect.py +257 -0
  63. package/action/scripts/test_resolve_marketing_version.py +298 -0
  64. package/action/scripts/test_reuse_stale_editable.py +182 -0
  65. package/action/scripts/test_set_app_store_whats_new.py +71 -0
  66. package/action/scripts/test_team_fallback_wiring.py +157 -0
  67. package/action/scripts/test_team_resolver.py +249 -0
  68. package/action/scripts/tests_common.py +167 -0
  69. package/action/scripts/version_utils.py +373 -0
  70. package/android-action/.daemux-version +1 -0
  71. package/android-action/action.yml +92 -0
  72. package/android-action/scripts/android_config.py +130 -0
  73. package/android-action/scripts/bitrise_deploy.py +160 -0
  74. package/android-action/scripts/find_bundle.py +25 -0
  75. package/android-action/scripts/play_preflight.py +69 -0
  76. package/android-action/scripts/resolve_android.py +70 -0
  77. package/android-action/scripts/sign_bundle.py +70 -0
  78. package/android-action/scripts/test_android_config.py +97 -0
  79. package/android-action/scripts/test_bitrise_deploy.py +124 -0
  80. package/android-action/scripts/test_sign_bundle.py +82 -0
  81. package/bin/cli.mjs +63 -0
  82. package/package.json +57 -0
  83. package/src/install.mjs +208 -0
  84. package/templates/deploy.yml +150 -0
@@ -0,0 +1,850 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for asc_metadata_applier.py -- the AI-response -> ASC PATCH writer.
4
+
5
+ Every test mocks `asc_metadata_applier.request` so no HTTP calls are made.
6
+ Covers the double-gate, URL-field skip, char-limit validation, null-id
7
+ graceful skip, and the top-level main() fail-open semantics.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sys
14
+ import tempfile
15
+ import unittest
16
+ from pathlib import Path
17
+ from unittest import mock
18
+
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
20
+
21
+ import asc_metadata_applier as mod # noqa: E402
22
+
23
+
24
+ TOKEN = "TEST_TOKEN"
25
+
26
+
27
+ def _state(empty_fields, localizations=None):
28
+ return {
29
+ "app_info_id": "ai-1",
30
+ "version_id": "v-1",
31
+ "locales": list(empty_fields.keys()),
32
+ "localizations": localizations or {},
33
+ "empty_fields": empty_fields,
34
+ }
35
+
36
+
37
+ def _loc(app_id="app-en", ver_id="ver-en"):
38
+ return {
39
+ "app_info_localization_id": app_id,
40
+ "version_localization_id": ver_id,
41
+ "fields": {},
42
+ }
43
+
44
+
45
+ class ValidateFieldTests(unittest.TestCase):
46
+ def test_returns_trimmed_value_under_limit(self):
47
+ self.assertEqual(mod._validate_field("name", " MyApp "), "MyApp")
48
+
49
+ def test_rejects_value_exceeding_char_limit(self):
50
+ long_name = "x" * 31
51
+ self.assertIsNone(mod._validate_field("name", long_name))
52
+
53
+ def test_accepts_value_at_exact_char_limit(self):
54
+ exact = "x" * 30
55
+ self.assertEqual(mod._validate_field("name", exact), exact)
56
+
57
+ def test_rejects_url_field_unconditionally(self):
58
+ self.assertIsNone(mod._validate_field("marketingUrl", "https://x.y"))
59
+
60
+ def test_rejects_non_string_value(self):
61
+ self.assertIsNone(mod._validate_field("name", 42))
62
+ self.assertIsNone(mod._validate_field("name", None))
63
+ self.assertIsNone(mod._validate_field("name", ["MyApp"]))
64
+
65
+ def test_rejects_whitespace_only_value(self):
66
+ self.assertIsNone(mod._validate_field("name", " \n\t "))
67
+
68
+ def test_char_limit_measures_characters_not_bytes(self):
69
+ # CJK chars are multi-byte but len() counts one each.
70
+ japanese_subtitle = "あ" * 30
71
+ self.assertEqual(
72
+ mod._validate_field("subtitle", japanese_subtitle),
73
+ japanese_subtitle,
74
+ )
75
+ over = "あ" * 31
76
+ self.assertIsNone(mod._validate_field("subtitle", over))
77
+
78
+
79
+ class BuildWritesTests(unittest.TestCase):
80
+ def test_includes_fields_that_pass_both_gates(self):
81
+ writes = mod._build_writes(
82
+ {"name": "MyApp", "subtitle": "Cool"},
83
+ ["name", "subtitle"],
84
+ mod.APP_LEVEL_FIELDS,
85
+ )
86
+ self.assertEqual(writes, {"name": "MyApp", "subtitle": "Cool"})
87
+
88
+ def test_drops_field_not_in_empty_list(self):
89
+ # Double-gate: subtitle is in the AI response but detector did NOT
90
+ # report it as empty -> applier must drop it.
91
+ writes = mod._build_writes(
92
+ {"name": "MyApp", "subtitle": "Cool"},
93
+ ["name"], # only name is empty
94
+ mod.APP_LEVEL_FIELDS,
95
+ )
96
+ self.assertEqual(writes, {"name": "MyApp"})
97
+
98
+ def test_drops_url_field_even_if_in_empty_list(self):
99
+ # Belt-and-suspenders: even if a URL field somehow leaks into
100
+ # empty_list, SKIP_URL_FIELDS drops it.
101
+ writes = mod._build_writes(
102
+ {"marketingUrl": "https://x.y"},
103
+ ["marketingUrl"],
104
+ mod.VERSION_LEVEL_FIELDS,
105
+ )
106
+ self.assertEqual(writes, {})
107
+
108
+ def test_drops_field_absent_from_ai_response(self):
109
+ writes = mod._build_writes(
110
+ {"name": "MyApp"}, # no subtitle
111
+ ["name", "subtitle"],
112
+ mod.APP_LEVEL_FIELDS,
113
+ )
114
+ self.assertEqual(writes, {"name": "MyApp"})
115
+
116
+ def test_only_considers_fields_in_its_group(self):
117
+ # description is version-level; must not appear when called with
118
+ # APP_LEVEL_FIELDS group.
119
+ writes = mod._build_writes(
120
+ {"name": "MyApp", "description": "A desc"},
121
+ ["name", "description"],
122
+ mod.APP_LEVEL_FIELDS,
123
+ )
124
+ self.assertEqual(writes, {"name": "MyApp"})
125
+
126
+
127
+ class ApplyLocaleTests(unittest.TestCase):
128
+ @mock.patch.object(mod, "request")
129
+ def test_patches_both_resources_when_both_have_writes(self, mock_req):
130
+ written = mod._apply_locale(
131
+ TOKEN,
132
+ "en-US",
133
+ _loc("app-en", "ver-en"),
134
+ {"name": "MyApp", "description": "Hello"},
135
+ ["name", "description"],
136
+ )
137
+ self.assertEqual(written, 2)
138
+ self.assertEqual(mock_req.call_count, 2)
139
+ paths = [call.args[1] for call in mock_req.call_args_list]
140
+ self.assertIn("/appInfoLocalizations/app-en", paths)
141
+ self.assertIn("/appStoreVersionLocalizations/ver-en", paths)
142
+
143
+ @mock.patch.object(mod, "request")
144
+ def test_skips_app_level_when_app_id_missing(self, mock_req):
145
+ written = mod._apply_locale(
146
+ TOKEN,
147
+ "ja",
148
+ _loc(None, "ver-ja"),
149
+ {"name": "MyApp", "description": "Hi"},
150
+ ["name", "description"],
151
+ )
152
+ self.assertEqual(written, 1) # only description PATCHed
153
+ self.assertEqual(mock_req.call_count, 1)
154
+ self.assertEqual(
155
+ mock_req.call_args.args[1], "/appStoreVersionLocalizations/ver-ja"
156
+ )
157
+
158
+ @mock.patch.object(mod, "request")
159
+ def test_skips_version_level_when_version_id_missing(self, mock_req):
160
+ written = mod._apply_locale(
161
+ TOKEN,
162
+ "ja",
163
+ _loc("app-ja", None),
164
+ {"name": "MyApp", "description": "Hi"},
165
+ ["name", "description"],
166
+ )
167
+ self.assertEqual(written, 1)
168
+ self.assertEqual(mock_req.call_count, 1)
169
+ self.assertEqual(
170
+ mock_req.call_args.args[1], "/appInfoLocalizations/app-ja"
171
+ )
172
+
173
+ @mock.patch.object(mod, "request")
174
+ def test_patch_body_contains_only_surviving_fields(self, mock_req):
175
+ # subtitle over limit -> only name PATCHed
176
+ long_subtitle = "x" * 40
177
+ written = mod._apply_locale(
178
+ TOKEN,
179
+ "en-US",
180
+ _loc("app-en", "ver-en"),
181
+ {"name": "MyApp", "subtitle": long_subtitle},
182
+ ["name", "subtitle"],
183
+ )
184
+ self.assertEqual(written, 1)
185
+ self.assertEqual(mock_req.call_count, 1)
186
+ body = mock_req.call_args.kwargs["json_body"]
187
+ self.assertEqual(body["data"]["attributes"], {"name": "MyApp"})
188
+ self.assertNotIn("subtitle", body["data"]["attributes"])
189
+
190
+ @mock.patch.object(mod, "request")
191
+ def test_request_failure_does_not_abort_other_resource(self, mock_req):
192
+ # First PATCH (app-info) raises SystemExit; version PATCH still runs.
193
+ mock_req.side_effect = [SystemExit("boom"), None]
194
+ written = mod._apply_locale(
195
+ TOKEN,
196
+ "en-US",
197
+ _loc("app-en", "ver-en"),
198
+ {"name": "MyApp", "description": "Hello"},
199
+ ["name", "description"],
200
+ )
201
+ self.assertEqual(written, 1) # version succeeded, app failed
202
+ self.assertEqual(mock_req.call_count, 2)
203
+
204
+
205
+ class ApplyTests(unittest.TestCase):
206
+ @mock.patch.object(mod, "request")
207
+ def test_empty_empty_fields_zero_patches(self, mock_req):
208
+ state = _state(empty_fields={})
209
+ ai = {"localizations": {"en-US": {"name": "MyApp"}}}
210
+ total, locales = mod.apply(state, ai, TOKEN)
211
+ self.assertEqual(total, 0)
212
+ self.assertEqual(locales, 0)
213
+ mock_req.assert_not_called()
214
+
215
+ @mock.patch.object(mod, "request")
216
+ def test_iterates_multiple_locales(self, mock_req):
217
+ state = _state(
218
+ empty_fields={"en-US": ["name"], "ja": ["name"]},
219
+ localizations={
220
+ "en-US": _loc("app-en", "ver-en"),
221
+ "ja": _loc("app-ja", "ver-ja"),
222
+ },
223
+ )
224
+ ai = {
225
+ "localizations": {
226
+ "en-US": {"name": "MyApp"},
227
+ "ja": {"name": "マイアプリ"},
228
+ }
229
+ }
230
+ total, locales = mod.apply(state, ai, TOKEN)
231
+ self.assertEqual(total, 2)
232
+ self.assertEqual(locales, 2)
233
+ self.assertEqual(mock_req.call_count, 2)
234
+
235
+ @mock.patch.object(mod, "request")
236
+ def test_skips_locale_missing_from_ai_response(self, mock_req):
237
+ state = _state(
238
+ empty_fields={"en-US": ["name"], "de-DE": ["name"]},
239
+ localizations={
240
+ "en-US": _loc("app-en", "ver-en"),
241
+ "de-DE": _loc("app-de", "ver-de"),
242
+ },
243
+ )
244
+ ai = {"localizations": {"en-US": {"name": "MyApp"}}}
245
+ total, locales = mod.apply(state, ai, TOKEN)
246
+ self.assertEqual(total, 1)
247
+ self.assertEqual(locales, 1)
248
+ self.assertEqual(mock_req.call_count, 1)
249
+
250
+ @mock.patch.object(mod, "request")
251
+ def test_url_field_in_ai_response_dropped(self, mock_req):
252
+ # Detector never reports URL fields as empty, but even if AI returns
253
+ # one unprompted, the applier must drop it.
254
+ state = _state(
255
+ empty_fields={"en-US": ["description"]},
256
+ localizations={"en-US": _loc("app-en", "ver-en")},
257
+ )
258
+ ai = {
259
+ "localizations": {
260
+ "en-US": {
261
+ "description": "Nice app.",
262
+ "marketingUrl": "https://example.com",
263
+ "supportUrl": "https://example.com/support",
264
+ }
265
+ }
266
+ }
267
+ total, _ = mod.apply(state, ai, TOKEN)
268
+ self.assertEqual(total, 1)
269
+ self.assertEqual(mock_req.call_count, 1)
270
+ attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
271
+ self.assertEqual(attrs, {"description": "Nice app."})
272
+
273
+ @mock.patch.object(mod, "request")
274
+ def test_double_gate_drops_field_not_in_empty_list(self, mock_req):
275
+ # Detector said only 'name' was empty for en-US, but AI returned both
276
+ # name AND subtitle. Applier must drop subtitle.
277
+ state = _state(
278
+ empty_fields={"en-US": ["name"]},
279
+ localizations={"en-US": _loc("app-en", "ver-en")},
280
+ )
281
+ ai = {
282
+ "localizations": {
283
+ "en-US": {"name": "MyApp", "subtitle": "Stale"}
284
+ }
285
+ }
286
+ total, _ = mod.apply(state, ai, TOKEN)
287
+ self.assertEqual(total, 1)
288
+ self.assertEqual(mock_req.call_count, 1)
289
+ attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
290
+ self.assertEqual(attrs, {"name": "MyApp"})
291
+ self.assertNotIn("subtitle", attrs)
292
+
293
+
294
+ ENV_FAKE = {"ASC_KEY_ID": "k", "ASC_ISSUER_ID": "i", "ASC_KEY_PATH": "/tmp/k.p8"}
295
+
296
+
297
+ def _run_main(state_data, ai_data, fields_filter=None):
298
+ """Write state/ai to temp files and invoke mod.main(). Returns the code."""
299
+ with tempfile.TemporaryDirectory() as tmp:
300
+ root = Path(tmp)
301
+ state_path = root / "state.json"
302
+ state_path.write_text(
303
+ state_data if isinstance(state_data, str) else json.dumps(state_data),
304
+ encoding="utf-8",
305
+ )
306
+ ai_path = root / "ai.json"
307
+ ai_path.write_text(
308
+ ai_data if isinstance(ai_data, str) else json.dumps(ai_data),
309
+ encoding="utf-8",
310
+ )
311
+ argv = [
312
+ "asc_metadata_applier.py",
313
+ "--state", str(state_path),
314
+ "--response", str(ai_path),
315
+ ]
316
+ if fields_filter is not None:
317
+ argv += ["--fields-filter", fields_filter]
318
+ with mock.patch.object(sys, "argv", argv):
319
+ return mod.main()
320
+
321
+
322
+ class MainTests(unittest.TestCase):
323
+ @mock.patch.object(mod, "make_jwt", return_value=TOKEN)
324
+ @mock.patch.object(mod, "request")
325
+ def test_empty_empty_fields_returns_early_no_jwt(self, mock_req, mock_jwt):
326
+ rc = _run_main(_state(empty_fields={}), {"localizations": {}})
327
+ self.assertEqual(rc, 0)
328
+ mock_req.assert_not_called()
329
+ mock_jwt.assert_not_called()
330
+
331
+ @mock.patch.object(mod, "make_jwt", return_value=TOKEN)
332
+ @mock.patch.object(mod, "request")
333
+ def test_invalid_state_json_fails_open(self, mock_req, mock_jwt):
334
+ rc = _run_main("{not json", {"localizations": {}})
335
+ self.assertEqual(rc, 0)
336
+ mock_req.assert_not_called()
337
+
338
+ @mock.patch.dict("os.environ", ENV_FAKE)
339
+ @mock.patch.object(mod, "make_jwt", return_value=TOKEN)
340
+ @mock.patch.object(mod, "request")
341
+ def test_null_app_info_loc_id_skips_app_level(self, mock_req, mock_jwt):
342
+ state = _state(
343
+ empty_fields={"en-US": ["name", "description"]},
344
+ localizations={"en-US": _loc(app_id=None, ver_id="ver-en")},
345
+ )
346
+ ai = {"localizations": {"en-US": {"name": "MyApp", "description": "Hi"}}}
347
+ rc = _run_main(state, ai)
348
+ self.assertEqual(rc, 0)
349
+ self.assertEqual(mock_req.call_count, 1)
350
+ self.assertEqual(
351
+ mock_req.call_args.args[1], "/appStoreVersionLocalizations/ver-en"
352
+ )
353
+ attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
354
+ self.assertEqual(attrs, {"description": "Hi"})
355
+
356
+ @mock.patch.dict("os.environ", ENV_FAKE)
357
+ @mock.patch.object(mod, "make_jwt", return_value=TOKEN)
358
+ @mock.patch.object(mod, "request")
359
+ def test_end_to_end_char_limit_drops_one_field(self, mock_req, mock_jwt):
360
+ state = _state(
361
+ empty_fields={"en-US": ["name", "subtitle"]},
362
+ localizations={"en-US": _loc("app-en", "ver-en")},
363
+ )
364
+ ai = {"localizations": {"en-US": {"name": "MyApp", "subtitle": "x" * 50}}}
365
+ rc = _run_main(state, ai)
366
+ self.assertEqual(rc, 0)
367
+ self.assertEqual(mock_req.call_count, 1)
368
+ attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
369
+ self.assertEqual(attrs, {"name": "MyApp"})
370
+
371
+ @mock.patch.dict("os.environ", ENV_FAKE)
372
+ @mock.patch.object(mod, "make_jwt", return_value=TOKEN)
373
+ @mock.patch.object(mod, "request")
374
+ def test_ai_response_with_unescaped_newlines_parses_and_normalizes(
375
+ self, mock_req, mock_jwt,
376
+ ):
377
+ # Raw AI output with a literal \n inside a string literal -- strict
378
+ # json.loads would reject this as invalid control char, collapsing
379
+ # the applier to zero writes. With strict=False + whitespace
380
+ # normalization the field is PATCHed with a single-space-joined
381
+ # value that still respects the 30-char subtitle cap.
382
+ state = _state(
383
+ empty_fields={"en-US": ["subtitle"]},
384
+ localizations={"en-US": _loc("app-en", "ver-en")},
385
+ )
386
+ raw_ai = (
387
+ '{"localizations": {"en-US": {"subtitle": "Fast.\n'
388
+ 'Reliable VPN"}}}'
389
+ )
390
+ rc = _run_main(state, raw_ai)
391
+ self.assertEqual(rc, 0)
392
+ self.assertEqual(mock_req.call_count, 1)
393
+ attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
394
+ # Newline collapsed to a single space; surrounding whitespace stripped.
395
+ self.assertEqual(attrs, {"subtitle": "Fast. Reliable VPN"})
396
+ self.assertNotIn("\n", attrs["subtitle"])
397
+
398
+ @mock.patch.dict("os.environ", ENV_FAKE)
399
+ @mock.patch.object(mod, "make_jwt", return_value=TOKEN)
400
+ @mock.patch.object(mod, "request")
401
+ def test_null_version_loc_id_logs_explicit_skip(self, mock_req, mock_jwt):
402
+ # Detector clears version_localization_id when the App Store version
403
+ # is in a non-editable state. Applier must skip version-level fields
404
+ # silently (no warning) but still PATCH app-level fields.
405
+ state = _state(
406
+ empty_fields={"en-US": ["name", "description"]},
407
+ localizations={"en-US": _loc(app_id="app-en", ver_id=None)},
408
+ )
409
+ ai = {"localizations": {"en-US": {"name": "MyApp", "description": "Hi"}}}
410
+ rc = _run_main(state, ai)
411
+ self.assertEqual(rc, 0)
412
+ # Only appInfo PATCH ran; version resource skipped.
413
+ self.assertEqual(mock_req.call_count, 1)
414
+ self.assertEqual(
415
+ mock_req.call_args.args[1], "/appInfoLocalizations/app-en"
416
+ )
417
+
418
+
419
+ def _asc_409(detail: str, code: str = "STATE_ERROR") -> SystemExit:
420
+ """Fabricate a SystemExit mirroring what asc_common.request raises on a
421
+ non-retryable 4xx (used by tests to simulate ASC's per-field rejection).
422
+ """
423
+ body = json.dumps({
424
+ "errors": [
425
+ {"status": "409", "code": code, "detail": detail}
426
+ ]
427
+ })
428
+ return SystemExit(f"ASC PATCH /appStoreVersionLocalizations/ver-en failed: 409\n{body}")
429
+
430
+
431
+ class PerFieldPatchTests(unittest.TestCase):
432
+ """Per-field PATCH resilience -- one field's 409 must not block others.
433
+
434
+ Reproduces the production failure on sample1/sample2 where bundling all
435
+ four version-level fields (description, keywords, promotionalText,
436
+ whatsNew) into a single PATCH lost every field when ASC rejected
437
+ whatsNew alone with 409 STATE_ERROR.
438
+ """
439
+
440
+ @mock.patch.object(mod, "request")
441
+ def test_patches_each_version_field_separately(self, mock_req):
442
+ # All four version fields present; verify 4 distinct PATCHes.
443
+ mod._patch_group(
444
+ TOKEN,
445
+ mod.VERSION_RESOURCE,
446
+ "en-US",
447
+ "ver-en",
448
+ {
449
+ "description": "Desc",
450
+ "keywords": "k1,k2",
451
+ "promotionalText": "Promo",
452
+ "whatsNew": "Notes",
453
+ },
454
+ )
455
+ self.assertEqual(mock_req.call_count, 4)
456
+ patched_fields = []
457
+ for call in mock_req.call_args_list:
458
+ attrs = call.kwargs["json_body"]["data"]["attributes"]
459
+ self.assertEqual(len(attrs), 1, f"expected single-field PATCH, got {attrs}")
460
+ patched_fields.extend(attrs.keys())
461
+ self.assertEqual(
462
+ sorted(patched_fields),
463
+ ["description", "keywords", "promotionalText", "whatsNew"],
464
+ )
465
+
466
+ @mock.patch.object(mod, "request")
467
+ def test_whatsnew_409_does_not_block_other_version_fields(self, mock_req):
468
+ # Exact reproduction of sample1/sample2 failure: whatsNew is
469
+ # rejected (build already attached, state transition in flight)
470
+ # while description/keywords/promotionalText still accept edits.
471
+ def fake_request(method, path, token, **kw):
472
+ field = next(iter(kw["json_body"]["data"]["attributes"].keys()))
473
+ if field == "whatsNew":
474
+ raise _asc_409("Attribute 'whatsNew' cannot be edited at this time")
475
+ return None
476
+
477
+ mock_req.side_effect = fake_request
478
+ written = mod._patch_group(
479
+ TOKEN,
480
+ mod.VERSION_RESOURCE,
481
+ "en-US",
482
+ "ver-en",
483
+ {
484
+ "description": "Desc",
485
+ "keywords": "k1,k2",
486
+ "promotionalText": "Promo",
487
+ "whatsNew": "Notes",
488
+ },
489
+ )
490
+ self.assertEqual(written, 3)
491
+ self.assertEqual(mock_req.call_count, 4)
492
+
493
+ @mock.patch.object(mod, "warn")
494
+ @mock.patch.object(mod, "request")
495
+ def test_all_version_fields_409_counts_zero_and_warns_each(self, mock_req, mock_warn):
496
+ # ASC rejects every version-level field -- applier must attempt
497
+ # each, warn on each, and report 0 writes. No single rejection
498
+ # short-circuits the others (granular visibility in logs).
499
+ mock_req.side_effect = lambda *a, **kw: (_ for _ in ()).throw(
500
+ _asc_409("Attribute cannot be edited at this time")
501
+ )
502
+ written = mod._patch_group(
503
+ TOKEN,
504
+ mod.VERSION_RESOURCE,
505
+ "en-US",
506
+ "ver-en",
507
+ {
508
+ "description": "Desc",
509
+ "keywords": "k1,k2",
510
+ "promotionalText": "Promo",
511
+ "whatsNew": "Notes",
512
+ },
513
+ )
514
+ self.assertEqual(written, 0)
515
+ self.assertEqual(mock_req.call_count, 4)
516
+ # One warn per field, each naming the field + ASC detail.
517
+ self.assertEqual(mock_warn.call_count, 4)
518
+ warn_msgs = [call.args[0] for call in mock_warn.call_args_list]
519
+ for field in ("description", "keywords", "promotionalText", "whatsNew"):
520
+ self.assertTrue(
521
+ any(field in msg for msg in warn_msgs),
522
+ f"expected warn mentioning {field}; got {warn_msgs}",
523
+ )
524
+ for msg in warn_msgs:
525
+ self.assertIn("cannot be edited at this time", msg)
526
+
527
+ @mock.patch.object(mod, "request")
528
+ def test_app_level_fields_also_split_per_field(self, mock_req):
529
+ # Symmetry: name and subtitle must also go out as separate PATCHes
530
+ # so a name-specific rejection doesn't nuke subtitle.
531
+ mod._patch_group(
532
+ TOKEN,
533
+ mod.APP_INFO_RESOURCE,
534
+ "en-US",
535
+ "app-en",
536
+ {"name": "MyApp", "subtitle": "Cool"},
537
+ )
538
+ self.assertEqual(mock_req.call_count, 2)
539
+ for call in mock_req.call_args_list:
540
+ attrs = call.kwargs["json_body"]["data"]["attributes"]
541
+ self.assertEqual(len(attrs), 1)
542
+
543
+ @mock.patch.object(mod, "request")
544
+ def test_warning_includes_asc_detail(self, mock_req):
545
+ # Warning must surface ASC's human-readable `detail`, not just
546
+ # "409 STATE_ERROR". Makes root-causing ASC rejections faster.
547
+ with mock.patch.object(mod, "warn") as mock_warn:
548
+ mock_req.side_effect = _asc_409(
549
+ "Attribute 'whatsNew' cannot be edited at this time"
550
+ )
551
+ mod._patch_group(
552
+ TOKEN,
553
+ mod.VERSION_RESOURCE,
554
+ "en-US",
555
+ "ver-en",
556
+ {"whatsNew": "Notes"},
557
+ )
558
+ self.assertEqual(mock_warn.call_count, 1)
559
+ msg = mock_warn.call_args.args[0]
560
+ self.assertIn("whatsNew", msg)
561
+ self.assertIn("Attribute 'whatsNew' cannot be edited at this time", msg)
562
+ self.assertIn("STATE_ERROR", msg)
563
+
564
+
565
+ class ExtractAscDetailTests(unittest.TestCase):
566
+ def test_parses_standard_errors_envelope(self):
567
+ exc = SystemExit(
568
+ 'ASC PATCH /x failed: 409\n'
569
+ '{"errors":[{"status":"409","code":"STATE_ERROR",'
570
+ '"detail":"Attribute cannot be edited at this time"}]}'
571
+ )
572
+ result = mod._extract_asc_detail(exc)
573
+ self.assertIn("Attribute cannot be edited at this time", result)
574
+ self.assertIn("STATE_ERROR", result)
575
+
576
+ def test_falls_back_to_raw_when_body_not_json(self):
577
+ exc = SystemExit("ASC PATCH /x failed: 500\n<html>gateway timeout</html>")
578
+ result = mod._extract_asc_detail(exc)
579
+ # Raw fallback -- we didn't crash and returned *something* useful.
580
+ self.assertIn("500", result)
581
+
582
+ def test_handles_missing_body(self):
583
+ exc = SystemExit("ASC PATCH /x network error")
584
+ result = mod._extract_asc_detail(exc)
585
+ self.assertEqual(result, "ASC PATCH /x network error")
586
+
587
+ def test_handles_empty_errors_array(self):
588
+ exc = SystemExit('ASC PATCH /x failed: 400\n{"errors":[]}')
589
+ result = mod._extract_asc_detail(exc)
590
+ # Empty errors array -> fall back to raw; no crash.
591
+ self.assertIn("400", result)
592
+
593
+
594
+ class ApplyPerFieldResilienceTests(unittest.TestCase):
595
+ @mock.patch.object(mod, "request")
596
+ def test_apply_counts_only_successful_patches_across_locales(self, mock_req):
597
+ # Three version fields across en-US: whatsNew rejected, others succeed.
598
+ # Final total_written must be 2 (description, keywords), locales_touched=1.
599
+ def fake_request(method, path, token, **kw):
600
+ field = next(iter(kw["json_body"]["data"]["attributes"].keys()))
601
+ if field == "whatsNew":
602
+ raise _asc_409("cannot be edited")
603
+ return None
604
+
605
+ mock_req.side_effect = fake_request
606
+ state = _state(
607
+ empty_fields={"en-US": ["description", "keywords", "whatsNew"]},
608
+ localizations={"en-US": _loc("app-en", "ver-en")},
609
+ )
610
+ ai = {
611
+ "localizations": {
612
+ "en-US": {
613
+ "description": "Desc",
614
+ "keywords": "k1,k2",
615
+ "whatsNew": "Notes",
616
+ }
617
+ }
618
+ }
619
+ total, locales = mod.apply(state, ai, TOKEN)
620
+ self.assertEqual(total, 2)
621
+ self.assertEqual(locales, 1)
622
+ self.assertEqual(mock_req.call_count, 3)
623
+
624
+
625
+ class ParseFieldsFilterTests(unittest.TestCase):
626
+ def test_none_returns_none(self):
627
+ self.assertIsNone(mod._parse_fields_filter(None))
628
+
629
+ def test_empty_string_returns_none(self):
630
+ self.assertIsNone(mod._parse_fields_filter(""))
631
+
632
+ def test_single_field(self):
633
+ self.assertEqual(mod._parse_fields_filter("description"), {"description"})
634
+
635
+ def test_comma_separated(self):
636
+ self.assertEqual(
637
+ mod._parse_fields_filter("name,subtitle,keywords"),
638
+ {"name", "subtitle", "keywords"},
639
+ )
640
+
641
+ def test_trims_whitespace(self):
642
+ self.assertEqual(
643
+ mod._parse_fields_filter(" name , subtitle "),
644
+ {"name", "subtitle"},
645
+ )
646
+
647
+ def test_drops_empty_tokens(self):
648
+ self.assertEqual(
649
+ mod._parse_fields_filter("name,,subtitle,"),
650
+ {"name", "subtitle"},
651
+ )
652
+
653
+
654
+ class ApplyWithFieldsFilterTests(unittest.TestCase):
655
+ """Two-phase orchestration: phase-1 PATCHes description first, phase-2
656
+ PATCHes the remaining fields using description as prompt context."""
657
+
658
+ @mock.patch.object(mod, "request")
659
+ def test_filter_description_only_patches_description(self, mock_req):
660
+ state = _state(
661
+ empty_fields={"en-US": ["description", "keywords", "whatsNew"]},
662
+ localizations={"en-US": _loc("app-en", "ver-en")},
663
+ )
664
+ ai = {
665
+ "localizations": {
666
+ "en-US": {
667
+ "description": "Desc",
668
+ "keywords": "k1,k2",
669
+ "whatsNew": "Notes",
670
+ }
671
+ }
672
+ }
673
+ total, locales = mod.apply(state, ai, TOKEN, fields_filter={"description"})
674
+ self.assertEqual(total, 1)
675
+ self.assertEqual(locales, 1)
676
+ self.assertEqual(mock_req.call_count, 1)
677
+ attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
678
+ self.assertEqual(attrs, {"description": "Desc"})
679
+
680
+ @mock.patch.object(mod, "request")
681
+ def test_filter_excludes_description(self, mock_req):
682
+ # Phase-2: filter out description, PATCH the rest.
683
+ state = _state(
684
+ empty_fields={"en-US": ["description", "keywords", "whatsNew"]},
685
+ localizations={"en-US": _loc("app-en", "ver-en")},
686
+ )
687
+ ai = {
688
+ "localizations": {
689
+ "en-US": {
690
+ "description": "Desc (stale)",
691
+ "keywords": "k1,k2",
692
+ "whatsNew": "Notes",
693
+ }
694
+ }
695
+ }
696
+ total, locales = mod.apply(
697
+ state, ai, TOKEN, fields_filter={"keywords", "whatsNew"}
698
+ )
699
+ self.assertEqual(total, 2)
700
+ self.assertEqual(locales, 1)
701
+ self.assertEqual(mock_req.call_count, 2)
702
+ patched = []
703
+ for call in mock_req.call_args_list:
704
+ attrs = call.kwargs["json_body"]["data"]["attributes"]
705
+ patched.extend(attrs.keys())
706
+ self.assertEqual(sorted(patched), ["keywords", "whatsNew"])
707
+
708
+ @mock.patch.object(mod, "request")
709
+ def test_filter_preserves_double_gate(self, mock_req):
710
+ # Field in filter but NOT in empty_fields -> still skipped.
711
+ # Detector said only 'description' was empty; phase-2 tries to PATCH
712
+ # keywords + whatsNew; must write 0.
713
+ state = _state(
714
+ empty_fields={"en-US": ["description"]},
715
+ localizations={"en-US": _loc("app-en", "ver-en")},
716
+ )
717
+ ai = {
718
+ "localizations": {
719
+ "en-US": {
720
+ "description": "Desc",
721
+ "keywords": "stale",
722
+ "whatsNew": "stale",
723
+ }
724
+ }
725
+ }
726
+ total, locales = mod.apply(
727
+ state, ai, TOKEN, fields_filter={"keywords", "whatsNew"}
728
+ )
729
+ self.assertEqual(total, 0)
730
+ self.assertEqual(locales, 0)
731
+ mock_req.assert_not_called()
732
+
733
+ @mock.patch.object(mod, "request")
734
+ def test_filter_none_is_current_behavior(self, mock_req):
735
+ # Regression: fields_filter omitted/None PATCHes every empty field.
736
+ state = _state(
737
+ empty_fields={"en-US": ["name", "description"]},
738
+ localizations={"en-US": _loc("app-en", "ver-en")},
739
+ )
740
+ ai = {"localizations": {"en-US": {"name": "MyApp", "description": "D"}}}
741
+ total, _ = mod.apply(state, ai, TOKEN)
742
+ self.assertEqual(total, 2)
743
+ self.assertEqual(mock_req.call_count, 2)
744
+
745
+ @mock.patch.object(mod, "request")
746
+ def test_empty_filter_set_writes_nothing(self, mock_req):
747
+ # Defensive: an explicitly empty filter set (no fields allowed)
748
+ # must skip every locale without touching the API.
749
+ state = _state(
750
+ empty_fields={"en-US": ["name", "description"]},
751
+ localizations={"en-US": _loc("app-en", "ver-en")},
752
+ )
753
+ ai = {"localizations": {"en-US": {"name": "MyApp", "description": "D"}}}
754
+ total, locales = mod.apply(state, ai, TOKEN, fields_filter=set())
755
+ self.assertEqual(total, 0)
756
+ self.assertEqual(locales, 0)
757
+ mock_req.assert_not_called()
758
+
759
+
760
+ class MainFieldsFilterTests(unittest.TestCase):
761
+ """CLI surface: --fields-filter is parsed + threaded through apply()."""
762
+
763
+ @mock.patch.dict("os.environ", ENV_FAKE)
764
+ @mock.patch.object(mod, "make_jwt", return_value=TOKEN)
765
+ @mock.patch.object(mod, "request")
766
+ def test_cli_filter_description_only(self, mock_req, mock_jwt):
767
+ state = _state(
768
+ empty_fields={"en-US": ["description", "keywords"]},
769
+ localizations={"en-US": _loc("app-en", "ver-en")},
770
+ )
771
+ ai = {
772
+ "localizations": {
773
+ "en-US": {"description": "Desc", "keywords": "k1,k2"}
774
+ }
775
+ }
776
+ rc = _run_main(state, ai, fields_filter="description")
777
+ self.assertEqual(rc, 0)
778
+ self.assertEqual(mock_req.call_count, 1)
779
+ attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
780
+ self.assertEqual(attrs, {"description": "Desc"})
781
+
782
+ @mock.patch.dict("os.environ", ENV_FAKE)
783
+ @mock.patch.object(mod, "make_jwt", return_value=TOKEN)
784
+ @mock.patch.object(mod, "request")
785
+ def test_cli_filter_phase2_subset(self, mock_req, mock_jwt):
786
+ state = _state(
787
+ empty_fields={"en-US": [
788
+ "name", "subtitle", "description",
789
+ "keywords", "promotionalText", "whatsNew",
790
+ ]},
791
+ localizations={"en-US": _loc("app-en", "ver-en")},
792
+ )
793
+ ai = {
794
+ "localizations": {
795
+ "en-US": {
796
+ "name": "MyApp",
797
+ "subtitle": "Cool",
798
+ "description": "stale",
799
+ "keywords": "k",
800
+ "promotionalText": "p",
801
+ "whatsNew": "w",
802
+ }
803
+ }
804
+ }
805
+ rc = _run_main(
806
+ state, ai,
807
+ fields_filter="name,subtitle,keywords,promotionalText,whatsNew",
808
+ )
809
+ self.assertEqual(rc, 0)
810
+ patched = []
811
+ for call in mock_req.call_args_list:
812
+ attrs = call.kwargs["json_body"]["data"]["attributes"]
813
+ patched.extend(attrs.keys())
814
+ self.assertEqual(
815
+ sorted(patched),
816
+ ["keywords", "name", "promotionalText", "subtitle", "whatsNew"],
817
+ )
818
+ self.assertNotIn("description", patched)
819
+
820
+ @mock.patch.dict("os.environ", ENV_FAKE)
821
+ @mock.patch.object(mod, "make_jwt", return_value=TOKEN)
822
+ @mock.patch.object(mod, "request")
823
+ def test_cli_filter_omitted_unchanged(self, mock_req, mock_jwt):
824
+ state = _state(
825
+ empty_fields={"en-US": ["name", "description"]},
826
+ localizations={"en-US": _loc("app-en", "ver-en")},
827
+ )
828
+ ai = {"localizations": {"en-US": {"name": "MyApp", "description": "D"}}}
829
+ rc = _run_main(state, ai)
830
+ self.assertEqual(rc, 0)
831
+ self.assertEqual(mock_req.call_count, 2)
832
+
833
+
834
+ class NormalizeWhitespaceTests(unittest.TestCase):
835
+ def test_collapses_embedded_newlines(self):
836
+ self.assertEqual(mod._normalize_whitespace("a\nb"), "a b")
837
+
838
+ def test_collapses_mixed_whitespace_runs(self):
839
+ self.assertEqual(mod._normalize_whitespace("a \n\t b"), "a b")
840
+
841
+ def test_strips_leading_trailing_whitespace(self):
842
+ self.assertEqual(mod._normalize_whitespace(" hi\n"), "hi")
843
+
844
+ def test_non_string_passthrough(self):
845
+ self.assertEqual(mod._normalize_whitespace(None), None)
846
+ self.assertEqual(mod._normalize_whitespace(42), 42)
847
+
848
+
849
+ if __name__ == "__main__":
850
+ unittest.main()