2026-05-24 04:38:36 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Tests for deploy_playstore.py."""
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
import unittest
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from unittest.mock import MagicMock, call, patch
|
|
|
|
|
|
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
|
|
|
|
|
|
|
|
import deploy_playstore
|
|
|
|
|
|
|
|
|
|
|
2026-05-24 07:40:17 +02:00
|
|
|
def _make_session(
|
|
|
|
|
edit_id="edit-42",
|
|
|
|
|
version_code=7,
|
|
|
|
|
upload_side_effects=None,
|
|
|
|
|
):
|
|
|
|
|
"""Return a mock AuthorizedSession with sensible defaults."""
|
|
|
|
|
session = MagicMock()
|
|
|
|
|
|
|
|
|
|
# POST /edits → create edit
|
|
|
|
|
edit_resp = MagicMock()
|
|
|
|
|
edit_resp.json.return_value = {"id": edit_id}
|
|
|
|
|
session.post.return_value = edit_resp
|
|
|
|
|
|
|
|
|
|
# POST resumable-init → Location header
|
|
|
|
|
init_resp = MagicMock()
|
|
|
|
|
init_resp.headers = {"Location": "https://upload.example.com/session"}
|
|
|
|
|
|
|
|
|
|
# PUT upload → bundle JSON
|
|
|
|
|
upload_resp = MagicMock()
|
|
|
|
|
upload_resp.json.return_value = {"versionCode": version_code}
|
|
|
|
|
|
|
|
|
|
if upload_side_effects is not None:
|
|
|
|
|
# Use side_effect list: first call is edit create, rest are upload inits
|
|
|
|
|
# We override the PUT side effects via _upload_aab_resumable mock instead
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
return session, init_resp, upload_resp
|
|
|
|
|
|
|
|
|
|
|
2026-05-24 04:38:36 +02:00
|
|
|
class TestMainEnvChecks(unittest.TestCase):
|
|
|
|
|
def test_missing_env_exits(self):
|
|
|
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
|
|
|
with self.assertRaises(SystemExit) as ctx:
|
|
|
|
|
deploy_playstore.main()
|
|
|
|
|
self.assertEqual(ctx.exception.code, 1)
|
|
|
|
|
|
|
|
|
|
def test_missing_aab_exits(self):
|
|
|
|
|
fake_config = '{"type": "service_account"}'
|
|
|
|
|
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": fake_config}):
|
|
|
|
|
with patch("deploy_playstore.os.path.exists", return_value=False):
|
|
|
|
|
with self.assertRaises(SystemExit) as ctx:
|
|
|
|
|
deploy_playstore.main()
|
|
|
|
|
self.assertEqual(ctx.exception.code, 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMainHappyPath(unittest.TestCase):
|
2026-05-24 07:40:17 +02:00
|
|
|
def _run_main(self, fake_config='{"type":"service_account"}'):
|
|
|
|
|
mock_session = MagicMock()
|
|
|
|
|
# POST for edit create and commit
|
|
|
|
|
post_responses = [
|
|
|
|
|
MagicMock(**{"json.return_value": {"id": "edit-42"}}), # create edit
|
|
|
|
|
MagicMock(), # commit
|
|
|
|
|
]
|
|
|
|
|
mock_session.post.side_effect = post_responses
|
|
|
|
|
# PUT for track update
|
|
|
|
|
mock_session.put.return_value = MagicMock()
|
2026-05-24 04:38:36 +02:00
|
|
|
|
|
|
|
|
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": fake_config}):
|
|
|
|
|
with patch("deploy_playstore.os.path.exists", return_value=True):
|
|
|
|
|
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
|
2026-05-24 07:40:17 +02:00
|
|
|
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
|
|
|
|
|
with patch(
|
|
|
|
|
"deploy_playstore._upload_aab_resumable",
|
|
|
|
|
return_value={"versionCode": 7},
|
|
|
|
|
):
|
|
|
|
|
deploy_playstore.main()
|
2026-05-24 04:38:36 +02:00
|
|
|
|
2026-05-24 07:40:17 +02:00
|
|
|
return mock_session
|
2026-05-24 04:38:36 +02:00
|
|
|
|
2026-05-24 07:40:17 +02:00
|
|
|
def test_creates_edit(self):
|
|
|
|
|
session = self._run_main()
|
|
|
|
|
create_call = session.post.call_args_list[0]
|
|
|
|
|
self.assertIn("/edits", create_call[0][0])
|
2026-05-24 04:38:36 +02:00
|
|
|
|
2026-05-24 07:40:17 +02:00
|
|
|
def test_commits_edit(self):
|
|
|
|
|
session = self._run_main()
|
|
|
|
|
commit_call = session.post.call_args_list[1]
|
|
|
|
|
self.assertIn(":commit", commit_call[0][0])
|
2026-05-24 04:38:36 +02:00
|
|
|
|
2026-05-24 07:40:17 +02:00
|
|
|
def test_updates_track(self):
|
|
|
|
|
session = self._run_main()
|
|
|
|
|
track_call = session.put.call_args_list[0]
|
|
|
|
|
self.assertIn("/tracks/", track_call[0][0])
|
2026-05-24 04:59:05 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestUploadRetry(unittest.TestCase):
|
2026-05-24 07:40:17 +02:00
|
|
|
def _run_main(self, upload_side_effects, sleep_mock=None):
|
|
|
|
|
mock_session = MagicMock()
|
|
|
|
|
post_responses = [
|
|
|
|
|
MagicMock(**{"json.return_value": {"id": "edit-1"}}),
|
|
|
|
|
MagicMock(),
|
|
|
|
|
]
|
|
|
|
|
mock_session.post.side_effect = post_responses
|
|
|
|
|
mock_session.put.return_value = MagicMock()
|
|
|
|
|
|
|
|
|
|
patches = [
|
|
|
|
|
patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}),
|
|
|
|
|
patch("deploy_playstore.os.path.exists", return_value=True),
|
|
|
|
|
patch("deploy_playstore.service_account.Credentials.from_service_account_info"),
|
|
|
|
|
patch("deploy_playstore.AuthorizedSession", return_value=mock_session),
|
|
|
|
|
patch("deploy_playstore._upload_aab_resumable", side_effect=upload_side_effects),
|
|
|
|
|
patch("deploy_playstore.time.sleep"),
|
|
|
|
|
]
|
|
|
|
|
for p in patches:
|
|
|
|
|
p.start()
|
|
|
|
|
try:
|
|
|
|
|
deploy_playstore.main()
|
|
|
|
|
finally:
|
|
|
|
|
for p in patches:
|
|
|
|
|
p.stop()
|
2026-05-24 04:59:05 +02:00
|
|
|
|
|
|
|
|
def test_succeeds_on_first_attempt(self):
|
2026-05-24 07:40:17 +02:00
|
|
|
with patch("deploy_playstore._upload_aab_resumable", return_value={"versionCode": 5}) as mock_upload:
|
|
|
|
|
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
|
|
|
|
|
with patch("deploy_playstore.os.path.exists", return_value=True):
|
|
|
|
|
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
|
|
|
|
|
mock_session = MagicMock()
|
|
|
|
|
mock_session.post.side_effect = [
|
|
|
|
|
MagicMock(**{"json.return_value": {"id": "e1"}}),
|
|
|
|
|
MagicMock(),
|
|
|
|
|
]
|
|
|
|
|
mock_session.put.return_value = MagicMock()
|
|
|
|
|
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
|
|
|
|
|
deploy_playstore.main()
|
|
|
|
|
mock_upload.assert_called_once()
|
|
|
|
|
|
|
|
|
|
def test_retries_once_on_error_then_succeeds(self):
|
|
|
|
|
self._run_main([ValueError("transient"), {"versionCode": 9}])
|
2026-05-24 04:59:05 +02:00
|
|
|
|
|
|
|
|
def test_raises_after_all_attempts_exhausted(self):
|
2026-05-24 07:40:17 +02:00
|
|
|
with self.assertRaises(RuntimeError) as ctx:
|
|
|
|
|
self._run_main([ValueError("err"), ValueError("err"), ValueError("err")])
|
|
|
|
|
self.assertIn(str(deploy_playstore._MAX_UPLOAD_ATTEMPTS), str(ctx.exception))
|
2026-05-24 04:59:05 +02:00
|
|
|
|
|
|
|
|
def test_backoff_delays_are_10s_then_20s(self):
|
2026-05-24 07:40:17 +02:00
|
|
|
mock_session = MagicMock()
|
|
|
|
|
mock_session.post.side_effect = [
|
|
|
|
|
MagicMock(**{"json.return_value": {"id": "e1"}}),
|
|
|
|
|
MagicMock(),
|
|
|
|
|
]
|
|
|
|
|
mock_session.put.return_value = MagicMock()
|
2026-05-24 04:59:05 +02:00
|
|
|
|
|
|
|
|
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
|
|
|
|
|
with patch("deploy_playstore.os.path.exists", return_value=True):
|
|
|
|
|
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
|
2026-05-24 07:40:17 +02:00
|
|
|
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
|
|
|
|
|
with patch(
|
|
|
|
|
"deploy_playstore._upload_aab_resumable",
|
|
|
|
|
side_effect=[ValueError("e"), ValueError("e"), {"versionCode": 3}],
|
|
|
|
|
):
|
|
|
|
|
with patch("deploy_playstore.time.sleep") as mock_sleep:
|
|
|
|
|
deploy_playstore.main()
|
2026-05-24 04:59:05 +02:00
|
|
|
|
|
|
|
|
mock_sleep.assert_has_calls([call(10), call(20)])
|
|
|
|
|
|
|
|
|
|
|
2026-05-24 07:40:17 +02:00
|
|
|
class TestUploadAabResumable(unittest.TestCase):
|
|
|
|
|
def test_initiates_and_uploads(self):
|
|
|
|
|
mock_session = MagicMock()
|
|
|
|
|
init_resp = MagicMock()
|
|
|
|
|
init_resp.headers = {"Location": "https://upload.example.com/sess"}
|
|
|
|
|
upload_resp = MagicMock()
|
|
|
|
|
upload_resp.json.return_value = {"versionCode": 42}
|
|
|
|
|
mock_session.post.return_value = init_resp
|
|
|
|
|
mock_session.put.return_value = upload_resp
|
|
|
|
|
|
|
|
|
|
import tempfile
|
|
|
|
|
with tempfile.NamedTemporaryFile(delete=False) as f:
|
|
|
|
|
f.write(b"fake-aab-content")
|
|
|
|
|
aab_path = f.name
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
result = deploy_playstore._upload_aab_resumable(
|
|
|
|
|
mock_session, "com.example.app", "edit-1", aab_path
|
|
|
|
|
)
|
|
|
|
|
finally:
|
|
|
|
|
os.unlink(aab_path)
|
|
|
|
|
|
|
|
|
|
self.assertEqual(result["versionCode"], 42)
|
|
|
|
|
mock_session.post.assert_called_once()
|
|
|
|
|
mock_session.put.assert_called_once()
|
|
|
|
|
put_call = mock_session.put.call_args
|
|
|
|
|
self.assertEqual(put_call[0][0], "https://upload.example.com/sess")
|
2026-05-24 04:59:05 +02:00
|
|
|
|
|
|
|
|
|
2026-05-24 04:38:36 +02:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|