From 0dd51eb7c1694c898b985a137c2748c31891fe67 Mon Sep 17 00:00:00 2001 From: neptunehub Date: Wed, 10 Jun 2026 10:38:06 +0200 Subject: [PATCH 1/3] Windows sigalarm fix - #624 --- taskqueue.py | 17 +++++++++++++++-- tests/unit/test_taskqueue.py | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_taskqueue.py diff --git a/taskqueue.py b/taskqueue.py index 0715e3ce..f64f5bb6 100644 --- a/taskqueue.py +++ b/taskqueue.py @@ -11,6 +11,14 @@ the macOS supervisor launches the bundled ``redis-server`` binary (see :func:`build_embedded_redis_argv`) and exports its socket URL as ``REDIS_URL`` before the app and workers boot. + +``rq`` hardcodes ``UnixSignalDeathPenalty`` as every job registry's default, so +on Windows (no ``signal.SIGALRM``) registry cleanup -- the janitor loop and the +workers' periodic maintenance -- raises ``AttributeError`` whenever an abandoned +job carries an ``on_failure`` callback, and the job is never removed. +``BaseRegistry`` is therefore re-pointed at RQ's own platform dispatcher and +the queues below are built with the same class: a no-op on POSIX, the +timer-based penalty (what RQ's workers already use) on Windows. """ from redis import Redis @@ -18,9 +26,14 @@ from rq.job import Job from rq.exceptions import NoSuchJobError from rq.command import send_stop_job_command +from rq.registry import BaseRegistry +from rq.timeouts import get_default_death_penalty_class import config +_death_penalty_class = get_default_death_penalty_class() +BaseRegistry.death_penalty_class = _death_penalty_class + __all__ = [ "redis_conn", "rq_queue_high", @@ -54,8 +67,8 @@ def redis_socket_options(url): **redis_socket_options(config.REDIS_URL), ) -rq_queue_high = Queue('high', connection=redis_conn, default_timeout=-1) -rq_queue_default = Queue('default', connection=redis_conn, default_timeout=-1) +rq_queue_high = Queue('high', connection=redis_conn, default_timeout=-1, death_penalty_class=_death_penalty_class) +rq_queue_default = Queue('default', connection=redis_conn, default_timeout=-1, death_penalty_class=_death_penalty_class) def build_embedded_redis_argv(server_binary, socket_path, data_dir): diff --git a/tests/unit/test_taskqueue.py b/tests/unit/test_taskqueue.py new file mode 100644 index 00000000..f61238cc --- /dev/null +++ b/tests/unit/test_taskqueue.py @@ -0,0 +1,21 @@ +import taskqueue +from rq.registry import BaseRegistry +from rq.timeouts import get_default_death_penalty_class + + +def test_base_registry_uses_platform_death_penalty(): + assert BaseRegistry.death_penalty_class is get_default_death_penalty_class() + + +def test_queues_use_platform_death_penalty(): + expected = get_default_death_penalty_class() + assert taskqueue.rq_queue_high.death_penalty_class is expected + assert taskqueue.rq_queue_default.death_penalty_class is expected + + +def test_registries_built_from_queues_inherit_platform_death_penalty(): + expected = get_default_death_penalty_class() + for queue in (taskqueue.rq_queue_high, taskqueue.rq_queue_default): + assert queue.started_job_registry.death_penalty_class is expected + assert queue.finished_job_registry.death_penalty_class is expected + assert queue.failed_job_registry.death_penalty_class is expected From caec4c3b7c4cbae72eb72519d99fc5efa03e569e Mon Sep 17 00:00:00 2001 From: neptunehub Date: Wed, 10 Jun 2026 12:44:48 +0200 Subject: [PATCH 2/3] Unit test improvement --- tests/unit/test_app_alchemy_anchor.py | 50 ++++++ tests/unit/test_app_alchemy_payload.py | 38 +++++ tests/unit/test_app_backup_restore.py | 154 ++++++++++++++++++ tests/unit/test_app_cron_parsing.py | 92 +++++++++++ tests/unit/test_app_dashboard_parsing.py | 35 ++++ tests/unit/test_app_helper_enrichment.py | 103 ++++++++++++ tests/unit/test_app_helper_task_note.py | 149 +++++++++++++++++ tests/unit/test_app_map_helpers.py | 56 +++++++ tests/unit/test_config_obsolete_fields.py | 30 ++++ tests/unit/test_external_search_validation.py | 76 +++++++++ tests/unit/test_flask_app.py | 36 ++++ tests/unit/test_memory_utils.py | 134 ++++++++++++++- tests/unit/test_numeric_bootstrap.py | 29 ++++ tests/unit/test_radius_walk_helper.py | 146 +++++++++++++++++ tests/unit/test_restart_manager.py | 53 ++++++ tests/unit/test_taskqueue.py | 56 +++++++ tests/unit/test_tz_helper.py | 62 +++++++ tests/unit/test_windows_paths.py | 66 ++++++++ 18 files changed, 1364 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_app_alchemy_anchor.py create mode 100644 tests/unit/test_app_alchemy_payload.py create mode 100644 tests/unit/test_app_backup_restore.py create mode 100644 tests/unit/test_app_cron_parsing.py create mode 100644 tests/unit/test_app_dashboard_parsing.py create mode 100644 tests/unit/test_app_helper_enrichment.py create mode 100644 tests/unit/test_app_helper_task_note.py create mode 100644 tests/unit/test_app_map_helpers.py create mode 100644 tests/unit/test_config_obsolete_fields.py create mode 100644 tests/unit/test_external_search_validation.py create mode 100644 tests/unit/test_flask_app.py create mode 100644 tests/unit/test_numeric_bootstrap.py create mode 100644 tests/unit/test_radius_walk_helper.py create mode 100644 tests/unit/test_restart_manager.py create mode 100644 tests/unit/test_tz_helper.py create mode 100644 tests/unit/test_windows_paths.py diff --git a/tests/unit/test_app_alchemy_anchor.py b/tests/unit/test_app_alchemy_anchor.py new file mode 100644 index 00000000..10b5c1e3 --- /dev/null +++ b/tests/unit/test_app_alchemy_anchor.py @@ -0,0 +1,50 @@ +import pytest +from unittest.mock import patch +from flask import Flask + +from app_alchemy import alchemy_bp + + +@pytest.fixture +def app(): + app = Flask(__name__) + app.register_blueprint(alchemy_bp) + app.config['TESTING'] = True + return app + + +@pytest.fixture +def client(app): + return app.test_client() + + +class TestCreateAnchorValidation: + @patch('app_helper.save_alchemy_anchor') + def test_whitespace_only_name_returns_400(self, mock_save, client): + response = client.post('/api/anchors', json={'name': ' ', 'centroid': [0.1, 0.2]}) + assert response.status_code == 400 + assert response.get_json() == {'error': 'Anchor name is required'} + mock_save.assert_not_called() + + @patch('app_helper.save_alchemy_anchor') + def test_non_list_centroid_returns_400(self, mock_save, client): + response = client.post('/api/anchors', json={'name': 'My Anchor', 'centroid': 'not-a-list'}) + assert response.status_code == 400 + assert response.get_json() == {'error': 'Anchor centroid is required and must be a list'} + mock_save.assert_not_called() + + @patch('app_helper.save_alchemy_anchor') + def test_empty_list_centroid_returns_400(self, mock_save, client): + response = client.post('/api/anchors', json={'name': 'My Anchor', 'centroid': []}) + assert response.status_code == 400 + assert response.get_json() == {'error': 'Anchor centroid is required and must be a list'} + mock_save.assert_not_called() + + +class TestRenameAnchorValidation: + @patch('app_helper.update_alchemy_anchor_name') + def test_whitespace_only_name_returns_400(self, mock_update, client): + response = client.put('/api/anchors/7', json={'name': ' '}) + assert response.status_code == 400 + assert response.get_json() == {'error': 'Anchor name is required'} + mock_update.assert_not_called() diff --git a/tests/unit/test_app_alchemy_payload.py b/tests/unit/test_app_alchemy_payload.py new file mode 100644 index 00000000..4a07143e --- /dev/null +++ b/tests/unit/test_app_alchemy_payload.py @@ -0,0 +1,38 @@ +import pytest +from unittest.mock import patch +from flask import Flask + +from app_alchemy import alchemy_bp + + +@pytest.fixture +def app(): + app = Flask(__name__) + app.register_blueprint(alchemy_bp) + app.config['TESTING'] = True + return app + + +@pytest.fixture +def client(app): + return app.test_client() + + +class TestAlchemyApiPayloadValidation: + def test_items_without_any_add_op_returns_400(self, client): + response = client.post('/api/alchemy', json={'items': [{'id': 'song-1', 'op': 'SUBTRACT'}]}) + assert response.status_code == 400 + assert response.get_json() == {'error': 'Invalid request'} + + def test_add_item_missing_id_returns_400(self, client): + response = client.post('/api/alchemy', json={'items': [{'op': 'ADD', 'type': 'song'}]}) + assert response.status_code == 400 + assert response.get_json() == {'error': 'Invalid request'} + + @patch('app_alchemy.song_alchemy') + def test_add_items_without_id_are_filtered_before_dispatch(self, mock_alchemy, client): + mock_alchemy.side_effect = ValueError('At least one item must be in the ADD set') + response = client.post('/api/alchemy', json={'items': [{'op': 'ADD'}, {'op': 'ADD', 'id': ''}]}) + assert response.status_code == 400 + assert mock_alchemy.call_args.kwargs['add_items'] == [] + assert mock_alchemy.call_args.kwargs['subtract_items'] == [] diff --git a/tests/unit/test_app_backup_restore.py b/tests/unit/test_app_backup_restore.py new file mode 100644 index 00000000..50d5e56f --- /dev/null +++ b/tests/unit/test_app_backup_restore.py @@ -0,0 +1,154 @@ +"""Unit tests for app_backup.restore_backup() chunk and lock validation. + +Exercises the /api/backup/restore route through a Flask test client with +multipart/form-data uploads. The module-level Redis lock helpers are patched +(the functions, not redis), BACKUP_DIR is redirected to a pytest tmp dir, and +no test ever completes a chunk set, so the detached restore subprocess is +never spawned. +""" +import io +import os +from unittest.mock import MagicMock + +import pytest +from flask import Flask + +import app_backup + +CONFIRMATION = "I want to restore the database from the backup. This action is not reversible" + + +@pytest.fixture +def client(): + app = Flask(__name__) + app.config['TESTING'] = True + app.register_blueprint(app_backup.backup_bp) + return app.test_client() + + +def _form(confirmation=CONFIRMATION, chunk_num=None, total_chunks=None, with_file=True): + data = {'confirmation': confirmation} + if chunk_num is not None: + data['chunk_num'] = str(chunk_num) + if total_chunks is not None: + data['total_chunks'] = str(total_chunks) + if with_file: + data['file'] = (io.BytesIO(b'SELECT 1;\n'), 'backup.sql') + return data + + +def _post(client, **kwargs): + return client.post( + '/api/backup/restore', + data=_form(**kwargs), + content_type='multipart/form-data', + ) + + +class TestRestoreValidation: + def test_wrong_confirmation_is_400(self, client): + resp = _post(client, confirmation='nope') + assert resp.status_code == 400 + assert 'Confirmation' in resp.get_json()['error'] + + def test_missing_confirmation_is_400(self, client): + resp = _post(client, confirmation='') + assert resp.status_code == 400 + + def test_missing_file_is_400(self, client): + resp = _post(client, with_file=False) + assert resp.status_code == 400 + assert resp.get_json()['error'] == 'No file uploaded.' + + def test_non_integer_chunk_fields_are_400(self, client): + resp = _post(client, chunk_num='abc', total_chunks='3') + assert resp.status_code == 400 + assert 'must be integers' in resp.get_json()['error'] + + @pytest.mark.parametrize('chunk_num,total_chunks', [ + (0, 3), + (4, 3), + (2, 1), + (-1, 3), + (0, 0), + ]) + def test_chunk_num_out_of_range_is_400(self, client, chunk_num, total_chunks): + resp = _post(client, chunk_num=chunk_num, total_chunks=total_chunks) + assert resp.status_code == 400 + assert 'Invalid chunk numbers' in resp.get_json()['error'] + + +class TestRestoreLock: + def test_first_chunk_lock_already_held_is_409(self, client, monkeypatch, tmp_path): + monkeypatch.setattr(app_backup, 'BACKUP_DIR', str(tmp_path)) + monkeypatch.setattr(app_backup, '_acquire_restore_lock', lambda: False) + resp = _post(client, chunk_num=1, total_chunks=3) + assert resp.status_code == 409 + assert 'already in progress' in resp.get_json()['error'] + + def test_later_chunk_lock_not_held_is_409(self, client, monkeypatch, tmp_path): + monkeypatch.setattr(app_backup, 'BACKUP_DIR', str(tmp_path)) + monkeypatch.setattr(app_backup, '_restore_lock_held', lambda: False) + resp = _post(client, chunk_num=2, total_chunks=3) + assert resp.status_code == 409 + assert 'Restart the upload from chunk 1' in resp.get_json()['error'] + + def test_later_chunk_never_tries_to_acquire(self, client, monkeypatch, tmp_path): + monkeypatch.setattr(app_backup, 'BACKUP_DIR', str(tmp_path)) + acquire = MagicMock(return_value=True) + monkeypatch.setattr(app_backup, '_acquire_restore_lock', acquire) + monkeypatch.setattr(app_backup, '_restore_lock_held', lambda: False) + resp = _post(client, chunk_num=2, total_chunks=3) + assert resp.status_code == 409 + acquire.assert_not_called() + + def test_single_file_upload_lock_held_is_409(self, client, monkeypatch): + monkeypatch.setattr(app_backup, '_acquire_restore_lock', lambda: False) + resp = _post(client) + assert resp.status_code == 409 + assert 'already in progress' in resp.get_json()['error'] + + +class TestRestoreChunkProgress: + def test_intermediate_chunk_is_acknowledged(self, client, monkeypatch, tmp_path): + monkeypatch.setattr(app_backup, 'BACKUP_DIR', str(tmp_path)) + monkeypatch.setattr(app_backup, '_acquire_restore_lock', lambda: True) + resp = _post(client, chunk_num=1, total_chunks=3) + assert resp.status_code == 200 + body = resp.get_json() + assert body['success'] is True + assert body['all_chunks_received'] is False + assert body['chunk_num'] == 1 + assert body['total_chunks'] == 3 + assert body['received_chunks'] == [1] + assert body['missing_chunks'] == [2, 3] + assert os.path.exists(os.path.join(str(tmp_path), 'chunks', 'backup_1_of_3.sql')) + + def test_first_chunk_wipes_leftover_chunks(self, client, monkeypatch, tmp_path): + monkeypatch.setattr(app_backup, 'BACKUP_DIR', str(tmp_path)) + monkeypatch.setattr(app_backup, '_acquire_restore_lock', lambda: True) + chunks_dir = tmp_path / 'chunks' + chunks_dir.mkdir() + leftover = chunks_dir / 'backup_2_of_3.sql' + leftover.write_bytes(b'stale data') + resp = _post(client, chunk_num=1, total_chunks=3) + assert resp.status_code == 200 + body = resp.get_json() + assert body['received_chunks'] == [1] + assert body['missing_chunks'] == [2, 3] + assert not leftover.exists() + + def test_second_chunk_keeps_existing_chunks(self, client, monkeypatch, tmp_path): + monkeypatch.setattr(app_backup, 'BACKUP_DIR', str(tmp_path)) + monkeypatch.setattr(app_backup, '_restore_lock_held', lambda: True) + chunks_dir = tmp_path / 'chunks' + chunks_dir.mkdir() + (chunks_dir / 'backup_1_of_3.sql').write_bytes(b'first chunk') + resp = _post(client, chunk_num=2, total_chunks=3) + assert resp.status_code == 200 + body = resp.get_json() + assert body['all_chunks_received'] is False + assert body['received_chunks'] == [1, 2] + assert body['missing_chunks'] == [3] + assert (chunks_dir / 'backup_1_of_3.sql').exists() + assert (chunks_dir / 'backup_2_of_3.sql').exists() diff --git a/tests/unit/test_app_cron_parsing.py b/tests/unit/test_app_cron_parsing.py new file mode 100644 index 00000000..9b8c4409 --- /dev/null +++ b/tests/unit/test_app_cron_parsing.py @@ -0,0 +1,92 @@ +import os +import time + +import pytest + +from app_cron import _field_matches, cron_matches_now + + +FIXED_TS = 1700000000 + +requires_tzset = pytest.mark.skipif( + not hasattr(time, 'tzset'), reason='time.tzset not available on this platform' +) + + +@pytest.fixture +def utc_tz(): + old = os.environ.get('TZ') + os.environ['TZ'] = 'UTC' + time.tzset() + yield + if old is None: + os.environ.pop('TZ', None) + else: + os.environ['TZ'] = old + time.tzset() + + +def test_field_matches_star_matches_any_value(): + assert _field_matches('*', 0) is True + assert _field_matches('*', 59) is True + assert _field_matches(' * ', 7) is True + + +def test_field_matches_exact_value(): + assert _field_matches('5', 5) is True + assert _field_matches('5', 6) is False + + +def test_field_matches_range_inclusive(): + assert _field_matches('1-5', 1) is True + assert _field_matches('1-5', 3) is True + assert _field_matches('1-5', 5) is True + + +def test_field_matches_range_outside(): + assert _field_matches('1-5', 0) is False + assert _field_matches('1-5', 6) is False + + +def test_field_matches_comma_list(): + assert _field_matches('1,3,5', 1) is True + assert _field_matches('1,3,5', 3) is True + assert _field_matches('1,3,5', 5) is True + assert _field_matches('1,3,5', 2) is False + assert _field_matches('1,3,5', 4) is False + + +def test_field_matches_malformed_range_returns_false(): + assert _field_matches('1-', 1) is False + assert _field_matches('1-', 0) is False + assert _field_matches('-5', 3) is False + + +def test_field_matches_non_numeric_returns_false(): + assert _field_matches('abc', 1) is False + + +def test_cron_matches_now_short_expression_returns_false(): + assert cron_matches_now('* * * *', FIXED_TS) is False + + +@requires_tzset +def test_cron_matches_now_matching_expression(utc_tz): + assert cron_matches_now('13 22 14 11 *', FIXED_TS) is True + + +@requires_tzset +def test_cron_matches_now_matching_dow(utc_tz): + assert cron_matches_now('13 22 * * 2', FIXED_TS) is True + + +@requires_tzset +def test_cron_matches_now_non_matching_minute(utc_tz): + assert cron_matches_now('14 22 14 11 *', FIXED_TS) is False + + +@requires_tzset +def test_cron_matches_now_dom_dow_either_matches(utc_tz): + assert cron_matches_now('13 22 1 11 2', FIXED_TS) is True + assert cron_matches_now('13 22 14 11 5', FIXED_TS) is True + assert cron_matches_now('13 22 1 11 5', FIXED_TS) is False diff --git a/tests/unit/test_app_dashboard_parsing.py b/tests/unit/test_app_dashboard_parsing.py new file mode 100644 index 00000000..2067e5f6 --- /dev/null +++ b/tests/unit/test_app_dashboard_parsing.py @@ -0,0 +1,35 @@ +from app_dashboard import _parse_keyval + + +class TestParseKeyval: + def test_empty_string_returns_empty_dict(self): + assert _parse_keyval('') == {} + + def test_none_returns_empty_dict(self): + assert _parse_keyval(None) == {} + + def test_invalid_value_pair_is_skipped(self): + result = _parse_keyval('key1:invalid,key2:5.5') + assert 'key1' not in result + assert result == {'key2': 5.5} + + def test_pair_without_separator_is_skipped(self): + result = _parse_keyval('key1,key2:5.5') + assert 'key1' not in result + assert result == {'key2': 5.5} + + def test_valid_multi_pair_string_parses_fully(self): + result = _parse_keyval('rock:0.9,jazz:0.05,pop:0.12') + assert result == {'rock': 0.9, 'jazz': 0.05, 'pop': 0.12} + + def test_key_whitespace_is_stripped(self): + result = _parse_keyval(' rock :0.9, jazz:0.1') + assert result == {'rock': 0.9, 'jazz': 0.1} + + def test_empty_key_is_skipped(self): + result = _parse_keyval(':0.5,pop:0.2') + assert result == {'pop': 0.2} + + def test_value_with_surrounding_whitespace_parses(self): + result = _parse_keyval('rock: 0.9 ') + assert result == {'rock': 0.9} diff --git a/tests/unit/test_app_helper_enrichment.py b/tests/unit/test_app_helper_enrichment.py new file mode 100644 index 00000000..4037e1a8 --- /dev/null +++ b/tests/unit/test_app_helper_enrichment.py @@ -0,0 +1,103 @@ +from unittest.mock import patch + +from app_helper import attach_song_features + + +def make_score(item_id, album='Album', mood_vector='rock:0.9,happy:0.5', other_features='danceable:0.4'): + return { + 'item_id': item_id, + 'album': album, + 'mood_vector': mood_vector, + 'other_features': other_features, + } + + +class TestAttachSongFeaturesShortCircuits: + def test_empty_list_returned_without_lookup(self): + with patch('app_helper.get_score_data_by_ids') as mock_lookup: + assert attach_song_features([]) == [] + mock_lookup.assert_not_called() + + def test_none_returned_without_lookup(self): + with patch('app_helper.get_score_data_by_ids') as mock_lookup: + assert attach_song_features(None) is None + mock_lookup.assert_not_called() + + def test_rows_without_usable_ids_skip_lookup(self): + rows = [{'title': 'x'}, {'item_id': None}, {'item_id': ''}, 'notadict'] + with patch('app_helper.get_score_data_by_ids') as mock_lookup: + result = attach_song_features(rows) + assert result is rows + assert result == [{'title': 'x'}, {'item_id': None}, {'item_id': ''}, 'notadict'] + mock_lookup.assert_not_called() + + +class TestAttachSongFeaturesEnrichment: + def test_int_row_id_matches_str_score_key(self): + rows = [{'item_id': 123}] + with patch('app_helper.get_score_data_by_ids', return_value=[make_score('123')]): + result = attach_song_features(rows) + assert result[0]['album'] == 'Album' + assert result[0]['mood_vector'] == 'rock:0.9,happy:0.5' + assert result[0]['other_features'] == 'danceable:0.4' + assert result[0]['top_genre'] == 'rock' + + def test_str_row_id_matches_int_score_key(self): + rows = [{'item_id': '456'}] + score = make_score(456, album='B', mood_vector=None, other_features=None) + with patch('app_helper.get_score_data_by_ids', return_value=[score]): + result = attach_song_features(rows) + assert result[0]['album'] == 'B' + assert result[0]['mood_vector'] is None + assert result[0]['other_features'] is None + assert result[0]['top_genre'] is None + + def test_existing_values_not_overwritten(self): + rows = [{'item_id': 1, 'album': 'Keep'}] + with patch('app_helper.get_score_data_by_ids', return_value=[make_score(1, album='New')]): + result = attach_song_features(rows) + assert result[0]['album'] == 'Keep' + assert result[0]['mood_vector'] == 'rock:0.9,happy:0.5' + + def test_non_dict_entries_pass_through_untouched(self): + rows = [{'item_id': 1}, 'plain-string', None] + with patch('app_helper.get_score_data_by_ids', return_value=[make_score(1)]) as mock_lookup: + result = attach_song_features(rows) + assert result[1] == 'plain-string' + assert result[2] is None + assert result[0]['album'] == 'Album' + mock_lookup.assert_called_once_with([1]) + + def test_empty_score_data_leaves_rows_unenriched(self): + rows = [{'item_id': 5, 'title': 't'}] + with patch('app_helper.get_score_data_by_ids', return_value=[]): + result = attach_song_features(rows) + assert result is rows + assert result[0] == {'item_id': 5, 'title': 't'} + + def test_row_without_matching_score_left_unchanged(self): + rows = [{'item_id': 1}, {'item_id': 2}] + with patch('app_helper.get_score_data_by_ids', return_value=[make_score(1)]): + result = attach_song_features(rows) + assert result[0]['album'] == 'Album' + assert result[1] == {'item_id': 2} + + def test_lookup_receives_only_truthy_dict_ids(self): + rows = [{'item_id': 1}, {'item_id': 0}, 'x', {'item_id': 2}] + with patch('app_helper.get_score_data_by_ids', return_value=[]) as mock_lookup: + attach_song_features(rows) + mock_lookup.assert_called_once_with([1, 2]) + + def test_custom_id_key(self): + rows = [{'id': 9}] + with patch('app_helper.get_score_data_by_ids', return_value=[make_score(9, album='C')]) as mock_lookup: + result = attach_song_features(rows, id_key='id') + mock_lookup.assert_called_once_with([9]) + assert result[0]['album'] == 'C' + + def test_top_genre_ignores_non_stratified_labels(self): + rows = [{'item_id': 7}] + score = make_score(7, mood_vector='female vocalist:0.99,metal:0.5') + with patch('app_helper.get_score_data_by_ids', return_value=[score]): + result = attach_song_features(rows) + assert result[0]['top_genre'] == 'metal' diff --git a/tests/unit/test_app_helper_task_note.py b/tests/unit/test_app_helper_task_note.py new file mode 100644 index 00000000..822a11ac --- /dev/null +++ b/tests/unit/test_app_helper_task_note.py @@ -0,0 +1,149 @@ +import json +from unittest.mock import MagicMock + +import pytest + +from app_helper import _build_task_note + + +def make_db(rows): + db = MagicMock() + cur = MagicMock() + cur.fetchall.return_value = rows + db.cursor.return_value.__enter__.return_value = cur + return db, cur + + +class TestAnalysisNote: + def test_sums_tracks_analyzed_from_subtasks(self): + db, cur = make_db([ + (json.dumps({'tracks_analyzed': 10}),), + (json.dumps({'tracks_analyzed': 5}),), + ]) + result = _build_task_note('main_analysis', {'_task_id': 'task-1'}, db) + assert result == 'Songs analyzed: 15' + assert cur.execute.call_args[0][1] == ('task-1',) + + def test_queries_with_empty_string_when_task_id_missing(self): + db, cur = make_db([]) + _build_task_note('main_analysis', {}, db) + assert cur.execute.call_args[0][1] == ('',) + + def test_skips_invalid_subtask_details(self): + db, _ = make_db([ + (None,), + ('not json',), + (json.dumps({'tracks_analyzed': '3'}),), + (json.dumps([1, 2]),), + (json.dumps({'tracks_analyzed': 4}),), + ]) + assert _build_task_note('main_analysis', {}, db) == 'Songs analyzed: 4' + + def test_float_track_counts_are_truncated_per_row(self): + db, _ = make_db([ + (json.dumps({'tracks_analyzed': 2.0}),), + (json.dumps({'tracks_analyzed': 3.5}),), + ]) + assert _build_task_note('main_analysis', {}, db) == 'Songs analyzed: 5' + + def test_falls_back_to_albums_completed(self): + db, _ = make_db([]) + result = _build_task_note('main_analysis', {'albums_completed': 7}, db) + assert result == 'Albums analyzed: 7' + + def test_falls_back_to_total_albums_processed(self): + db, _ = make_db([]) + result = _build_task_note('main_analysis', {'total_albums_processed': 12}, db) + assert result == 'Albums analyzed: 12' + + def test_returns_empty_string_when_nothing_to_report(self): + db, _ = make_db([]) + assert _build_task_note('main_analysis', {}, db) == '' + + def test_db_error_falls_back_to_album_details(self): + db = MagicMock() + db.cursor.side_effect = RuntimeError('no db') + result = _build_task_note('main_analysis', {'albums_completed': 3}, db) + assert result == 'Albums analyzed: 3' + + def test_db_error_without_albums_returns_empty_string(self): + db = MagicMock() + db.cursor.side_effect = RuntimeError('no db') + assert _build_task_note('main_analysis', {}, db) == '' + + +class TestCleanNote: + @pytest.mark.parametrize('key', [ + 'tracks_deleted', 'orphans_removed', 'songs_cleaned', + 'tracks_removed', 'deleted_count', 'cleaned_tracks', + ]) + def test_each_recognized_key(self, key): + result = _build_task_note('main_cleaning', {key: 6}, MagicMock()) + assert result == 'Songs cleaned: 6' + + def test_first_key_wins(self): + details = {'tracks_deleted': 2, 'orphans_removed': 9} + assert _build_task_note('main_cleaning', details, MagicMock()) == 'Songs cleaned: 2' + + def test_zero_is_reported(self): + result = _build_task_note('main_cleaning', {'tracks_deleted': 0}, MagicMock()) + assert result == 'Songs cleaned: 0' + + def test_string_values_skipped_in_favor_of_later_numeric_key(self): + details = {'tracks_deleted': '5', 'orphans_removed': 3} + assert _build_task_note('main_cleaning', details, MagicMock()) == 'Songs cleaned: 3' + + def test_float_value_truncated(self): + result = _build_task_note('main_cleaning', {'songs_cleaned': 4.7}, MagicMock()) + assert result == 'Songs cleaned: 4' + + def test_no_recognized_keys_returns_empty_string(self): + assert _build_task_note('main_cleaning', {'other': 1}, MagicMock()) == '' + + +class TestClusterNote: + def test_best_params_subset_size_preferred(self): + details = { + 'best_params': {'initial_subset_size': 500}, + 'sampled_songs': 1, + 'num_playlists_created': 8, + } + result = _build_task_note('main_clustering', details, MagicMock()) + assert result == 'sampled: 500 • clusters: 8' + + def test_non_dict_best_params_falls_back_to_sampled_songs(self): + details = {'best_params': 'oops', 'sampled_songs': 100} + assert _build_task_note('main_clustering', details, MagicMock()) == 'sampled: 100' + + def test_best_params_without_subset_size_falls_back(self): + details = {'best_params': {}, 'num_sampled_songs': 50} + assert _build_task_note('main_clustering', details, MagicMock()) == 'sampled: 50' + + def test_clusters_only(self): + assert _build_task_note('main_clustering', {'num_clusters': 4}, MagicMock()) == 'clusters: 4' + + def test_zero_sampled_is_omitted(self): + details = {'sampled_songs': 0, 'num_clusters': 3} + assert _build_task_note('main_clustering', details, MagicMock()) == 'clusters: 3' + + def test_no_data_returns_empty_string(self): + assert _build_task_note('main_clustering', {}, MagicMock()) == '' + + def test_non_numeric_sampled_returns_empty_string(self): + details = {'sampled_songs': 'abc', 'num_clusters': 3} + assert _build_task_note('main_clustering', details, MagicMock()) == '' + + +class TestGeneralBehavior: + def test_none_task_type_returns_empty_string(self): + assert _build_task_note(None, {'tracks_deleted': 5}, MagicMock()) == '' + + def test_unknown_task_type_returns_empty_string(self): + assert _build_task_note('sonic_fingerprint', {'tracks_deleted': 5}, MagicMock()) == '' + + def test_task_type_matching_is_case_insensitive(self): + result = _build_task_note('MAIN_CLUSTERING', {'num_clusters': 2}, MagicMock()) + assert result == 'clusters: 2' + + def test_non_dict_details_treated_as_empty(self): + assert _build_task_note('main_cleaning', 'notadict', MagicMock()) == '' diff --git a/tests/unit/test_app_map_helpers.py b/tests/unit/test_app_map_helpers.py new file mode 100644 index 00000000..1622c60a --- /dev/null +++ b/tests/unit/test_app_map_helpers.py @@ -0,0 +1,56 @@ +import pytest + +from app_map import _pick_top_mood, _round_coord, _sample_items + + +class TestPickTopMood: + def test_returns_highest_scoring_label(self): + assert _pick_top_mood('happy:0.8,sad:0.2') == 'happy' + + def test_empty_string_returns_unknown(self): + assert _pick_top_mood('') == 'unknown' + + def test_none_returns_unknown(self): + assert _pick_top_mood(None) == 'unknown' + + def test_no_colon_parts_returns_unknown(self): + assert _pick_top_mood('justalabel') == 'unknown' + + def test_unparseable_score_treated_as_zero(self): + assert _pick_top_mood('happy:abc,sad:0.2') == 'sad' + + def test_single_unparseable_score_still_returns_label(self): + assert _pick_top_mood('happy:abc') == 'happy' + + +class TestRoundCoord: + def test_rounds_to_three_decimals(self): + assert _round_coord([1.23456789, 2.98765432]) == [1.235, 2.988] + + def test_non_numeric_entries_return_zeros(self): + assert _round_coord(['a', 'b']) == [0.0, 0.0] + + def test_none_returns_zeros(self): + assert _round_coord(None) == [0.0, 0.0] + + def test_too_short_returns_zeros(self): + assert _round_coord([1.0]) == [0.0, 0.0] + + +class TestSampleItems: + def test_deterministic_for_same_input(self): + items = list(range(40)) + assert _sample_items(items, 0.5) == _sample_items(items, 0.5) + + def test_fraction_075_of_100_returns_75(self): + items = list(range(100)) + assert len(_sample_items(items, 0.75)) == 75 + + def test_empty_list_returns_empty(self): + assert _sample_items([], 0.5) == [] + + def test_fraction_one_returns_all_items(self): + items = list(range(10)) + result = _sample_items(items, 1.0) + assert result == items + assert result is not items diff --git a/tests/unit/test_config_obsolete_fields.py b/tests/unit/test_config_obsolete_fields.py new file mode 100644 index 00000000..7baf20c3 --- /dev/null +++ b/tests/unit/test_config_obsolete_fields.py @@ -0,0 +1,30 @@ +import pytest + +import config + + +def test_obsolete_fields_has_same_keys_as_fields_by_type(): + assert set(config.MEDIASERVER_OBSOLETE_FIELDS_BY_TYPE) == set( + config.MEDIASERVER_FIELDS_BY_TYPE + ) + + +@pytest.mark.parametrize( + 'media_type', sorted(config.MEDIASERVER_OBSOLETE_FIELDS_BY_TYPE) +) +def test_obsolete_fields_are_union_of_other_types(media_type): + all_fields = set() + for fields in config.MEDIASERVER_FIELDS_BY_TYPE.values(): + all_fields.update(fields) + own_fields = set(config.MEDIASERVER_FIELDS_BY_TYPE[media_type]) + obsolete = config.MEDIASERVER_OBSOLETE_FIELDS_BY_TYPE[media_type] + assert set(obsolete) == all_fields - own_fields + + +@pytest.mark.parametrize( + 'media_type', sorted(config.MEDIASERVER_OBSOLETE_FIELDS_BY_TYPE) +) +def test_obsolete_fields_never_include_own_fields(media_type): + obsolete = set(config.MEDIASERVER_OBSOLETE_FIELDS_BY_TYPE[media_type]) + own_fields = set(config.MEDIASERVER_FIELDS_BY_TYPE[media_type]) + assert obsolete.isdisjoint(own_fields) diff --git a/tests/unit/test_external_search_validation.py b/tests/unit/test_external_search_validation.py new file mode 100644 index 00000000..a8868e76 --- /dev/null +++ b/tests/unit/test_external_search_validation.py @@ -0,0 +1,76 @@ +"""Validation tests for the external /search endpoint in app_external. + +The heavy ``tasks.voyager_manager`` import is stubbed at module load so the +endpoint checks stay fast and hermetic; the backend search function is patched +per test so no database is touched. +""" +import sys +import types +from unittest.mock import MagicMock, patch + +import pytest +from flask import Flask + + +def _import_app_external(): + if 'app_external' in sys.modules: + return sys.modules['app_external'] + fake_vm = types.ModuleType('tasks.voyager_manager') + fake_vm.search_tracks_unified = MagicMock(return_value=[]) + stubs = {'tasks.voyager_manager': fake_vm} + if 'tasks' not in sys.modules: + stubs['tasks'] = types.ModuleType('tasks') + with patch.dict(sys.modules, stubs): + import app_external + return app_external + + +@pytest.fixture +def ext(): + return _import_app_external() + + +@pytest.fixture +def client(ext): + app = Flask(__name__) + app.register_blueprint(ext.external_bp) + app.config['TESTING'] = True + return app.test_client() + + +class TestSearchQueryValidation: + def test_missing_query_returns_empty_list(self, ext, client): + with patch.object(ext, 'search_tracks_unified') as backend: + resp = client.get('/search') + assert resp.status_code == 200 + assert resp.get_json() == [] + backend.assert_not_called() + + def test_explicit_empty_query_returns_empty_list(self, ext, client): + with patch.object(ext, 'search_tracks_unified') as backend: + resp = client.get('/search', query_string={'search_query': ''}) + assert resp.status_code == 200 + assert resp.get_json() == [] + backend.assert_not_called() + + def test_two_char_query_returns_400(self, ext, client): + with patch.object(ext, 'search_tracks_unified') as backend: + resp = client.get('/search', query_string={'search_query': 'ab'}) + assert resp.status_code == 400 + assert resp.get_json() == {"error": "Query must be at least 3 characters long"} + backend.assert_not_called() + + def test_valid_query_reaches_backend_and_returns_its_value(self, ext, client): + results = [{'item_id': 'id-1', 'title': 'Song', 'author': 'Artist'}] + with patch.object(ext, 'search_tracks_unified', return_value=results) as backend: + resp = client.get('/search', query_string={'search_query': 'abc'}) + assert resp.status_code == 200 + assert resp.get_json() == results + backend.assert_called_once_with('abc') + + def test_legacy_title_artist_params_build_query(self, ext, client): + with patch.object(ext, 'search_tracks_unified', return_value=[]) as backend: + resp = client.get('/search', query_string={'title': 'Hello', 'artist': 'Adele'}) + assert resp.status_code == 200 + assert resp.get_json() == [] + backend.assert_called_once_with('Adele Hello') diff --git a/tests/unit/test_flask_app.py b/tests/unit/test_flask_app.py new file mode 100644 index 00000000..b5426145 --- /dev/null +++ b/tests/unit/test_flask_app.py @@ -0,0 +1,36 @@ +import os +import sys + +import flask_app + + +def _module_dir(): + return os.path.dirname(os.path.abspath(flask_app.__file__)) + + +def test_resource_root_frozen_with_meipass(monkeypatch): + monkeypatch.setattr(sys, 'frozen', True, raising=False) + monkeypatch.setattr(sys, '_MEIPASS', '/bundle', raising=False) + + assert flask_app._resource_root() == '/bundle' + + +def test_resource_root_frozen_without_meipass(monkeypatch): + monkeypatch.setattr(sys, 'frozen', True, raising=False) + monkeypatch.delattr(sys, '_MEIPASS', raising=False) + + assert flask_app._resource_root() == _module_dir() + + +def test_resource_root_not_frozen(monkeypatch): + monkeypatch.delattr(sys, 'frozen', raising=False) + monkeypatch.setattr(sys, '_MEIPASS', '/bundle', raising=False) + + assert flask_app._resource_root() == _module_dir() + + +def test_resource_root_frozen_false(monkeypatch): + monkeypatch.setattr(sys, 'frozen', False, raising=False) + monkeypatch.setattr(sys, '_MEIPASS', '/bundle', raising=False) + + assert flask_app._resource_root() == _module_dir() diff --git a/tests/unit/test_memory_utils.py b/tests/unit/test_memory_utils.py index 48e4f710..5d3e07d0 100644 --- a/tests/unit/test_memory_utils.py +++ b/tests/unit/test_memory_utils.py @@ -3,10 +3,16 @@ Tests the memory management and data sanitization utilities. """ +import pytest +from unittest.mock import Mock, MagicMock + from tasks.memory_utils import ( sanitize_string_for_db, + sanitize_json_for_db, cleanup_cuda_memory, cleanup_onnx_session, + comprehensive_memory_cleanup, + handle_onnx_memory_error, SessionRecycler ) @@ -192,6 +198,132 @@ def test_full_cycle(self): for i in range(3): assert not recycler.should_recycle() recycler.increment() - + assert recycler.should_recycle() + +class TestHandleOnnxMemoryError: + """Test ONNX memory error handling with cleanup, retry and CPU fallback.""" + + def test_non_memory_error_reraises_same_object(self): + """A non-memory error is re-raised as the same exception object.""" + err = ValueError("boom") + cleanup = Mock() + retry = Mock() + + with pytest.raises(ValueError) as excinfo: + handle_onnx_memory_error( + err, "test context", cleanup_func=cleanup, retry_func=retry + ) + + assert excinfo.value is err + cleanup.assert_not_called() + retry.assert_not_called() + + def test_memory_error_triggers_cleanup_and_returns_retry_result(self): + """A BFCArena error calls cleanup once and returns the retry result.""" + err = Exception("BFCArena failed") + cleanup = Mock() + retry = Mock(return_value="retried") + + result = handle_onnx_memory_error( + err, "test context", cleanup_func=cleanup, retry_func=retry + ) + + assert result == "retried" + cleanup.assert_called_once() + retry.assert_called_once() + + def test_cpu_fallback_returns_result_session_provider_tuple(self): + """CPU fallback returns (result, new_session, provider).""" + err = Exception("BFCArena failed") + session_mock = MagicMock() + creator = Mock(return_value=(session_mock, "CPUExecutionProvider")) + retry = Mock(return_value="r") + + result = handle_onnx_memory_error( + err, + "test context", + retry_func=retry, + fallback_to_cpu=True, + session_creator=creator, + ) + + assert result == ("r", session_mock, "CPUExecutionProvider") + creator.assert_called_once() + retry.assert_called_once() + + def test_retry_failure_propagates_retry_exception(self): + """If the retry itself fails, that exception propagates.""" + err = Exception("Failed to allocate memory for requested buffer") + retry_err = RuntimeError("still failing") + retry = Mock(side_effect=retry_err) + + with pytest.raises(RuntimeError) as excinfo: + handle_onnx_memory_error(err, "test context", retry_func=retry) + + assert excinfo.value is retry_err + + def test_memory_error_without_retry_or_fallback_reraises_original(self): + """A memory error with no retry and no fallback re-raises the original.""" + err = Exception("out of memory") + + with pytest.raises(Exception) as excinfo: + handle_onnx_memory_error(err, "test context") + + assert excinfo.value is err + + +class TestComprehensiveMemoryCleanup: + """Test combined memory cleanup result dictionary.""" + + def test_returns_expected_keys_with_bool_values(self): + """Result dict has exactly the four expected keys, all booleans.""" + results = comprehensive_memory_cleanup(force_cuda=False, reset_onnx_pool=False) + + assert set(results.keys()) == {"cuda", "onnx_pool", "gc", "malloc_trim"} + assert all(isinstance(v, bool) for v in results.values()) + + def test_gc_is_always_true(self): + """Garbage collection is always reported as successful.""" + results = comprehensive_memory_cleanup(force_cuda=False, reset_onnx_pool=False) + assert results["gc"] is True + + def test_force_cuda_false_reports_cuda_false(self): + """Disabling CUDA cleanup leaves the cuda flag False.""" + results = comprehensive_memory_cleanup(force_cuda=False, reset_onnx_pool=False) + assert results["cuda"] is False + + def test_reset_onnx_pool_false_reports_onnx_pool_false(self): + """Disabling pool reset leaves the onnx_pool flag False.""" + results = comprehensive_memory_cleanup(force_cuda=False, reset_onnx_pool=False) + assert results["onnx_pool"] is False + + +class TestSanitizeJsonForDB: + """Test recursive JSON sanitization for jsonb writes.""" + + def test_nested_structures_are_cleaned_recursively(self): + """Strings at every depth are cleaned; containers keep their types.""" + data = { + "a": {"b": "Va\x00l"}, + "l": [1, "S\x01tr", {"n": "B\x00ad"}], + "t": ("x\x00y",), + } + + result = sanitize_json_for_db(data) + + assert result == { + "a": {"b": "Val"}, + "l": [1, "Str", {"n": "Bad"}], + "t": ("xy",), + } + assert isinstance(result["t"], tuple) + assert isinstance(result["l"], list) + assert result["l"][0] == 1 + + def test_non_string_scalars_pass_through(self): + """Non-string scalars are returned unchanged.""" + assert sanitize_json_for_db(123) == 123 + assert sanitize_json_for_db(None) is None + diff --git a/tests/unit/test_numeric_bootstrap.py b/tests/unit/test_numeric_bootstrap.py new file mode 100644 index 00000000..fd0145bc --- /dev/null +++ b/tests/unit/test_numeric_bootstrap.py @@ -0,0 +1,29 @@ +import locale +import os +from unittest.mock import patch + +import numeric_bootstrap + + +def test_pin_numeric_locale_sets_env_and_calls_setlocale(monkeypatch): + monkeypatch.setenv("LC_NUMERIC", "en_US.UTF-8") + with patch("locale.setlocale") as mock_setlocale: + numeric_bootstrap.pin_numeric_locale() + assert os.environ["LC_NUMERIC"] == "C" + mock_setlocale.assert_called_once_with(locale.LC_NUMERIC, "C") + + +def test_pin_numeric_locale_setlocale_error_does_not_propagate(monkeypatch): + monkeypatch.setenv("LC_NUMERIC", "en_US.UTF-8") + with patch("locale.setlocale", side_effect=locale.Error("unsupported locale")) as mock_setlocale: + numeric_bootstrap.pin_numeric_locale() + assert os.environ["LC_NUMERIC"] == "C" + mock_setlocale.assert_called_once_with(locale.LC_NUMERIC, "C") + + +def test_pin_numeric_locale_generic_exception_still_sets_env_when_unset(monkeypatch): + monkeypatch.setenv("LC_NUMERIC", "placeholder") + monkeypatch.delenv("LC_NUMERIC") + with patch("locale.setlocale", side_effect=Exception("boom")): + numeric_bootstrap.pin_numeric_locale() + assert os.environ["LC_NUMERIC"] == "C" diff --git a/tests/unit/test_radius_walk_helper.py b/tests/unit/test_radius_walk_helper.py new file mode 100644 index 00000000..b6727741 --- /dev/null +++ b/tests/unit/test_radius_walk_helper.py @@ -0,0 +1,146 @@ +import numpy as np + +from tasks.radius_walk_helper import avoid_triple_adjacent, execute_radius_walk + + +def _euclid(v1, v2): + return float(np.linalg.norm(v1 - v2)) + + +def _assert_no_triple(ids, id_to_author): + for i in range(len(ids) - 2): + a1 = id_to_author.get(ids[i]) + a2 = id_to_author.get(ids[i + 1]) + a3 = id_to_author.get(ids[i + 2]) + assert not (a1 and a1 == a2 == a3) + + +class TestAvoidTripleAdjacent: + def test_triple_swapped_with_later_different_artist(self): + ids = ['a1', 'a2', 'a3', 'b1'] + id_to_author = {'a1': 'A', 'a2': 'A', 'a3': 'A', 'b1': 'B'} + original = list(ids) + + result = avoid_triple_adjacent(ids, id_to_author) + + assert result is ids + assert sorted(result) == sorted(original) + _assert_no_triple(result, id_to_author) + + def test_all_same_artist_unchanged(self): + ids = ['a1', 'a2', 'a3', 'a4'] + id_to_author = {'a1': 'A', 'a2': 'A', 'a3': 'A', 'a4': 'A'} + + result = avoid_triple_adjacent(ids, id_to_author) + + assert result is ids + assert result == ['a1', 'a2', 'a3', 'a4'] + + def test_no_triple_order_identical(self): + ids = ['a1', 'b1', 'a2', 'b2'] + id_to_author = {'a1': 'A', 'b1': 'B', 'a2': 'A', 'b2': 'B'} + + result = avoid_triple_adjacent(ids, id_to_author) + + assert result is ids + assert result == ['a1', 'b1', 'a2', 'b2'] + + +def _make_candidates(): + candidates = [] + for i in range(12): + candidates.append({ + 'item_id': f's{i:02d}', + 'vector': np.array([float(i), 0.0, 0.0, 0.0], dtype=np.float32), + 'dist_anchor': round(i * 0.1, 1), + 'title': f'Title {i}', + 'author': f'art{i % 6}', + }) + return candidates + + +def _make_candidates_with_duplicate_pair(): + candidates = [] + for i in range(10): + candidates.append({ + 'item_id': f's{i:02d}', + 'vector': np.array([float(i), 0.0, 0.0, 0.0], dtype=np.float32), + 'dist_anchor': round(i * 0.1, 1), + 'title': f'Track {i}', + 'author': f'solo{i}', + }) + for j, item_id in enumerate(['dup1', 'dup2']): + candidates.append({ + 'item_id': item_id, + 'vector': np.array([float(10 + j), 0.0, 0.0, 0.0], dtype=np.float32), + 'dist_anchor': round((10 + j) * 0.1, 1), + 'title': 'Same Song', + 'author': 'dupart', + }) + return candidates + + +class TestExecuteRadiusWalk: + def test_empty_candidates_returns_empty_list(self): + assert execute_radius_walk([], 5, get_distance_fn=_euclid) == [] + + def test_returns_dicts_walk_order_and_length(self): + candidates = _make_candidates() + dist_map = {c['item_id']: c['dist_anchor'] for c in candidates} + + result = execute_radius_walk(candidates, 5, get_distance_fn=_euclid) + + assert isinstance(result, list) + assert len(result) == 5 + for entry in result: + assert isinstance(entry, dict) + assert set(entry.keys()) == {'item_id', 'distance'} + assert entry['distance'] == dist_map[entry['item_id']] + assert [e['item_id'] for e in result] == ['s00', 's01', 's02', 's03', 's04'] + + def test_max_songs_per_artist_one_no_author_repeats(self): + candidates = _make_candidates() + id_to_author = {c['item_id']: c['author'] for c in candidates} + + result = execute_radius_walk( + candidates, + 10, + eliminate_duplicates=True, + max_songs_per_artist=1, + get_distance_fn=_euclid, + ) + + assert len(result) <= 10 + assert len(result) == 6 + authors = [id_to_author[e['item_id']] for e in result] + assert len(authors) == len(set(authors)) + + def test_duplicate_title_author_pair_capped_to_one(self): + candidates = _make_candidates_with_duplicate_pair() + + result = execute_radius_walk( + candidates, + 12, + eliminate_duplicates=True, + max_songs_per_artist=1, + get_distance_fn=_euclid, + ) + + result_ids = {e['item_id'] for e in result} + assert len(result_ids & {'dup1', 'dup2'}) == 1 + assert len(result) == 11 + + def test_eliminate_duplicates_without_cap_keeps_both_duplicates(self): + candidates = _make_candidates_with_duplicate_pair() + + result = execute_radius_walk( + candidates, + 12, + eliminate_duplicates=True, + max_songs_per_artist=None, + get_distance_fn=_euclid, + ) + + result_ids = {e['item_id'] for e in result} + assert {'dup1', 'dup2'} <= result_ids + assert len(result) == 12 diff --git a/tests/unit/test_restart_manager.py b/tests/unit/test_restart_manager.py new file mode 100644 index 00000000..ff55e603 --- /dev/null +++ b/tests/unit/test_restart_manager.py @@ -0,0 +1,53 @@ +from unittest.mock import MagicMock + +import pytest + +import restart_manager + + +@pytest.fixture +def mock_timer(monkeypatch): + timer_cls = MagicMock() + monkeypatch.setattr(restart_manager.threading, 'Timer', timer_cls) + return timer_cls + + +def test_returns_false_when_service_type_unset(monkeypatch, mock_timer): + monkeypatch.delenv('SERVICE_TYPE', raising=False) + monkeypatch.delenv('DISABLE_FLASK_RESTART', raising=False) + + assert restart_manager.schedule_flask_restart() is False + mock_timer.assert_not_called() + + +def test_returns_false_when_service_type_is_worker(monkeypatch, mock_timer): + monkeypatch.setenv('SERVICE_TYPE', 'worker') + monkeypatch.delenv('DISABLE_FLASK_RESTART', raising=False) + + assert restart_manager.schedule_flask_restart() is False + mock_timer.assert_not_called() + + +def test_returns_false_when_flask_restart_disabled(monkeypatch, mock_timer): + monkeypatch.setenv('SERVICE_TYPE', 'flask') + monkeypatch.setenv('DISABLE_FLASK_RESTART', 'true') + + assert restart_manager.schedule_flask_restart() is False + mock_timer.assert_not_called() + + +def test_disable_guard_is_case_insensitive(monkeypatch, mock_timer): + monkeypatch.setenv('SERVICE_TYPE', 'FLASK') + monkeypatch.setenv('DISABLE_FLASK_RESTART', 'TRUE') + + assert restart_manager.schedule_flask_restart() is False + mock_timer.assert_not_called() + + +def test_guards_pass_for_flask_service_with_restart_enabled(monkeypatch, mock_timer): + monkeypatch.setenv('SERVICE_TYPE', 'flask') + monkeypatch.setenv('DISABLE_FLASK_RESTART', 'false') + + assert restart_manager.schedule_flask_restart() is True + mock_timer.assert_called_once_with(2.5, restart_manager._restart_flask_program) + mock_timer.return_value.start.assert_called_once_with() diff --git a/tests/unit/test_taskqueue.py b/tests/unit/test_taskqueue.py index f61238cc..f80b44b4 100644 --- a/tests/unit/test_taskqueue.py +++ b/tests/unit/test_taskqueue.py @@ -1,3 +1,4 @@ +import config import taskqueue from rq.registry import BaseRegistry from rq.timeouts import get_default_death_penalty_class @@ -19,3 +20,58 @@ def test_registries_built_from_queues_inherit_platform_death_penalty(): assert queue.started_job_registry.death_penalty_class is expected assert queue.finished_job_registry.death_penalty_class is expected assert queue.failed_job_registry.death_penalty_class is expected + + +def test_redis_socket_options_unix_url_omits_keepalive(): + assert taskqueue.redis_socket_options('unix:///tmp/r.sock') == {} + + +def test_redis_socket_options_tcp_url_keeps_keepalive(): + assert taskqueue.redis_socket_options('redis://h:6379/0') == {'socket_keepalive': True} + + +def test_redis_socket_options_tls_url_keeps_keepalive(): + assert taskqueue.redis_socket_options('rediss://h:6380/0') == {'socket_keepalive': True} + + +def test_build_embedded_redis_argv_binary_flags_and_url(): + argv, url = taskqueue.build_embedded_redis_argv( + '/usr/bin/redis-server', '/tmp/r.sock', '/data' + ) + assert argv[0] == '/usr/bin/redis-server' + for flag, value in ( + ('--unixsocket', '/tmp/r.sock'), + ('--unixsocketperm', '700'), + ('--port', '0'), + ('--save', ''), + ('--appendonly', 'no'), + ('--dir', '/data'), + ): + idx = argv.index(flag) + assert argv[idx + 1] == value + assert url == 'unix:///tmp/r.sock?db=0' + + +def test_redis_conn_connection_kwargs(): + kwargs = taskqueue.redis_conn.connection_pool.connection_kwargs + assert kwargs['socket_connect_timeout'] == 30 + assert kwargs['socket_timeout'] == 60 + assert kwargs['health_check_interval'] == 30 + assert kwargs['retry_on_timeout'] is True + expected_keepalive = not str(config.REDIS_URL).startswith('unix://') + assert ('socket_keepalive' in kwargs) == expected_keepalive + + +def test_queue_names_connection_and_default_timeout(): + assert taskqueue.rq_queue_high.name == 'high' + assert taskqueue.rq_queue_default.name == 'default' + for queue in (taskqueue.rq_queue_high, taskqueue.rq_queue_default): + assert queue.connection is taskqueue.redis_conn + assert queue._default_timeout == -1 + + +def test_app_helper_reexports_taskqueue_handles(): + import app_helper + assert app_helper.redis_conn is taskqueue.redis_conn + assert app_helper.rq_queue_high is taskqueue.rq_queue_high + assert app_helper.rq_queue_default is taskqueue.rq_queue_default diff --git a/tests/unit/test_tz_helper.py b/tests/unit/test_tz_helper.py new file mode 100644 index 00000000..c099f31c --- /dev/null +++ b/tests/unit/test_tz_helper.py @@ -0,0 +1,62 @@ +import datetime +import time + +import pytest + +import tz_helper + +pytestmark = pytest.mark.skipif( + not hasattr(time, 'tzset'), + reason="time.tzset() not available on this platform", +) + + +@pytest.fixture +def new_york_tz(monkeypatch): + monkeypatch.setenv('TZ', 'America/New_York') + time.tzset() + yield + monkeypatch.undo() + time.tzset() + + +def test_to_local_none_passes_through(new_york_tz): + assert tz_helper.to_local(None) is None + + +def test_to_local_string_passes_through(new_york_tz): + assert tz_helper.to_local('x') == 'x' + + +def test_to_local_int_passes_through(new_york_tz): + assert tz_helper.to_local(5) == 5 + + +def test_to_local_naive_winter_treated_as_utc(new_york_tz): + result = tz_helper.to_local(datetime.datetime(2026, 1, 15, 12, 0)) + assert result.hour == 7 + assert result.utcoffset() == datetime.timedelta(hours=-5) + + +def test_to_local_naive_summer_applies_dst(new_york_tz): + result = tz_helper.to_local(datetime.datetime(2026, 7, 15, 12, 0)) + assert result.hour == 8 + assert result.utcoffset() == datetime.timedelta(hours=-4) + + +def test_to_local_aware_utc_matches_naive(new_york_tz): + naive_result = tz_helper.to_local(datetime.datetime(2026, 1, 15, 12, 0)) + aware_result = tz_helper.to_local( + datetime.datetime(2026, 1, 15, 12, 0, tzinfo=datetime.timezone.utc) + ) + assert aware_result == naive_result + assert aware_result.hour == 7 + + +def test_to_local_str_none_returns_none(new_york_tz): + assert tz_helper.to_local_str(None) is None + + +def test_to_local_str_formats_naive_winter(new_york_tz): + result = tz_helper.to_local_str(datetime.datetime(2026, 1, 15, 12, 0)) + assert result == '2026-01-15 07:00:00' diff --git a/tests/unit/test_windows_paths.py b/tests/unit/test_windows_paths.py new file mode 100644 index 00000000..b6022ae7 --- /dev/null +++ b/tests/unit/test_windows_paths.py @@ -0,0 +1,66 @@ +import os +from unittest.mock import patch + +from tests.conftest import _import_module + + +def _load_paths(): + return _import_module('windows.paths', 'windows/paths.py') + + +def _norm(path): + return path.replace('\\', '/') + + +class TestAppSupportDir: + def test_space_free_localappdata_used_directly(self, monkeypatch): + mod = _load_paths() + monkeypatch.setenv('LOCALAPPDATA', 'C:\\Users\\tester\\AppData\\Local') + with patch('os.makedirs') as mk: + result = mod.app_support_dir() + assert _norm(result) == 'C:/Users/tester/AppData/Local/AudioMuse-AI' + assert 'AudioMuse-AI' in result + assert ' ' not in result + mk.assert_called_once_with(result, exist_ok=True) + + def test_localappdata_with_space_falls_back_to_programdata(self, monkeypatch): + mod = _load_paths() + monkeypatch.setenv('LOCALAPPDATA', 'C:\\Users\\John Doe\\AppData\\Local') + monkeypatch.delenv('PROGRAMDATA', raising=False) + with patch('os.makedirs') as mk: + result = mod.app_support_dir() + assert _norm(result) == 'C:/ProgramData/AudioMuse-AI' + assert ' ' not in result + mk.assert_called_once_with(result, exist_ok=True) + + def test_programdata_env_overrides_fallback_root(self, monkeypatch): + mod = _load_paths() + monkeypatch.setenv('LOCALAPPDATA', 'C:\\Users\\John Doe\\AppData\\Local') + monkeypatch.setenv('PROGRAMDATA', 'D:\\SharedData') + with patch('os.makedirs') as mk: + result = mod.app_support_dir() + assert _norm(result) == 'D:/SharedData/AudioMuse-AI' + mk.assert_called_once_with(result, exist_ok=True) + + def test_missing_localappdata_uses_home_profile(self, monkeypatch): + mod = _load_paths() + monkeypatch.delenv('LOCALAPPDATA', raising=False) + with patch('os.path.expanduser', return_value='/home/tester') as exp, \ + patch('os.makedirs') as mk: + result = mod.app_support_dir() + exp.assert_called_once_with('~') + expected = os.path.join('/home/tester', 'AppData', 'Local', 'AudioMuse-AI') + assert result == expected + assert 'AudioMuse-AI' in result + mk.assert_called_once_with(result, exist_ok=True) + + def test_missing_localappdata_home_with_space_falls_back(self, monkeypatch): + mod = _load_paths() + monkeypatch.delenv('LOCALAPPDATA', raising=False) + monkeypatch.delenv('PROGRAMDATA', raising=False) + with patch('os.path.expanduser', return_value='/home/John Doe'), \ + patch('os.makedirs') as mk: + result = mod.app_support_dir() + assert _norm(result) == 'C:/ProgramData/AudioMuse-AI' + assert ' ' not in result + mk.assert_called_once_with(result, exist_ok=True) From 2f9739d20375d86766ba36d8c0d7c65d97510898 Mon Sep 17 00:00:00 2001 From: neptunehub Date: Wed, 10 Jun 2026 13:00:34 +0200 Subject: [PATCH 3/3] sonarcloud fix --- tests/unit/test_memory_utils.py | 10 +++++----- tests/unit/test_radius_walk_helper.py | 2 +- tests/unit/test_taskqueue.py | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/unit/test_memory_utils.py b/tests/unit/test_memory_utils.py index 5d3e07d0..f5ac88b2 100644 --- a/tests/unit/test_memory_utils.py +++ b/tests/unit/test_memory_utils.py @@ -222,7 +222,7 @@ def test_non_memory_error_reraises_same_object(self): def test_memory_error_triggers_cleanup_and_returns_retry_result(self): """A BFCArena error calls cleanup once and returns the retry result.""" - err = Exception("BFCArena failed") + err = RuntimeError("BFCArena failed") cleanup = Mock() retry = Mock(return_value="retried") @@ -236,7 +236,7 @@ def test_memory_error_triggers_cleanup_and_returns_retry_result(self): def test_cpu_fallback_returns_result_session_provider_tuple(self): """CPU fallback returns (result, new_session, provider).""" - err = Exception("BFCArena failed") + err = RuntimeError("BFCArena failed") session_mock = MagicMock() creator = Mock(return_value=(session_mock, "CPUExecutionProvider")) retry = Mock(return_value="r") @@ -255,7 +255,7 @@ def test_cpu_fallback_returns_result_session_provider_tuple(self): def test_retry_failure_propagates_retry_exception(self): """If the retry itself fails, that exception propagates.""" - err = Exception("Failed to allocate memory for requested buffer") + err = RuntimeError("Failed to allocate memory for requested buffer") retry_err = RuntimeError("still failing") retry = Mock(side_effect=retry_err) @@ -266,9 +266,9 @@ def test_retry_failure_propagates_retry_exception(self): def test_memory_error_without_retry_or_fallback_reraises_original(self): """A memory error with no retry and no fallback re-raises the original.""" - err = Exception("out of memory") + err = RuntimeError("out of memory") - with pytest.raises(Exception) as excinfo: + with pytest.raises(RuntimeError) as excinfo: handle_onnx_memory_error(err, "test context") assert excinfo.value is err diff --git a/tests/unit/test_radius_walk_helper.py b/tests/unit/test_radius_walk_helper.py index b6727741..12dfbd1f 100644 --- a/tests/unit/test_radius_walk_helper.py +++ b/tests/unit/test_radius_walk_helper.py @@ -19,7 +19,7 @@ class TestAvoidTripleAdjacent: def test_triple_swapped_with_later_different_artist(self): ids = ['a1', 'a2', 'a3', 'b1'] id_to_author = {'a1': 'A', 'a2': 'A', 'a3': 'A', 'b1': 'B'} - original = list(ids) + original = ids.copy() result = avoid_triple_adjacent(ids, id_to_author) diff --git a/tests/unit/test_taskqueue.py b/tests/unit/test_taskqueue.py index f80b44b4..8946cfad 100644 --- a/tests/unit/test_taskqueue.py +++ b/tests/unit/test_taskqueue.py @@ -36,11 +36,11 @@ def test_redis_socket_options_tls_url_keeps_keepalive(): def test_build_embedded_redis_argv_binary_flags_and_url(): argv, url = taskqueue.build_embedded_redis_argv( - '/usr/bin/redis-server', '/tmp/r.sock', '/data' + '/usr/bin/redis-server', '/var/lib/audiomuse/redis.sock', '/data' ) assert argv[0] == '/usr/bin/redis-server' for flag, value in ( - ('--unixsocket', '/tmp/r.sock'), + ('--unixsocket', '/var/lib/audiomuse/redis.sock'), ('--unixsocketperm', '700'), ('--port', '0'), ('--save', ''), @@ -49,7 +49,7 @@ def test_build_embedded_redis_argv_binary_flags_and_url(): ): idx = argv.index(flag) assert argv[idx + 1] == value - assert url == 'unix:///tmp/r.sock?db=0' + assert url == 'unix:///var/lib/audiomuse/redis.sock?db=0' def test_redis_conn_connection_kwargs():