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,298 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for resolve_marketing_version.py."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import io
7
+ import os
8
+ import plistlib
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import unittest
13
+ from contextlib import redirect_stderr, redirect_stdout
14
+ from pathlib import Path
15
+ from unittest import mock
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
18
+
19
+ import resolve_marketing_version as rmv # noqa: E402
20
+
21
+
22
+ class _Env:
23
+ """Scoped env override that restores prior state on exit."""
24
+
25
+ def __init__(self, mapping: dict[str, str | None]) -> None:
26
+ self._mapping = mapping
27
+ self._prev: dict[str, str | None] = {}
28
+
29
+ def __enter__(self) -> "_Env":
30
+ for k, v in self._mapping.items():
31
+ self._prev[k] = os.environ.get(k)
32
+ if v is None:
33
+ os.environ.pop(k, None)
34
+ else:
35
+ os.environ[k] = v
36
+ return self
37
+
38
+ def __exit__(self, *_exc) -> None:
39
+ for k, prev in self._prev.items():
40
+ if prev is None:
41
+ os.environ.pop(k, None)
42
+ else:
43
+ os.environ[k] = prev
44
+
45
+
46
+ def _fake_run(stdout: str = "", returncode: int = 0):
47
+ """Build a fake subprocess.run replacement returning stdout."""
48
+ def runner(cmd, check=False, capture_output=True, text=True, timeout=None):
49
+ return subprocess.CompletedProcess(
50
+ cmd, returncode=returncode, stdout=stdout, stderr="",
51
+ )
52
+ return runner
53
+
54
+
55
+ class GrepSettingTests(unittest.TestCase):
56
+ def test_grep_marketing_version(self):
57
+ text = (
58
+ " OTHER = foo\n"
59
+ " MARKETING_VERSION = 1.2.3\n"
60
+ " SOMETHING_ELSE = bar\n"
61
+ )
62
+ self.assertEqual(rmv._grep_setting(text, "MARKETING_VERSION"), "1.2.3")
63
+
64
+ def test_grep_returns_empty_on_miss(self):
65
+ text = " OTHER = foo\n"
66
+ self.assertEqual(rmv._grep_setting(text, "MARKETING_VERSION"), "")
67
+
68
+ def test_grep_strips_surrounding_whitespace(self):
69
+ text = " MARKETING_VERSION = 2.0 \n"
70
+ self.assertEqual(rmv._grep_setting(text, "MARKETING_VERSION"), "2.0")
71
+
72
+
73
+ class ResolveTests(unittest.TestCase):
74
+ """End-to-end resolution paths via main() exercise."""
75
+
76
+ def test_marketing_version_from_build_settings(self):
77
+ stdout = " MARKETING_VERSION = 1.1.2\n INFOPLIST_FILE = Info.plist\n"
78
+ out_buf = io.StringIO()
79
+ err_buf = io.StringIO()
80
+ env = {"PROJECT": "App.xcodeproj", "WORKSPACE": "",
81
+ "SCHEME": "App", "CONFIGURATION": "Release"}
82
+ with _Env(env), \
83
+ mock.patch.object(rmv.subprocess, "run", side_effect=_fake_run(stdout)), \
84
+ redirect_stdout(out_buf), redirect_stderr(err_buf):
85
+ rmv.main()
86
+ self.assertEqual(out_buf.getvalue().strip(), "MARKETING_VERSION=1.1.2")
87
+ self.assertIn("Resolved MARKETING_VERSION=1.1.2", err_buf.getvalue())
88
+ self.assertIn("xcodebuild -showBuildSettings", err_buf.getvalue())
89
+
90
+ def test_falls_back_to_info_plist(self):
91
+ with tempfile.TemporaryDirectory() as tmp:
92
+ tmp_dir = Path(tmp)
93
+ plist_path = tmp_dir / "Info.plist"
94
+ with open(plist_path, "wb") as fh:
95
+ plistlib.dump({"CFBundleShortVersionString": "1.0.7"}, fh)
96
+ stdout = f" INFOPLIST_FILE = {plist_path.name}\n"
97
+ project = str(tmp_dir / "App.xcodeproj")
98
+ out_buf = io.StringIO()
99
+ err_buf = io.StringIO()
100
+ env = {"PROJECT": project, "WORKSPACE": "",
101
+ "SCHEME": "App", "CONFIGURATION": "Release"}
102
+ with _Env(env), \
103
+ mock.patch.object(
104
+ rmv.subprocess, "run", side_effect=_fake_run(stdout)), \
105
+ redirect_stdout(out_buf), redirect_stderr(err_buf):
106
+ rmv.main()
107
+ self.assertEqual(out_buf.getvalue().strip(),
108
+ "MARKETING_VERSION=1.0.7")
109
+ self.assertIn("Info.plist CFBundleShortVersionString",
110
+ err_buf.getvalue())
111
+
112
+ def test_empty_settings_exits_1_with_migration_hint(self):
113
+ out_buf = io.StringIO()
114
+ err_buf = io.StringIO()
115
+ env = {"PROJECT": "App.xcodeproj", "WORKSPACE": "",
116
+ "SCHEME": "App", "CONFIGURATION": "Release"}
117
+ with _Env(env), \
118
+ mock.patch.object(rmv.subprocess, "run", side_effect=_fake_run("")), \
119
+ redirect_stdout(out_buf), redirect_stderr(err_buf):
120
+ with self.assertRaises(SystemExit) as ctx:
121
+ rmv.main()
122
+ self.assertEqual(ctx.exception.code, 1)
123
+ self.assertEqual(out_buf.getvalue(), "")
124
+ stderr = err_buf.getvalue()
125
+ self.assertIn("::error::", stderr)
126
+ self.assertIn("MARKETING_VERSION", stderr)
127
+ self.assertIn("MIGRATION.md", stderr)
128
+
129
+ def test_invalid_semver_in_settings_exits_1(self):
130
+ stdout = " MARKETING_VERSION = banana\n"
131
+ out_buf = io.StringIO()
132
+ err_buf = io.StringIO()
133
+ env = {"PROJECT": "App.xcodeproj", "WORKSPACE": "",
134
+ "SCHEME": "App", "CONFIGURATION": "Release"}
135
+ with _Env(env), \
136
+ mock.patch.object(rmv.subprocess, "run", side_effect=_fake_run(stdout)), \
137
+ redirect_stdout(out_buf), redirect_stderr(err_buf):
138
+ with self.assertRaises(SystemExit) as ctx:
139
+ rmv.main()
140
+ self.assertEqual(ctx.exception.code, 1)
141
+ self.assertIn("::error::", err_buf.getvalue())
142
+
143
+ def test_subprocess_error_falls_through_to_error(self):
144
+ def raiser(*_a, **_kw):
145
+ raise OSError("xcodebuild not found")
146
+ out_buf = io.StringIO()
147
+ err_buf = io.StringIO()
148
+ env = {"PROJECT": "App.xcodeproj", "WORKSPACE": "",
149
+ "SCHEME": "App", "CONFIGURATION": "Release"}
150
+ with _Env(env), \
151
+ mock.patch.object(rmv.subprocess, "run", side_effect=raiser), \
152
+ redirect_stdout(out_buf), redirect_stderr(err_buf):
153
+ with self.assertRaises(SystemExit) as ctx:
154
+ rmv.main()
155
+ self.assertEqual(ctx.exception.code, 1)
156
+ self.assertIn("::error::", err_buf.getvalue())
157
+
158
+ def test_project_takes_precedence_over_workspace(self):
159
+ # MARKETING_VERSION is an app-target setting; the bare -project
160
+ # avoids evaluating the whole CocoaPods workspace graph, which can
161
+ # time out -showBuildSettings on CI runners.
162
+ captured: dict = {}
163
+
164
+ def runner(cmd, **_kw):
165
+ captured["cmd"] = cmd
166
+ return subprocess.CompletedProcess(
167
+ cmd, returncode=0, stdout=" MARKETING_VERSION = 1.0\n", stderr="",
168
+ )
169
+
170
+ env = {"WORKSPACE": "App.xcworkspace", "PROJECT": "App.xcodeproj",
171
+ "SCHEME": "App", "CONFIGURATION": "Release"}
172
+ out_buf = io.StringIO()
173
+ err_buf = io.StringIO()
174
+ with _Env(env), \
175
+ mock.patch.object(rmv.subprocess, "run", side_effect=runner), \
176
+ redirect_stdout(out_buf), redirect_stderr(err_buf):
177
+ rmv.main()
178
+ self.assertIn("-project", captured["cmd"])
179
+ self.assertIn("App.xcodeproj", captured["cmd"])
180
+ self.assertNotIn("-workspace", captured["cmd"])
181
+ self.assertEqual(out_buf.getvalue().strip(),
182
+ "MARKETING_VERSION=1.0")
183
+
184
+ def test_workspace_used_when_no_project(self):
185
+ captured: dict = {}
186
+
187
+ def runner(cmd, **_kw):
188
+ captured["cmd"] = cmd
189
+ return subprocess.CompletedProcess(
190
+ cmd, returncode=0, stdout=" MARKETING_VERSION = 1.0\n", stderr="",
191
+ )
192
+
193
+ env = {"WORKSPACE": "App.xcworkspace", "PROJECT": "",
194
+ "SCHEME": "App", "CONFIGURATION": "Release"}
195
+ out_buf = io.StringIO()
196
+ err_buf = io.StringIO()
197
+ with _Env(env), \
198
+ mock.patch.object(rmv.subprocess, "run", side_effect=runner), \
199
+ redirect_stdout(out_buf), redirect_stderr(err_buf):
200
+ rmv.main()
201
+ self.assertIn("-workspace", captured["cmd"])
202
+ self.assertNotIn("-project", captured["cmd"])
203
+
204
+
205
+ class FlutterVarRefExpansionTests(unittest.TestCase):
206
+ """Flutter templates put $(FLUTTER_BUILD_NAME) in the plist; the real
207
+ value comes from Generated.xcconfig and shows up in build settings."""
208
+
209
+ def test_expands_flutter_build_name_from_settings(self):
210
+ with tempfile.TemporaryDirectory() as tmp:
211
+ plist_path = Path(tmp) / "Info.plist"
212
+ with open(plist_path, "wb") as fh:
213
+ plistlib.dump(
214
+ {"CFBundleShortVersionString": "$(FLUTTER_BUILD_NAME)"}, fh)
215
+ stdout = (
216
+ f" INFOPLIST_FILE = {plist_path.name}\n"
217
+ " FLUTTER_BUILD_NAME = 1.2.0\n"
218
+ )
219
+ project = str(Path(tmp) / "Runner.xcodeproj")
220
+ out_buf = io.StringIO()
221
+ err_buf = io.StringIO()
222
+ env = {"PROJECT": project, "WORKSPACE": "",
223
+ "SCHEME": "Runner", "CONFIGURATION": "Release"}
224
+ with _Env(env), \
225
+ mock.patch.object(
226
+ rmv.subprocess, "run", side_effect=_fake_run(stdout)), \
227
+ redirect_stdout(out_buf), redirect_stderr(err_buf):
228
+ rmv.main()
229
+ self.assertEqual(out_buf.getvalue().strip(),
230
+ "MARKETING_VERSION=1.2.0")
231
+ self.assertIn("expanded via build settings", err_buf.getvalue())
232
+
233
+ def test_unresolvable_reference_exits_1(self):
234
+ with tempfile.TemporaryDirectory() as tmp:
235
+ plist_path = Path(tmp) / "Info.plist"
236
+ with open(plist_path, "wb") as fh:
237
+ plistlib.dump(
238
+ {"CFBundleShortVersionString": "$(NOT_DEFINED_ANYWHERE)"}, fh)
239
+ stdout = f" INFOPLIST_FILE = {plist_path.name}\n"
240
+ project = str(Path(tmp) / "Runner.xcodeproj")
241
+ env = {"PROJECT": project, "WORKSPACE": "",
242
+ "SCHEME": "Runner", "CONFIGURATION": "Release"}
243
+ out_buf = io.StringIO()
244
+ err_buf = io.StringIO()
245
+ with _Env(env), \
246
+ mock.patch.object(
247
+ rmv.subprocess, "run", side_effect=_fake_run(stdout)), \
248
+ redirect_stdout(out_buf), redirect_stderr(err_buf):
249
+ with self.assertRaises(SystemExit) as ctx:
250
+ rmv.main()
251
+ self.assertEqual(ctx.exception.code, 1)
252
+ self.assertIn("::error::", err_buf.getvalue())
253
+
254
+ def test_expands_braced_and_modifier_forms(self):
255
+ settings = " FLUTTER_BUILD_NAME = 2.1.0\n"
256
+ self.assertEqual(
257
+ rmv._expand_setting_refs("${FLUTTER_BUILD_NAME}", settings),
258
+ "2.1.0")
259
+ self.assertEqual(
260
+ rmv._expand_setting_refs(
261
+ "$(FLUTTER_BUILD_NAME:default=1.0)", settings),
262
+ "2.1.0")
263
+
264
+
265
+ class PlistEdgeCasesTests(unittest.TestCase):
266
+ def test_missing_short_version_string_returns_empty(self):
267
+ with tempfile.TemporaryDirectory() as tmp:
268
+ plist_path = Path(tmp) / "Info.plist"
269
+ with open(plist_path, "wb") as fh:
270
+ plistlib.dump({"CFBundleName": "App"}, fh) # no version key
271
+ stdout = f" INFOPLIST_FILE = {plist_path.name}\n"
272
+ project = str(Path(tmp) / "App.xcodeproj")
273
+ env = {"PROJECT": project, "WORKSPACE": "",
274
+ "SCHEME": "App", "CONFIGURATION": "Release"}
275
+ with _Env(env), \
276
+ mock.patch.object(
277
+ rmv.subprocess, "run", side_effect=_fake_run(stdout)):
278
+ value, source = rmv._resolve()
279
+ self.assertEqual(value, "")
280
+ self.assertEqual(source, "")
281
+
282
+ def test_corrupt_plist_falls_through_safely(self):
283
+ with tempfile.TemporaryDirectory() as tmp:
284
+ plist_path = Path(tmp) / "Info.plist"
285
+ plist_path.write_bytes(b"not a plist")
286
+ stdout = f" INFOPLIST_FILE = {plist_path.name}\n"
287
+ project = str(Path(tmp) / "App.xcodeproj")
288
+ env = {"PROJECT": project, "WORKSPACE": "",
289
+ "SCHEME": "App", "CONFIGURATION": "Release"}
290
+ with _Env(env), \
291
+ mock.patch.object(
292
+ rmv.subprocess, "run", side_effect=_fake_run(stdout)):
293
+ value, _source = rmv._resolve()
294
+ self.assertEqual(value, "")
295
+
296
+
297
+ if __name__ == "__main__":
298
+ unittest.main()
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for ``asc_version_create.reuse_stale_editable`` — the
4
+ PATCH-rename / DELETE-then-POST fallback / failure-mode helper.
5
+
6
+ Split out of ``test_mmv_decide_create.py`` (Issue 15: file
7
+ function-count cap) so that:
8
+ - ``test_reuse_stale_editable.py`` owns the asc_version_create
9
+ seam: PATCH succeeds, PATCH fails -> DELETE+POST, PATCH+DELETE
10
+ both fail -> exit 5, full-flow logging.
11
+ - ``test_mmv_decide_create.py`` owns the orchestrator
12
+ decisions made via ``_stale_editable_id`` and ``decide_create``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import io
18
+ import json
19
+ import sys
20
+ import unittest
21
+ from contextlib import redirect_stderr
22
+ from pathlib import Path
23
+
24
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
25
+
26
+ import asc_version_create # noqa: E402
27
+
28
+
29
+ class _FakeResp:
30
+ def __init__(self, status_code: int, *, payload: dict | None = None,
31
+ text: str = "", headers: dict | None = None) -> None:
32
+ self.status_code = status_code
33
+ self.text = text if text else (json.dumps(payload) if payload else "")
34
+ self.headers = headers or {}
35
+ self._payload = payload
36
+
37
+ def json(self) -> dict:
38
+ return self._payload or {}
39
+
40
+
41
+ class ReuseStaleEditablePatchTests(unittest.TestCase):
42
+ """PATCH succeeds: stale editable is renamed to target."""
43
+
44
+ def test_stale_editable_patch_path_reuses_and_skips_post(self):
45
+ patch_calls: list[dict] = []
46
+ post_calls: list[str] = []
47
+ delete_calls: list[str] = []
48
+
49
+ def fake_patch(app_id, existing_id, target, token):
50
+ patch_calls.append({
51
+ "app_id": app_id, "existing_id": existing_id,
52
+ "target": target, "token": token,
53
+ })
54
+ return _FakeResp(200, payload={
55
+ "data": {
56
+ "id": existing_id,
57
+ "type": "appStoreVersions",
58
+ "attributes": {"versionString": target},
59
+ },
60
+ })
61
+
62
+ def fake_post(app_id, version, token):
63
+ post_calls.append(version)
64
+ return _FakeResp(201, payload={"data": {"id": "should-not-happen"}})
65
+
66
+ def fake_delete(app_id, existing_id, token):
67
+ delete_calls.append(existing_id)
68
+ return _FakeResp(204)
69
+
70
+ buf = io.StringIO()
71
+ with redirect_stderr(buf):
72
+ got = asc_version_create.reuse_stale_editable(
73
+ app_id="111", existing_id="ed-101",
74
+ target_version="1.0.6", token="tok",
75
+ patch_fn=fake_patch, delete_fn=fake_delete,
76
+ post_fn=fake_post,
77
+ )
78
+
79
+ self.assertEqual(len(patch_calls), 1)
80
+ self.assertEqual(patch_calls[0]["existing_id"], "ed-101")
81
+ self.assertEqual(patch_calls[0]["target"], "1.0.6")
82
+ self.assertEqual(post_calls, [])
83
+ self.assertEqual(delete_calls, [])
84
+ self.assertEqual(got, "ed-101")
85
+
86
+
87
+ class ReuseStaleEditablePatchFailsDeleteCreateTests(unittest.TestCase):
88
+ def test_stale_editable_patch_fails_falls_back_to_delete_create(self):
89
+ sequence: list[str] = []
90
+
91
+ def fake_patch(app_id, existing_id, target, token):
92
+ sequence.append("PATCH")
93
+ return _FakeResp(409, payload={
94
+ "errors": [{"code": "SOME.REJECTION", "detail": "nope"}],
95
+ })
96
+
97
+ def fake_delete(app_id, existing_id, token):
98
+ sequence.append(f"DELETE:{existing_id}")
99
+ return _FakeResp(204)
100
+
101
+ def fake_post(app_id, version, token):
102
+ sequence.append(f"POST:{version}")
103
+ return _FakeResp(201, payload={"data": {"id": "new-106"}})
104
+
105
+ buf = io.StringIO()
106
+ with redirect_stderr(buf):
107
+ got = asc_version_create.reuse_stale_editable(
108
+ app_id="111", existing_id="ed-101",
109
+ target_version="1.0.6", token="tok",
110
+ patch_fn=fake_patch, delete_fn=fake_delete,
111
+ post_fn=fake_post,
112
+ )
113
+
114
+ self.assertEqual(sequence, ["PATCH", "DELETE:ed-101", "POST:1.0.6"])
115
+ self.assertEqual(got, "new-106")
116
+
117
+
118
+ class ReuseStaleEditableDeleteFailsTests(unittest.TestCase):
119
+ def test_stale_editable_delete_fails_exits_clean(self):
120
+ def fake_patch(app_id, existing_id, target, token):
121
+ return _FakeResp(409, payload={
122
+ "errors": [{"code": "SOME.REJECTION", "detail": "nope"}],
123
+ })
124
+
125
+ def fake_delete(app_id, existing_id, token):
126
+ return _FakeResp(409, payload={
127
+ "errors": [{"code": "ENTITY_ERROR.STATE_INVALID",
128
+ "detail": "version locked by review"}],
129
+ })
130
+
131
+ def fake_post(app_id, version, token):
132
+ raise AssertionError("POST must NOT be called when DELETE fails")
133
+
134
+ buf = io.StringIO()
135
+ with redirect_stderr(buf):
136
+ with self.assertRaises(SystemExit) as ctx:
137
+ asc_version_create.reuse_stale_editable(
138
+ app_id="111", existing_id="ed-101",
139
+ target_version="1.0.6", token="tok",
140
+ patch_fn=fake_patch, delete_fn=fake_delete,
141
+ post_fn=fake_post,
142
+ )
143
+
144
+ self.assertEqual(ctx.exception.code, 5)
145
+ stderr = buf.getvalue()
146
+ self.assertIn("ed-101", stderr)
147
+
148
+
149
+ class ReuseStaleEditableLoggingTests(unittest.TestCase):
150
+ def test_reuse_stale_editable_logs_full_flow(self):
151
+ def fake_patch(app_id, existing_id, target, token):
152
+ return _FakeResp(409, payload={
153
+ "errors": [{"code": "SOME.REJECTION", "detail": "nope"}],
154
+ })
155
+
156
+ def fake_delete(app_id, existing_id, token):
157
+ return _FakeResp(204)
158
+
159
+ def fake_post(app_id, version, token):
160
+ return _FakeResp(201, payload={"data": {"id": "new-106"}})
161
+
162
+ buf = io.StringIO()
163
+ with redirect_stderr(buf):
164
+ asc_version_create.reuse_stale_editable(
165
+ app_id="111", existing_id="ed-101",
166
+ target_version="1.0.6", token="tok",
167
+ patch_fn=fake_patch, delete_fn=fake_delete,
168
+ post_fn=fake_post,
169
+ )
170
+
171
+ stderr = buf.getvalue()
172
+ self.assertIn("[reuse-stale]", stderr)
173
+ self.assertIn("PATCH", stderr)
174
+ self.assertIn("ed-101", stderr)
175
+ self.assertIn("1.0.6", stderr)
176
+ self.assertIn("PATCH failed", stderr)
177
+ self.assertIn("DELETE", stderr)
178
+ self.assertIn("retrying POST", stderr)
179
+
180
+
181
+ if __name__ == "__main__":
182
+ unittest.main()
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for set_app_store_whats_new.py's fill-empty backstop mode."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+ import unittest
8
+ from pathlib import Path
9
+ from unittest import mock
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
12
+
13
+ import set_app_store_whats_new as sw # noqa: E402
14
+
15
+
16
+ def _loc(loc_id, locale, whats_new=None):
17
+ attrs = {"locale": locale}
18
+ if whats_new is not None:
19
+ attrs["whatsNew"] = whats_new
20
+ return {"id": loc_id, "attributes": attrs}
21
+
22
+
23
+ class UpdateAllLocalizationsTests(unittest.TestCase):
24
+ def _run(self, entries, only_if_empty):
25
+ patched = []
26
+
27
+ def fake_patch(token, loc_id, whats_new):
28
+ patched.append(loc_id)
29
+ return True
30
+
31
+ with mock.patch.object(sw, "get_json", return_value={"data": entries}), \
32
+ mock.patch.object(sw, "_patch_localization", side_effect=fake_patch):
33
+ count = sw._update_all_localizations(
34
+ "tok", "v1", "1.0.1", "DEFAULT", "en-US",
35
+ only_if_empty=only_if_empty)
36
+ return count, patched
37
+
38
+ def test_force_mode_patches_every_locale(self):
39
+ entries = [_loc("a", "en-US", "existing"), _loc("b", "ru", ""),
40
+ _loc("c", "de", None)]
41
+ count, patched = self._run(entries, only_if_empty=False)
42
+ self.assertEqual(count, 3)
43
+ self.assertEqual(set(patched), {"a", "b", "c"})
44
+
45
+ def test_backstop_fills_only_empty_locales(self):
46
+ entries = [_loc("a", "en-US", "AI generated notes"),
47
+ _loc("b", "ru", ""), # empty -> fill
48
+ _loc("c", "de", " "), # blank whitespace -> fill
49
+ _loc("d", "fr", None)] # missing -> fill
50
+ count, patched = self._run(entries, only_if_empty=True)
51
+ self.assertEqual(count, 3)
52
+ self.assertEqual(set(patched), {"b", "c", "d"})
53
+ self.assertNotIn("a", patched) # AI content preserved
54
+
55
+ def test_backstop_noop_when_all_present(self):
56
+ entries = [_loc("a", "en-US", "notes"), _loc("b", "ru", "notes")]
57
+ count, patched = self._run(entries, only_if_empty=True)
58
+ self.assertEqual(count, 0)
59
+ self.assertEqual(patched, [])
60
+
61
+ def test_seeds_when_no_localizations(self):
62
+ with mock.patch.object(sw, "get_json", return_value={"data": []}), \
63
+ mock.patch.object(sw, "_create_localization", return_value="new1") as cre:
64
+ count = sw._update_all_localizations(
65
+ "tok", "v1", "1.0.1", "DEFAULT", "en-US", only_if_empty=True)
66
+ self.assertEqual(count, 1)
67
+ cre.assert_called_once()
68
+
69
+
70
+ if __name__ == "__main__":
71
+ unittest.main()