import importlib.util
import json
import sys
import types
import unittest
from io import BytesIO
from unittest.mock import Mock, mock_open, patch
from urllib.error import HTTPError, URLError
try:
ruamel_spec = importlib.util.find_spec('ruamel.yaml')
except ModuleNotFoundError:
ruamel_spec = None
if ruamel_spec is None:
ruamel = types.ModuleType('ruamel')
ruamel_yaml = types.ModuleType('ruamel.yaml')
class DummyYAML:
preserve_quotes = False
def indent(self, **_kwargs):
pass
def dump(self, _data, stream):
stream.write('{}\n')
ruamel_yaml.YAML = DummyYAML
ruamel.yaml = ruamel_yaml
sys.modules['ruamel'] = ruamel
sys.modules['ruamel.yaml'] = ruamel_yaml
import get_data
from history_store import empty_history
class FakeResponse:
def __init__(self, data, final_url):
self.data = data
self.final_url = final_url
self.headers = {}
def __enter__(self):
return self
def __exit__(self, *_args):
pass
def geturl(self):
return self.final_url
def read(self, _limit=None):
return json.dumps(self.data).encode('utf-8')
class GetDataTests(unittest.TestCase):
def test_gitlab_commit_date_uses_branch_endpoint(self):
api_url = 'https://gitlab.com/api/v4/projects/example%2Frepository'
branch_url = api_url + '/repository/branches/feature%2Flatest'
response = {
'name': 'feature/latest',
'commit': {'committed_date': '2026-10-10T12:00:00Z'},
}
with patch('get_data.fetch_json', return_value=(response, {}, branch_url)) as fetch:
commit_date = get_data.gitlab_commit_date(
api_url, 'feature/latest', 'secret-token'
)
self.assertEqual(commit_date, '2026‑10‑10')
self.assertEqual(fetch.call_args.args[0], branch_url)
self.assertEqual(fetch.call_args.args[1]['PRIVATE-TOKEN'], 'secret-token')
def test_gitlab_branch_without_commit_date_is_rejected(self):
response = {'name': 'dev', 'commit': {}}
with patch('get_data.fetch_json', return_value=(response, {}, 'branch-url')):
with self.assertRaisesRegex(RuntimeError, 'has no commit date'):
get_data.gitlab_commit_date(
'https://gitlab.com/api/v4/projects/example', 'dev', None
)
def test_cloudflare_html_error_is_summarized(self):
url = 'https://gitlab.com/api/v4/projects/example/repository/branches/dev'
challenge = (
b'
Just a moment...'
b'nonce-secret'
)
error = HTTPError(
url, 403, 'Forbidden', {'Content-Type': 'text/html'}, BytesIO(challenge)
)
with patch('get_data.urlopen', side_effect=error):
with self.assertRaisesRegex(RuntimeError, 'Cloudflare challenge page') as raised:
get_data.fetch_json(url, {})
self.assertNotIn('nonce-secret', str(raised.exception))
def test_github_redirect_updates_source_and_notifies_immediately(self):
old_url = 'https://github.com/imaegoo/twikoo'
old_api_url = 'https://api.github.com/repos/imaegoo/twikoo'
canonical_url = 'https://github.com/twikoojs/twikoo'
canonical_api_url = 'https://api.github.com/repos/twikoojs/twikoo'
source = {
'provider': 'github',
'api_url': old_api_url,
'url': old_url,
}
item = {'source': old_url, 'license': 'MIT'}
repository_data = {
'stargazers_count': 2200,
'created_at': '2020-05-24T00:00:00Z',
'open_issues_count': 10,
'html_url': canonical_url,
'default_branch': 'main',
'license': {'spdx_id': 'MIT'},
}
commit_data = {
'commit': {'committer': {'date': '2026-07-30T00:00:00Z'}},
}
notify = Mock()
with patch(
'get_data.fetch_json',
side_effect=[
(repository_data, {}, 'https://api.github.com/repositories/266566637'),
(commit_data, {}, canonical_api_url + '/commits/main'),
],
) as fetch:
stats = get_data.fetch_github_repo(
source,
None,
item,
on_anomaly=notify,
)
self.assertEqual(item['source'], canonical_url)
self.assertEqual(stats['stars'], 2200)
notify.assert_called_once_with(
'Repository moved permanently',
'Repository moved: {} -> {}'.format(old_url, canonical_url),
)
self.assertEqual(fetch.call_args_list[1].args[0], canonical_api_url + '/commits/main')
def test_incomplete_redirect_response_is_an_error_not_zero_stars(self):
source = {
'provider': 'github',
'api_url': 'https://api.github.com/repos/imaegoo/twikoo',
'url': 'https://github.com/imaegoo/twikoo',
}
item = {'source': source['url'], 'license': 'MIT'}
with patch(
'get_data.fetch_json',
return_value=(
{
'message': 'Moved Permanently',
'url': 'https://api.github.com/repositories/266566637',
},
{},
source['api_url'],
),
):
with self.assertRaisesRegex(RuntimeError, 'missing required fields'):
get_data.fetch_github_repo(source, None, item)
def test_temporary_network_error_notifies_and_is_retried(self):
response = FakeResponse(
{'stargazers_count': 1},
'https://api.github.com/repos/example/repository',
)
notify = Mock()
with patch('get_data.urlopen', side_effect=[URLError('temporary'), response]) as fetch:
with patch('get_data.time.sleep') as sleep:
data, _headers, final_url = get_data.fetch_json(
'https://api.github.com/repos/example/repository',
{},
on_anomaly=notify,
)
self.assertEqual(data['stargazers_count'], 1)
self.assertEqual(final_url, response.final_url)
self.assertEqual(fetch.call_count, 2)
sleep.assert_called_once_with(1)
self.assertEqual(notify.call_count, 1)
self.assertIn('retrying 2/3', notify.call_args.args[1])
def test_failed_repository_is_frozen_while_other_repository_updates(self):
failed_source = {
'provider': 'github',
'api_url': 'https://api.github.com/repos/example/repository',
'url': 'https://github.com/example/repository',
}
working_source = {
'provider': 'github',
'api_url': 'https://api.github.com/repos/working/repository',
'url': 'https://github.com/working/repository',
}
data = {
'example': {
'source': failed_source['url'],
'stars': 42,
'stars_dif': 1,
},
'working': {
'source': working_source['url'],
'stars': 10,
},
}
history = empty_history()
working_stats = {
'provider': 'github',
'stars': 20,
'created': '2020‑01‑01',
'open_issues': 3,
'license': 'MIT',
'last_commit': '2026‑07‑30',
}
with patch('builtins.open', mock_open()):
with patch.object(get_data.yaml, 'load', return_value=data, create=True):
with patch('get_data.load_history', return_value=history):
with patch(
'get_data.repo_sources',
side_effect=[[failed_source], [working_source]],
):
with patch(
'get_data.fetch_repo_stats',
side_effect=[
RuntimeError('API unavailable'),
working_stats,
],
):
with patch('get_data.send_alert') as send_alert:
with patch('get_data.atomic_write_many_text') as atomic_write:
result = get_data.main()
self.assertEqual(result, 0)
self.assertEqual(data['example']['stars'], 42)
self.assertIn('API unavailable', data['example']['update_warning'])
self.assertIn('update_warning_at', data['example'])
self.assertEqual(data['working']['stars'], 20)
self.assertNotIn('update_warning', data['working'])
send_alert.assert_called_once()
atomic_write.assert_called_once()
self.assertEqual(history['dates'], [str(get_data.date.today())])
self.assertEqual(
history['projects']['working']['stars'][-1][1],
20,
)
def test_repository_identity_change_is_flagged_and_frozen(self):
history = empty_history()
history['dates'] = ['2026-07-29']
history['projects'] = {
'example': {
'created': [['2026-07-29', '2020‑01‑01']],
},
}
notify = Mock()
safe = get_data.check_repository_changes(
history,
'example',
'2026-07-30',
{
'created': '2021‑01‑01',
'license': 'MIT',
'last_commit': '2026‑07‑30',
},
{'github': 100},
notify,
)
self.assertFalse(safe)
self.assertEqual(notify.call_args.args[0], 'Repository identity changed')
def test_star_drop_below_warning_threshold_is_accepted_without_warning(self):
history = empty_history()
history['dates'] = ['2026-07-30']
history['projects'] = {
'example': {
'stars_github': [['2026-07-30', 100]],
},
}
notify = Mock()
safe = get_data.check_repository_changes(
history,
'example',
'2026-07-31',
{
'created': '2020‑01‑01',
'license': 'MIT',
'last_commit': '2026‑07‑30',
},
{'github': 81},
notify,
)
self.assertTrue(safe)
notify.assert_not_called()
def test_star_drop_at_warning_threshold_is_flagged(self):
history = empty_history()
history['dates'] = ['2026-07-30']
history['projects'] = {
'example': {
'stars_github': [['2026-07-30', 100]],
},
}
notify = Mock()
safe = get_data.check_repository_changes(
history,
'example',
'2026-07-31',
{
'created': '2020‑01‑01',
'license': 'MIT',
'last_commit': '2026‑07‑30',
},
{'github': 80},
notify,
)
self.assertTrue(safe)
notify.assert_called_once_with(
'github star count decreased',
'github stars decreased from 100 to 80',
)
def test_malformed_known_repository_url_is_not_silently_ignored(self):
with self.assertRaisesRegex(RuntimeError, 'malformed repository'):
get_data.repo_sources({'source': 'https://github.com/only-owner'})
if __name__ == '__main__':
unittest.main()