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,221 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for asc_build_history.fetch_prerelease_versions and the
4
+ combined-floor wiring that consumes both /preReleaseVersions and
5
+ /builds?include=preReleaseVersion.
6
+
7
+ Why these live in their own module: they exercise asc_build_history
8
+ directly (not via mmv seams), and they're large enough that bundling
9
+ them with the other floor-check tests pushed test_mmv_floor_check.py
10
+ over the 400-line cap. The /builds?include=preReleaseVersion path is
11
+ covered separately in ``test_asc_builds_prerelease.py``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import io
17
+ import sys
18
+ import unittest
19
+ from contextlib import redirect_stderr
20
+ from pathlib import Path
21
+ from unittest import mock
22
+
23
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
24
+
25
+ import manage_marketing_version as mmv # noqa: E402
26
+ import asc_version_fetch # noqa: E402
27
+ import asc_build_history # noqa: E402
28
+
29
+ from tests_common import _FakeResponse, _mk_prerelease_entry, _v # noqa: E402
30
+
31
+
32
+ class PrereleaseFetchShapeTests(unittest.TestCase):
33
+ """The /preReleaseVersions fetcher must use the correct query shape and
34
+ paginate."""
35
+
36
+ def test_prerelease_fetch_client_side_platform_filter(self):
37
+ seen_params: list[dict | None] = []
38
+ payload = {
39
+ "data": [
40
+ {
41
+ "id": "pr-ios",
42
+ "attributes": {"version": "1.0.1", "platform": "IOS"},
43
+ },
44
+ {
45
+ "id": "pr-mac",
46
+ "attributes": {"version": "1.0.2", "platform": "MAC_OS"},
47
+ },
48
+ {
49
+ "id": "pr-null",
50
+ "attributes": {"version": "1.0.3"},
51
+ },
52
+ ],
53
+ "links": {},
54
+ }
55
+
56
+ def fake_request(method, path, token, *, params=None, **_kw):
57
+ seen_params.append(params)
58
+ return _FakeResponse(payload)
59
+
60
+ buf = io.StringIO()
61
+ with mock.patch.object(
62
+ asc_build_history, "request", side_effect=fake_request), \
63
+ redirect_stderr(buf):
64
+ got = asc_build_history.fetch_prerelease_versions("111", "tok")
65
+
66
+ self.assertTrue(seen_params)
67
+ first = seen_params[0] or {}
68
+ self.assertNotIn("filter[platform]", first)
69
+ self.assertEqual(first.get("limit"), 200)
70
+ self.assertEqual(sorted(got), ["1.0.1", "1.0.3"])
71
+ stderr = buf.getvalue()
72
+ self.assertIn("version=1.0.1", stderr)
73
+ self.assertIn("platform=IOS", stderr)
74
+ self.assertIn("pr-mac", stderr)
75
+ self.assertIn("MAC_OS", stderr)
76
+ self.assertIn("not-ios", stderr)
77
+ self.assertIn("pr-null", stderr)
78
+
79
+ def test_prerelease_fetch_combined_floor_only_counts_ios(self):
80
+ payload = {
81
+ "data": [
82
+ {
83
+ "id": "pr-ios",
84
+ "attributes": {"version": "1.0.4", "platform": "IOS"},
85
+ },
86
+ {
87
+ "id": "pr-mac-high",
88
+ "attributes": {"version": "9.9.9", "platform": "MAC_OS"},
89
+ },
90
+ ],
91
+ "links": {},
92
+ }
93
+
94
+ def fake_request(method, path, token, *, params=None, **_kw):
95
+ return _FakeResponse(payload)
96
+
97
+ with mock.patch.object(
98
+ asc_build_history, "request", side_effect=fake_request):
99
+ floor, _ = mmv.get_combined_floor(
100
+ "111", "tok", appstore_versions=[],
101
+ )
102
+ self.assertEqual(floor, "1.0.4")
103
+
104
+ def test_prerelease_fetch_paginated(self):
105
+ seen_params: list[dict | None] = []
106
+ page1 = {
107
+ "data": [
108
+ _mk_prerelease_entry("1.0.1", pid="pr-101"),
109
+ _mk_prerelease_entry("1.0.2", pid="pr-102"),
110
+ ],
111
+ "links": {
112
+ "next": "https://api.appstoreconnect.apple.com/v1/apps/111/"
113
+ "preReleaseVersions?cursor=PAGE2",
114
+ },
115
+ }
116
+ page2 = {
117
+ "data": [_mk_prerelease_entry("1.0.4", pid="pr-104")],
118
+ "links": {},
119
+ }
120
+ responses = [_FakeResponse(page1), _FakeResponse(page2)]
121
+
122
+ def fake_request(method, path, token, *, params=None, **_kw):
123
+ seen_params.append(params)
124
+ return responses.pop(0)
125
+
126
+ with mock.patch.object(
127
+ asc_build_history, "request", side_effect=fake_request):
128
+ got = asc_build_history.fetch_prerelease_versions("111", "tok")
129
+
130
+ self.assertEqual(sorted(got), ["1.0.1", "1.0.2", "1.0.4"])
131
+ self.assertEqual(len(responses), 0)
132
+ for p in seen_params:
133
+ if p is None:
134
+ continue
135
+ self.assertNotIn("filter[platform]", p)
136
+
137
+
138
+ class PlatformNullDefensiveInclusionTests(unittest.TestCase):
139
+ """Null/missing platform must be INCLUDED defensively."""
140
+
141
+ def test_platform_null_record_included_defensively(self):
142
+ payload = {
143
+ "data": [
144
+ {
145
+ "id": "pr-null",
146
+ "attributes": {"version": "1.0.9"},
147
+ },
148
+ ],
149
+ "links": {},
150
+ }
151
+
152
+ def fake_request(method, path, token, *, params=None, **_kw):
153
+ return _FakeResponse(payload)
154
+
155
+ buf = io.StringIO()
156
+ with mock.patch.object(
157
+ asc_build_history, "request", side_effect=fake_request), \
158
+ redirect_stderr(buf):
159
+ versions = asc_build_history.fetch_prerelease_versions(
160
+ "111", "tok",
161
+ )
162
+
163
+ self.assertEqual(versions, ["1.0.9"])
164
+ stderr = buf.getvalue()
165
+ self.assertIn("pr-null", stderr)
166
+ self.assertIn("null", stderr.lower())
167
+
168
+ def test_platform_explicit_non_ios_still_excluded(self):
169
+ payload = {
170
+ "data": [
171
+ {
172
+ "id": "pr-mac",
173
+ "attributes": {"version": "9.9.9", "platform": "MAC_OS"},
174
+ },
175
+ {
176
+ "id": "pr-tv",
177
+ "attributes": {"version": "8.8.8", "platform": "TV_OS"},
178
+ },
179
+ {
180
+ "id": "pr-vision",
181
+ "attributes": {"version": "7.7.7", "platform": "VISION_OS"},
182
+ },
183
+ ],
184
+ "links": {},
185
+ }
186
+
187
+ def fake_request(method, path, token, *, params=None, **_kw):
188
+ return _FakeResponse(payload)
189
+
190
+ with mock.patch.object(
191
+ asc_build_history, "request", side_effect=fake_request):
192
+ versions = asc_build_history.fetch_prerelease_versions(
193
+ "111", "tok",
194
+ )
195
+
196
+ self.assertEqual(versions, [])
197
+
198
+
199
+ class CombinedFloorBuildsViaSourceTests(unittest.TestCase):
200
+ """Combined floor per_source must include all three sources."""
201
+
202
+ def test_per_source_breakdown_includes_buildsViaPreRelease(self):
203
+ with mock.patch.object(
204
+ asc_build_history, "fetch_prerelease_versions",
205
+ return_value=["1.0.4"]), \
206
+ mock.patch.object(
207
+ asc_build_history, "fetch_builds_prerelease_versions",
208
+ return_value=["1.0.7"]):
209
+ floor, per_source = asc_version_fetch.get_combined_floor(
210
+ "111", "tok", mmv.semver_tuple,
211
+ appstore_versions=[_v("1.0", "READY_FOR_SALE")],
212
+ )
213
+
214
+ self.assertEqual(floor, "1.0.7")
215
+ self.assertEqual(per_source.get("appStoreVersions"), "1.0")
216
+ self.assertEqual(per_source.get("preReleaseVersions"), "1.0.4")
217
+ self.assertEqual(per_source.get("buildsViaPreRelease"), "1.0.7")
218
+
219
+
220
+ if __name__ == "__main__":
221
+ unittest.main()
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for asc_build_history.fetch_builds_prerelease_versions, the
4
+ /v1/builds?include=preReleaseVersion path that powers the
5
+ buildsViaPreRelease component of the combined floor.
6
+
7
+ This module owns one large class, ``BuildsPreReleaseRelationshipTests``,
8
+ which was lifted out of ``test_asc_build_history.py`` purely to keep
9
+ that file under the 400-line cap. Both modules share helper imports
10
+ from ``tests_common``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import io
16
+ import sys
17
+ import unittest
18
+ from contextlib import redirect_stderr
19
+ from pathlib import Path
20
+ from unittest import mock
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
23
+
24
+ import manage_marketing_version as mmv # noqa: E402
25
+ import asc_version_fetch # noqa: E402
26
+ import asc_build_history # noqa: E402
27
+
28
+ from tests_common import _FakeResponse, _v # noqa: E402
29
+
30
+
31
+ def _build_data_entry(build_id: str, build_version: str, pr_id: str) -> dict:
32
+ """One /v1/builds.data entry with a preReleaseVersion relationship."""
33
+ return {
34
+ "id": build_id,
35
+ "type": "builds",
36
+ "attributes": {"version": build_version},
37
+ "relationships": {
38
+ "preReleaseVersion": {
39
+ "data": {"type": "preReleaseVersions", "id": pr_id},
40
+ },
41
+ },
42
+ }
43
+
44
+
45
+ def _prerelease_included(
46
+ pr_id: str, pr_version: str, *, platform: str | None = "IOS",
47
+ ) -> dict:
48
+ """One /v1/builds.included entry describing a preReleaseVersion."""
49
+ attrs: dict = {"version": pr_version}
50
+ if platform is not None:
51
+ attrs["platform"] = platform
52
+ return {
53
+ "id": pr_id,
54
+ "type": "preReleaseVersions",
55
+ "attributes": attrs,
56
+ }
57
+
58
+
59
+ def _build_with_prerelease(
60
+ build_id: str, build_version: str,
61
+ pr_id: str, pr_version: str, *, platform: str | None = "IOS",
62
+ ) -> dict:
63
+ """Build the /v1/builds payload pairing one build with one prerelease
64
+ relationship — the canonical fixture shape these tests exercise."""
65
+ return {
66
+ "data": [_build_data_entry(build_id, build_version, pr_id)],
67
+ "included": [
68
+ _prerelease_included(pr_id, pr_version, platform=platform),
69
+ ],
70
+ "links": {},
71
+ }
72
+
73
+
74
+ def _capture_build_history_request(payload: dict):
75
+ """Patch asc_build_history.request to return `payload`. Returns
76
+ (seen_paths, seen_params, ctx) where ctx is the unittest.mock patch."""
77
+ seen_paths: list[str] = []
78
+ seen_params: list[dict | None] = []
79
+
80
+ def fake_request(method, path, token, *, params=None, **_kw):
81
+ seen_paths.append(path)
82
+ seen_params.append(params)
83
+ return _FakeResponse(payload)
84
+
85
+ return seen_paths, seen_params, mock.patch.object(
86
+ asc_build_history, "request", side_effect=fake_request,
87
+ )
88
+
89
+
90
+ class BuildsPreReleaseRelationshipTests(unittest.TestCase):
91
+ """/v1/builds?include=preReleaseVersion reveals preReleaseVersions
92
+ that the direct /preReleaseVersions query might miss."""
93
+
94
+ def test_buildsViaPreRelease_included_in_floor(self):
95
+ payload = _build_with_prerelease(
96
+ "build-1", "42", "pr-ref-106", "1.0.6",
97
+ )
98
+ seen_paths, seen_params, patch_ctx = (
99
+ _capture_build_history_request(payload)
100
+ )
101
+
102
+ buf = io.StringIO()
103
+ with patch_ctx, redirect_stderr(buf):
104
+ versions = asc_build_history.fetch_builds_prerelease_versions(
105
+ "111", "tok",
106
+ )
107
+
108
+ self.assertEqual(versions, ["1.0.6"])
109
+ self.assertEqual(seen_paths[0], "/builds")
110
+ self.assertEqual(seen_params[0].get("filter[app]"), "111")
111
+ self.assertEqual(seen_params[0].get("include"), "preReleaseVersion")
112
+
113
+ with mock.patch.object(
114
+ asc_build_history, "fetch_prerelease_versions",
115
+ return_value=[]), \
116
+ mock.patch.object(
117
+ asc_build_history, "fetch_builds_prerelease_versions",
118
+ return_value=["1.0.6"]):
119
+ floor, per_source = asc_version_fetch.get_combined_floor(
120
+ "111", "tok", mmv.semver_tuple,
121
+ appstore_versions=[_v("1.0", "READY_FOR_SALE")],
122
+ )
123
+
124
+ self.assertEqual(floor, "1.0.6")
125
+ self.assertEqual(per_source.get("buildsViaPreRelease"), "1.0.6")
126
+
127
+ def test_buildsViaPreRelease_uses_top_level_builds_endpoint(self):
128
+ payload = _build_with_prerelease("b1", "1", "pr1", "1.0.9")
129
+ seen_paths, seen_params, patch_ctx = (
130
+ _capture_build_history_request(payload)
131
+ )
132
+
133
+ with patch_ctx:
134
+ versions = asc_build_history.fetch_builds_prerelease_versions(
135
+ "111", "tok",
136
+ )
137
+
138
+ self.assertEqual(versions, ["1.0.9"])
139
+ self.assertEqual(seen_paths[0], "/builds")
140
+ self.assertNotEqual(seen_paths[0], "/apps/111/builds")
141
+ params = seen_params[0] or {}
142
+ self.assertEqual(params.get("filter[app]"), "111")
143
+ self.assertEqual(params.get("include"), "preReleaseVersion")
144
+
145
+ def test_buildsViaPreRelease_excludes_non_ios_platform(self):
146
+ payload = {
147
+ "data": [
148
+ _build_data_entry("build-mac", "1", "pr-ref-mac"),
149
+ _build_data_entry("build-null", "2", "pr-ref-null"),
150
+ ],
151
+ "included": [
152
+ _prerelease_included(
153
+ "pr-ref-mac", "9.9.9", platform="MAC_OS"),
154
+ _prerelease_included(
155
+ "pr-ref-null", "1.0.8", platform=None),
156
+ ],
157
+ "links": {},
158
+ }
159
+ _, _, patch_ctx = _capture_build_history_request(payload)
160
+
161
+ with patch_ctx:
162
+ versions = asc_build_history.fetch_builds_prerelease_versions(
163
+ "111", "tok",
164
+ )
165
+
166
+ self.assertEqual(versions, ["1.0.8"])
167
+
168
+ def test_buildsViaPreRelease_handles_missing_relationship(self):
169
+ payload = {
170
+ "data": [
171
+ {
172
+ "id": "build-orphan",
173
+ "type": "builds",
174
+ "attributes": {"version": "1"},
175
+ "relationships": {},
176
+ },
177
+ ],
178
+ "included": [],
179
+ "links": {},
180
+ }
181
+
182
+ def fake_request(method, path, token, *, params=None, **_kw):
183
+ return _FakeResponse(payload)
184
+
185
+ with mock.patch.object(
186
+ asc_build_history, "request", side_effect=fake_request):
187
+ versions = asc_build_history.fetch_builds_prerelease_versions(
188
+ "111", "tok",
189
+ )
190
+
191
+ self.assertEqual(versions, [])
192
+
193
+ def test_buildsViaPreRelease_paginated(self):
194
+ page1 = _build_with_prerelease("b1", "1", "pr1", "1.0.5")
195
+ page1["links"] = {
196
+ "next": "https://api.appstoreconnect.apple.com/v1/"
197
+ "builds?cursor=PAGE2",
198
+ }
199
+ page2 = _build_with_prerelease("b2", "2", "pr2", "1.0.6")
200
+ responses = [_FakeResponse(page1), _FakeResponse(page2)]
201
+ seen_paths: list[str] = []
202
+
203
+ def fake_request(method, path, token, *, params=None, **_kw):
204
+ seen_paths.append(path)
205
+ return responses.pop(0)
206
+
207
+ with mock.patch.object(
208
+ asc_build_history, "request", side_effect=fake_request):
209
+ versions = asc_build_history.fetch_builds_prerelease_versions(
210
+ "111", "tok",
211
+ )
212
+
213
+ self.assertEqual(sorted(versions), ["1.0.5", "1.0.6"])
214
+ self.assertEqual(len(responses), 0)
215
+ self.assertEqual(seen_paths[0], "/builds")
216
+
217
+
218
+ if __name__ == "__main__":
219
+ unittest.main()
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for round-12 explicit per-request timeouts in ``asc_common.request``.
4
+
5
+ Without an explicit ``timeout`` kwarg, ``requests.request(...)`` blocks
6
+ forever on a hung TCP socket. CI then ticks until the runner's 6h hard
7
+ limit and a human pages on. The tests here pin:
8
+
9
+ * default ``(connect, read)`` = (10.0, 30.0)
10
+ * env overrides ``ASC_REQUEST_TIMEOUT_CONNECT_SEC`` /
11
+ ``ASC_REQUEST_TIMEOUT_READ_SEC`` parse + take effect
12
+ * invalid / negative env values fall back to the defaults so a typo
13
+ can't disable the timeout
14
+ * the timeout is forwarded to ``requests.request`` on every attempt
15
+ (including retries)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import sys
22
+ import unittest
23
+ from pathlib import Path
24
+ from unittest import mock
25
+
26
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
27
+
28
+ import asc_common # noqa: E402
29
+
30
+
31
+ class _OkResponse:
32
+ """Minimal stand-in for a 200 OK response."""
33
+
34
+ status_code = 200
35
+ text = ""
36
+
37
+ def json(self) -> dict:
38
+ return {"data": []}
39
+
40
+
41
+ class RequestTimeoutTests(unittest.TestCase):
42
+ """``request()`` must pass an explicit ``(connect, read)`` timeout."""
43
+
44
+ def _capture_timeout_via_request(self, env: dict) -> tuple[float, float]:
45
+ captured: dict = {}
46
+
47
+ def fake_request(method, url, **kwargs):
48
+ captured["timeout"] = kwargs.get("timeout")
49
+ return _OkResponse()
50
+
51
+ with mock.patch.dict(os.environ, env, clear=False), \
52
+ mock.patch.object(asc_common.requests, "request",
53
+ side_effect=fake_request):
54
+ asc_common.request("GET", "/apps/111", "tok")
55
+
56
+ return captured.get("timeout")
57
+
58
+ def test_default_timeouts_are_10s_connect_30s_read(self):
59
+ timeout = self._capture_timeout_via_request({
60
+ "ASC_REQUEST_TIMEOUT_CONNECT_SEC": "",
61
+ "ASC_REQUEST_TIMEOUT_READ_SEC": "",
62
+ })
63
+ self.assertEqual(timeout, (10.0, 30.0))
64
+
65
+ def test_env_override_takes_effect(self):
66
+ timeout = self._capture_timeout_via_request({
67
+ "ASC_REQUEST_TIMEOUT_CONNECT_SEC": "5",
68
+ "ASC_REQUEST_TIMEOUT_READ_SEC": "60",
69
+ })
70
+ self.assertEqual(timeout, (5.0, 60.0))
71
+
72
+ def test_invalid_env_falls_back_to_default(self):
73
+ """A typo (e.g. ``CONNECT=ten``) MUST NOT disable the timeout."""
74
+ timeout = self._capture_timeout_via_request({
75
+ "ASC_REQUEST_TIMEOUT_CONNECT_SEC": "ten",
76
+ "ASC_REQUEST_TIMEOUT_READ_SEC": "thirty",
77
+ })
78
+ self.assertEqual(timeout, (10.0, 30.0))
79
+
80
+ def test_non_positive_env_falls_back_to_default(self):
81
+ """Zero/negative values would disable the timeout; default instead."""
82
+ timeout = self._capture_timeout_via_request({
83
+ "ASC_REQUEST_TIMEOUT_CONNECT_SEC": "0",
84
+ "ASC_REQUEST_TIMEOUT_READ_SEC": "-5",
85
+ })
86
+ self.assertEqual(timeout, (10.0, 30.0))
87
+
88
+ def test_timeout_forwarded_on_every_retry_attempt(self):
89
+ """A 503 retry must pass the same timeout on the retry call."""
90
+ captured_timeouts: list = []
91
+ responses = iter([_Status(503), _OkResponse()])
92
+
93
+ def fake_request(method, url, **kwargs):
94
+ captured_timeouts.append(kwargs.get("timeout"))
95
+ return next(responses)
96
+
97
+ with mock.patch.dict(
98
+ os.environ,
99
+ {"ASC_REQUEST_TIMEOUT_CONNECT_SEC": "",
100
+ "ASC_REQUEST_TIMEOUT_READ_SEC": ""},
101
+ clear=False), \
102
+ mock.patch.object(asc_common.requests, "request",
103
+ side_effect=fake_request), \
104
+ mock.patch.object(asc_common.time, "sleep"):
105
+ asc_common.request("GET", "/apps/111", "tok")
106
+
107
+ self.assertEqual(len(captured_timeouts), 2)
108
+ self.assertEqual(captured_timeouts[0], (10.0, 30.0))
109
+ self.assertEqual(captured_timeouts[1], (10.0, 30.0))
110
+
111
+
112
+ class _Status:
113
+ """Stand-in for a non-2xx response that should trigger a retry."""
114
+
115
+ def __init__(self, code: int) -> None:
116
+ self.status_code = code
117
+ self.text = f"<error {code}>"
118
+
119
+
120
+ if __name__ == "__main__":
121
+ unittest.main()