Skip to content

Commit caec4c3

Browse files
committed
Unit test improvement
1 parent 0dd51eb commit caec4c3

18 files changed

Lines changed: 1364 additions & 1 deletion
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import pytest
2+
from unittest.mock import patch
3+
from flask import Flask
4+
5+
from app_alchemy import alchemy_bp
6+
7+
8+
@pytest.fixture
9+
def app():
10+
app = Flask(__name__)
11+
app.register_blueprint(alchemy_bp)
12+
app.config['TESTING'] = True
13+
return app
14+
15+
16+
@pytest.fixture
17+
def client(app):
18+
return app.test_client()
19+
20+
21+
class TestCreateAnchorValidation:
22+
@patch('app_helper.save_alchemy_anchor')
23+
def test_whitespace_only_name_returns_400(self, mock_save, client):
24+
response = client.post('/api/anchors', json={'name': ' ', 'centroid': [0.1, 0.2]})
25+
assert response.status_code == 400
26+
assert response.get_json() == {'error': 'Anchor name is required'}
27+
mock_save.assert_not_called()
28+
29+
@patch('app_helper.save_alchemy_anchor')
30+
def test_non_list_centroid_returns_400(self, mock_save, client):
31+
response = client.post('/api/anchors', json={'name': 'My Anchor', 'centroid': 'not-a-list'})
32+
assert response.status_code == 400
33+
assert response.get_json() == {'error': 'Anchor centroid is required and must be a list'}
34+
mock_save.assert_not_called()
35+
36+
@patch('app_helper.save_alchemy_anchor')
37+
def test_empty_list_centroid_returns_400(self, mock_save, client):
38+
response = client.post('/api/anchors', json={'name': 'My Anchor', 'centroid': []})
39+
assert response.status_code == 400
40+
assert response.get_json() == {'error': 'Anchor centroid is required and must be a list'}
41+
mock_save.assert_not_called()
42+
43+
44+
class TestRenameAnchorValidation:
45+
@patch('app_helper.update_alchemy_anchor_name')
46+
def test_whitespace_only_name_returns_400(self, mock_update, client):
47+
response = client.put('/api/anchors/7', json={'name': ' '})
48+
assert response.status_code == 400
49+
assert response.get_json() == {'error': 'Anchor name is required'}
50+
mock_update.assert_not_called()
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import pytest
2+
from unittest.mock import patch
3+
from flask import Flask
4+
5+
from app_alchemy import alchemy_bp
6+
7+
8+
@pytest.fixture
9+
def app():
10+
app = Flask(__name__)
11+
app.register_blueprint(alchemy_bp)
12+
app.config['TESTING'] = True
13+
return app
14+
15+
16+
@pytest.fixture
17+
def client(app):
18+
return app.test_client()
19+
20+
21+
class TestAlchemyApiPayloadValidation:
22+
def test_items_without_any_add_op_returns_400(self, client):
23+
response = client.post('/api/alchemy', json={'items': [{'id': 'song-1', 'op': 'SUBTRACT'}]})
24+
assert response.status_code == 400
25+
assert response.get_json() == {'error': 'Invalid request'}
26+
27+
def test_add_item_missing_id_returns_400(self, client):
28+
response = client.post('/api/alchemy', json={'items': [{'op': 'ADD', 'type': 'song'}]})
29+
assert response.status_code == 400
30+
assert response.get_json() == {'error': 'Invalid request'}
31+
32+
@patch('app_alchemy.song_alchemy')
33+
def test_add_items_without_id_are_filtered_before_dispatch(self, mock_alchemy, client):
34+
mock_alchemy.side_effect = ValueError('At least one item must be in the ADD set')
35+
response = client.post('/api/alchemy', json={'items': [{'op': 'ADD'}, {'op': 'ADD', 'id': ''}]})
36+
assert response.status_code == 400
37+
assert mock_alchemy.call_args.kwargs['add_items'] == []
38+
assert mock_alchemy.call_args.kwargs['subtract_items'] == []
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Unit tests for app_backup.restore_backup() chunk and lock validation.
2+
3+
Exercises the /api/backup/restore route through a Flask test client with
4+
multipart/form-data uploads. The module-level Redis lock helpers are patched
5+
(the functions, not redis), BACKUP_DIR is redirected to a pytest tmp dir, and
6+
no test ever completes a chunk set, so the detached restore subprocess is
7+
never spawned.
8+
"""
9+
import io
10+
import os
11+
from unittest.mock import MagicMock
12+
13+
import pytest
14+
from flask import Flask
15+
16+
import app_backup
17+
18+
CONFIRMATION = "I want to restore the database from the backup. This action is not reversible"
19+
20+
21+
@pytest.fixture
22+
def client():
23+
app = Flask(__name__)
24+
app.config['TESTING'] = True
25+
app.register_blueprint(app_backup.backup_bp)
26+
return app.test_client()
27+
28+
29+
def _form(confirmation=CONFIRMATION, chunk_num=None, total_chunks=None, with_file=True):
30+
data = {'confirmation': confirmation}
31+
if chunk_num is not None:
32+
data['chunk_num'] = str(chunk_num)
33+
if total_chunks is not None:
34+
data['total_chunks'] = str(total_chunks)
35+
if with_file:
36+
data['file'] = (io.BytesIO(b'SELECT 1;\n'), 'backup.sql')
37+
return data
38+
39+
40+
def _post(client, **kwargs):
41+
return client.post(
42+
'/api/backup/restore',
43+
data=_form(**kwargs),
44+
content_type='multipart/form-data',
45+
)
46+
47+
48+
class TestRestoreValidation:
49+
def test_wrong_confirmation_is_400(self, client):
50+
resp = _post(client, confirmation='nope')
51+
assert resp.status_code == 400
52+
assert 'Confirmation' in resp.get_json()['error']
53+
54+
def test_missing_confirmation_is_400(self, client):
55+
resp = _post(client, confirmation='')
56+
assert resp.status_code == 400
57+
58+
def test_missing_file_is_400(self, client):
59+
resp = _post(client, with_file=False)
60+
assert resp.status_code == 400
61+
assert resp.get_json()['error'] == 'No file uploaded.'
62+
63+
def test_non_integer_chunk_fields_are_400(self, client):
64+
resp = _post(client, chunk_num='abc', total_chunks='3')
65+
assert resp.status_code == 400
66+
assert 'must be integers' in resp.get_json()['error']
67+
68+
@pytest.mark.parametrize('chunk_num,total_chunks', [
69+
(0, 3),
70+
(4, 3),
71+
(2, 1),
72+
(-1, 3),
73+
(0, 0),
74+
])
75+
def test_chunk_num_out_of_range_is_400(self, client, chunk_num, total_chunks):
76+
resp = _post(client, chunk_num=chunk_num, total_chunks=total_chunks)
77+
assert resp.status_code == 400
78+
assert 'Invalid chunk numbers' in resp.get_json()['error']
79+
80+
81+
class TestRestoreLock:
82+
def test_first_chunk_lock_already_held_is_409(self, client, monkeypatch, tmp_path):
83+
monkeypatch.setattr(app_backup, 'BACKUP_DIR', str(tmp_path))
84+
monkeypatch.setattr(app_backup, '_acquire_restore_lock', lambda: False)
85+
resp = _post(client, chunk_num=1, total_chunks=3)
86+
assert resp.status_code == 409
87+
assert 'already in progress' in resp.get_json()['error']
88+
89+
def test_later_chunk_lock_not_held_is_409(self, client, monkeypatch, tmp_path):
90+
monkeypatch.setattr(app_backup, 'BACKUP_DIR', str(tmp_path))
91+
monkeypatch.setattr(app_backup, '_restore_lock_held', lambda: False)
92+
resp = _post(client, chunk_num=2, total_chunks=3)
93+
assert resp.status_code == 409
94+
assert 'Restart the upload from chunk 1' in resp.get_json()['error']
95+
96+
def test_later_chunk_never_tries_to_acquire(self, client, monkeypatch, tmp_path):
97+
monkeypatch.setattr(app_backup, 'BACKUP_DIR', str(tmp_path))
98+
acquire = MagicMock(return_value=True)
99+
monkeypatch.setattr(app_backup, '_acquire_restore_lock', acquire)
100+
monkeypatch.setattr(app_backup, '_restore_lock_held', lambda: False)
101+
resp = _post(client, chunk_num=2, total_chunks=3)
102+
assert resp.status_code == 409
103+
acquire.assert_not_called()
104+
105+
def test_single_file_upload_lock_held_is_409(self, client, monkeypatch):
106+
monkeypatch.setattr(app_backup, '_acquire_restore_lock', lambda: False)
107+
resp = _post(client)
108+
assert resp.status_code == 409
109+
assert 'already in progress' in resp.get_json()['error']
110+
111+
112+
class TestRestoreChunkProgress:
113+
def test_intermediate_chunk_is_acknowledged(self, client, monkeypatch, tmp_path):
114+
monkeypatch.setattr(app_backup, 'BACKUP_DIR', str(tmp_path))
115+
monkeypatch.setattr(app_backup, '_acquire_restore_lock', lambda: True)
116+
resp = _post(client, chunk_num=1, total_chunks=3)
117+
assert resp.status_code == 200
118+
body = resp.get_json()
119+
assert body['success'] is True
120+
assert body['all_chunks_received'] is False
121+
assert body['chunk_num'] == 1
122+
assert body['total_chunks'] == 3
123+
assert body['received_chunks'] == [1]
124+
assert body['missing_chunks'] == [2, 3]
125+
assert os.path.exists(os.path.join(str(tmp_path), 'chunks', 'backup_1_of_3.sql'))
126+
127+
def test_first_chunk_wipes_leftover_chunks(self, client, monkeypatch, tmp_path):
128+
monkeypatch.setattr(app_backup, 'BACKUP_DIR', str(tmp_path))
129+
monkeypatch.setattr(app_backup, '_acquire_restore_lock', lambda: True)
130+
chunks_dir = tmp_path / 'chunks'
131+
chunks_dir.mkdir()
132+
leftover = chunks_dir / 'backup_2_of_3.sql'
133+
leftover.write_bytes(b'stale data')
134+
resp = _post(client, chunk_num=1, total_chunks=3)
135+
assert resp.status_code == 200
136+
body = resp.get_json()
137+
assert body['received_chunks'] == [1]
138+
assert body['missing_chunks'] == [2, 3]
139+
assert not leftover.exists()
140+
141+
def test_second_chunk_keeps_existing_chunks(self, client, monkeypatch, tmp_path):
142+
monkeypatch.setattr(app_backup, 'BACKUP_DIR', str(tmp_path))
143+
monkeypatch.setattr(app_backup, '_restore_lock_held', lambda: True)
144+
chunks_dir = tmp_path / 'chunks'
145+
chunks_dir.mkdir()
146+
(chunks_dir / 'backup_1_of_3.sql').write_bytes(b'first chunk')
147+
resp = _post(client, chunk_num=2, total_chunks=3)
148+
assert resp.status_code == 200
149+
body = resp.get_json()
150+
assert body['all_chunks_received'] is False
151+
assert body['received_chunks'] == [1, 2]
152+
assert body['missing_chunks'] == [3]
153+
assert (chunks_dir / 'backup_1_of_3.sql').exists()
154+
assert (chunks_dir / 'backup_2_of_3.sql').exists()
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import os
2+
import time
3+
4+
import pytest
5+
6+
from app_cron import _field_matches, cron_matches_now
7+
8+
9+
FIXED_TS = 1700000000
10+
11+
requires_tzset = pytest.mark.skipif(
12+
not hasattr(time, 'tzset'), reason='time.tzset not available on this platform'
13+
)
14+
15+
16+
@pytest.fixture
17+
def utc_tz():
18+
old = os.environ.get('TZ')
19+
os.environ['TZ'] = 'UTC'
20+
time.tzset()
21+
yield
22+
if old is None:
23+
os.environ.pop('TZ', None)
24+
else:
25+
os.environ['TZ'] = old
26+
time.tzset()
27+
28+
29+
def test_field_matches_star_matches_any_value():
30+
assert _field_matches('*', 0) is True
31+
assert _field_matches('*', 59) is True
32+
assert _field_matches(' * ', 7) is True
33+
34+
35+
def test_field_matches_exact_value():
36+
assert _field_matches('5', 5) is True
37+
assert _field_matches('5', 6) is False
38+
39+
40+
def test_field_matches_range_inclusive():
41+
assert _field_matches('1-5', 1) is True
42+
assert _field_matches('1-5', 3) is True
43+
assert _field_matches('1-5', 5) is True
44+
45+
46+
def test_field_matches_range_outside():
47+
assert _field_matches('1-5', 0) is False
48+
assert _field_matches('1-5', 6) is False
49+
50+
51+
def test_field_matches_comma_list():
52+
assert _field_matches('1,3,5', 1) is True
53+
assert _field_matches('1,3,5', 3) is True
54+
assert _field_matches('1,3,5', 5) is True
55+
assert _field_matches('1,3,5', 2) is False
56+
assert _field_matches('1,3,5', 4) is False
57+
58+
59+
def test_field_matches_malformed_range_returns_false():
60+
assert _field_matches('1-', 1) is False
61+
assert _field_matches('1-', 0) is False
62+
assert _field_matches('-5', 3) is False
63+
64+
65+
def test_field_matches_non_numeric_returns_false():
66+
assert _field_matches('abc', 1) is False
67+
68+
69+
def test_cron_matches_now_short_expression_returns_false():
70+
assert cron_matches_now('* * * *', FIXED_TS) is False
71+
72+
73+
@requires_tzset
74+
def test_cron_matches_now_matching_expression(utc_tz):
75+
assert cron_matches_now('13 22 14 11 *', FIXED_TS) is True
76+
77+
78+
@requires_tzset
79+
def test_cron_matches_now_matching_dow(utc_tz):
80+
assert cron_matches_now('13 22 * * 2', FIXED_TS) is True
81+
82+
83+
@requires_tzset
84+
def test_cron_matches_now_non_matching_minute(utc_tz):
85+
assert cron_matches_now('14 22 14 11 *', FIXED_TS) is False
86+
87+
88+
@requires_tzset
89+
def test_cron_matches_now_dom_dow_either_matches(utc_tz):
90+
assert cron_matches_now('13 22 1 11 2', FIXED_TS) is True
91+
assert cron_matches_now('13 22 14 11 5', FIXED_TS) is True
92+
assert cron_matches_now('13 22 1 11 5', FIXED_TS) is False
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from app_dashboard import _parse_keyval
2+
3+
4+
class TestParseKeyval:
5+
def test_empty_string_returns_empty_dict(self):
6+
assert _parse_keyval('') == {}
7+
8+
def test_none_returns_empty_dict(self):
9+
assert _parse_keyval(None) == {}
10+
11+
def test_invalid_value_pair_is_skipped(self):
12+
result = _parse_keyval('key1:invalid,key2:5.5')
13+
assert 'key1' not in result
14+
assert result == {'key2': 5.5}
15+
16+
def test_pair_without_separator_is_skipped(self):
17+
result = _parse_keyval('key1,key2:5.5')
18+
assert 'key1' not in result
19+
assert result == {'key2': 5.5}
20+
21+
def test_valid_multi_pair_string_parses_fully(self):
22+
result = _parse_keyval('rock:0.9,jazz:0.05,pop:0.12')
23+
assert result == {'rock': 0.9, 'jazz': 0.05, 'pop': 0.12}
24+
25+
def test_key_whitespace_is_stripped(self):
26+
result = _parse_keyval(' rock :0.9, jazz:0.1')
27+
assert result == {'rock': 0.9, 'jazz': 0.1}
28+
29+
def test_empty_key_is_skipped(self):
30+
result = _parse_keyval(':0.5,pop:0.2')
31+
assert result == {'pop': 0.2}
32+
33+
def test_value_with_surrounding_whitespace_parses(self):
34+
result = _parse_keyval('rock: 0.9 ')
35+
assert result == {'rock': 0.9}

0 commit comments

Comments
 (0)