"""Unit tests for the main module.""" import argparse from unittest.mock import MagicMock, patch from config import FORBIDDEN_SUBSTRINGS import pytest from splitio_cli import ( audit_feature_flags, collect_feature_flag_data, create_flag, entry_point, export_audit_data, parse_args, validate_flag_name ) from splitio_client import SplitIOClient @pytest.fixture def mock_splitio(mock_environments, mock_traffic_types, mock_users, mock_feature_flags, mock_groups): """Fixture to create a mock SplitIOClient instance.""" client = MagicMock(spec=SplitIOClient) client.resolve_owner.return_value = mock_users client.get_traffic_types.return_value = mock_traffic_types client.create_feature_flag.return_value = {'id': 'flag_abc'} client.patch_feature_flag.return_value = {'status': 'ok'} client.get_environments.return_value = mock_environments client.create_flag_definition.return_value = {'id': 'flag_abc', 'treatments': []} client.get_all_feature_flags.return_value = mock_feature_flags client.get_all_users.return_value = mock_users client.get_all_groups.return_value = mock_groups return client @pytest.mark.parametrize( 'flag_name', [ pytest.param('my_feature', id='simple_snake_case'), pytest.param('new_flag_123', id='snake_case_with_numbers'), pytest.param('abc', id='single_word'), ], ) def test_validate_flag_name_valid(flag_name): """Valid names should not raise an error.""" validate_flag_name(flag_name) @pytest.mark.parametrize( 'flag_name, expected_message', [ pytest.param('MyFeature', 'snake_case', id='uppercase_letters'), pytest.param('my-feature', 'snake_case', id='contains_hyphen'), pytest.param('my__feature', 'snake_case', id='double_underscore'), pytest.param('_leading', 'snake_case', id='leading_underscore'), pytest.param('trailing_', 'snake_case', id='trailing_underscore'), pytest.param('global_flag', "cannot contain 'global'", id='forbidden_global'), pytest.param('admin_feature', "cannot contain 'admin'", id='forbidden_admin'), ], ) def test_validate_flag_name_invalid(flag_name, expected_message): """Invalid names should raise ValueError with correct message.""" with pytest.raises(ValueError) as excinfo: validate_flag_name(flag_name) assert expected_message in str(excinfo.value) def test_create_flag_success(mock_splitio): """Test successful creation of a feature flag.""" create_flag( name='new_flag', owners=['alice@example.com'], teams=['team1'], jira='ABC-123', description='Test feature flag', traffic_type='Traffic Type 1', splitio=mock_splitio ) mock_splitio.create_feature_flag.assert_called_once() mock_splitio.patch_feature_flag.assert_called_once() def test_create_flag_invalid_name(mock_splitio): """Test creation of a feature flag with an invalid name.""" bad_name = f'bad-{FORBIDDEN_SUBSTRINGS[0]}' with pytest.raises(ValueError, match='cannot contain'): create_flag( name=bad_name, owners=['alice@example.com'], teams=['team1'], jira='ABC-123', description='desc', traffic_type='Traffic Type 1', splitio=mock_splitio ) @pytest.mark.parametrize('owners,teams,jira', [ ([], ['team1'], 'ABC-123'), (['alice@example.com'], [], 'ABC-123'), (['alice@example.com'], ['team1'], ''), ]) def test_create_flag_missing_required_fields(owners, teams, jira, mock_splitio): """Test creation of a feature flag with missing required fields.""" with pytest.raises(ValueError, match='Missing required fields'): create_flag( name='flag', owners=owners, teams=teams, jira=jira, description='desc', traffic_type='Traffic Type 1', splitio=mock_splitio ) def test_create_flag_invalid_jira_format(mock_splitio): """Test creation of a feature flag with an invalid Jira ticket format.""" with pytest.raises(ValueError, match='Invalid Jira ticket format'): create_flag( name='flag', owners=['alice@example.com'], teams=['team1'], jira='abc123', description='desc', traffic_type='Traffic Type 1', splitio=mock_splitio ) def test_create_flag_traffic_type_not_found(mock_splitio): """Test creation of a feature flag with a traffic type that does not exist.""" with pytest.raises(ValueError, match="Traffic type 'Traffic Type 3' not found"): create_flag( name='flag', owners=['alice@example.com'], teams=['team1'], jira='ABC-123', description='desc', traffic_type='Traffic Type 3', splitio=mock_splitio ) def test_create_flag_already_exists(mock_splitio): """Test creation of a feature flag that already exists.""" mock_splitio.create_feature_flag.side_effect = Exception('409 Conflict') with pytest.raises(ValueError, match='already exists'): create_flag( name='flag', owners=['alice@example.com'], teams=['team1'], jira='ABC-123', description='desc', traffic_type='Traffic Type 1', splitio=mock_splitio ) def test_create_flag_create_failure(mock_splitio): """Test creation of a feature flag that fails during API call.""" mock_splitio.create_feature_flag.side_effect = Exception('500 Server Error') with pytest.raises(RuntimeError, match='Failed to create FF'): create_flag( name='flag', owners=['alice@example.com'], teams=['team1'], jira='ABC-123', description='desc', traffic_type='Traffic Type 1', splitio=mock_splitio ) def test_create_flag_patch_failure(mock_splitio): """Test patching a feature flag that fails during API call.""" mock_splitio.create_feature_flag.return_value = {'id': 'flag_abc'} mock_splitio.patch_feature_flag.side_effect = Exception('403 Forbidden') with pytest.raises(RuntimeError, match='Failed to patch flag'): create_flag( name='flag', owners=['alice@example.com'], teams=['team1'], jira='ABC-123', description='desc', traffic_type='Traffic Type 1', splitio=mock_splitio ) def test_create_flag_definition_failure(mock_splitio): """Test creating a feature flag definition fails during API call.""" mock_splitio.create_flag_definition.side_effect = Exception('403 Forbidden') with pytest.raises(RuntimeError, match="Failed to create definition in env 'Environment 1': 403 Forbidden"): create_flag( name='flag', owners=['alice@example.com'], teams=['team1'], jira='ABC-123', description='desc', traffic_type='Traffic Type 1', splitio=mock_splitio ) def test_collect_feature_flag_data_normal(mock_splitio): """Test normal collection of feature flag data.""" flags_df, users_df, groups_df = collect_feature_flag_data(mock_splitio) assert 'Name' in flags_df.columns assert 'flag_1' in flags_df['Name'].values assert 'User 1' in flags_df['Owners'].values[0] assert 'name' in users_df.columns assert 'User 1' in users_df['name'].values assert 'name' in groups_df.columns assert 'Group 1' in groups_df['name'].values def test_collect_feature_flag_data_with_filter(mock_splitio): """Test collection of feature flag data with team filter.""" flags_df, _, _ = collect_feature_flag_data(mock_splitio, filter_team='Team 2') assert len(flags_df) == 1 assert flags_df.iloc[0]['Name'] == 'flag_2' def test_collect_feature_flag_data_handles_bad_timestamp(mock_splitio): """Test collection of feature flag data handles bad timestamp format.""" flags_df, _, _ = collect_feature_flag_data(mock_splitio) row = flags_df[flags_df['Name'] == 'flag_2'].iloc[0] assert row['Creation Time'] == 'bad-time' def test_export_audit_data_json(make_sample_dfs): """Test exporting audit data to JSON format.""" flags_df, users_df, groups_df = make_sample_dfs with patch('splitio_cli.open', create=True) as mock_open: export_audit_data(flags_df, users_df, groups_df, 'json') assert mock_open.called def test_export_audit_data_table(capsys, make_sample_dfs): """Test exporting audit data to table format.""" flags_df, users_df, groups_df = make_sample_dfs export_audit_data(flags_df, users_df, groups_df, 'table') captured = capsys.readouterr() assert 'Feature Flags' in captured.out assert 'Users' in captured.out assert 'Groups' in captured.out def test_export_audit_data_invalid_format(make_sample_dfs): """Test exporting audit data with an invalid format.""" flags_df, users_df, groups_df = make_sample_dfs with pytest.raises(ValueError): export_audit_data(flags_df, users_df, groups_df, 'csv') def test_audit_feature_flags_success(mock_splitio, make_sample_dfs): """Test successful auditing of feature flags.""" with patch('splitio_cli.collect_feature_flag_data') as mock_collect, \ patch('splitio_cli.export_audit_data') as mock_export: mock_collect.return_value = make_sample_dfs audit_feature_flags(mock_splitio, 'excel') mock_collect.assert_called_once() mock_export.assert_called_once() def test_audit_feature_flags_failure(mock_splitio): """Test auditing of feature flags when data collection fails.""" with patch('splitio_cli.collect_feature_flag_data', side_effect=Exception('boom')): with pytest.raises(RuntimeError, match='Failed to audit feature flags: boom'): audit_feature_flags(mock_splitio, 'excel') @patch('sys.argv', ['prog', 'create', 'my-flag', '--owners', 'alice,bob', '--teams', 'platform', '--jira', 'ABC-123']) def test_parse_args_valid_create_command(): """parse_args should return correct values for valid create command.""" args = parse_args() assert args.command == 'create' assert args.name == 'my-flag' assert args.owners == 'alice,bob' assert args.teams == 'platform' assert args.jira == 'ABC-123' assert args.description is None assert args.traffic_type == 'user' @patch('sys.argv', ['prog', 'create', 'my-flag']) def test_parse_args_missing_required_flags_raises_system_exit(): """parse_args should exit when required flags are missing.""" with pytest.raises(SystemExit): parse_args() @patch('sys.argv', ['prog']) def test_parse_args_missing_command_raises_system_exit(): """parse_args should exit when command is missing.""" with pytest.raises(SystemExit): parse_args() @patch('splitio_cli.logger') @patch('splitio_cli.HARNESS_API_KEY', None) @patch('splitio_cli.SPLITIO_WORKSPACE_ID', 'workspace_id') def test_entry_point_missing_api_key_exits(mock_logger): """entry_point should log error and exit if env vars are missing.""" with patch('sys.exit') as mock_exit: entry_point() mock_logger.error.assert_called_once() mock_exit.assert_called_once_with(1) @patch('splitio_cli.logger') @patch('splitio_cli.HARNESS_API_KEY', 'apikey') @patch('splitio_cli.SPLITIO_WORKSPACE_ID', None) def test_entry_point_missing_workspace_id_exits(mock_logger): """entry_point should log error and exit if workspace ID is missing.""" with patch('sys.exit') as mock_exit: entry_point() mock_logger.error.assert_called_once() mock_exit.assert_called_once_with(1) @patch('splitio_cli.logger') @patch('splitio_cli.create_flag') @patch('splitio_cli.parse_args') @patch('splitio_cli.SplitIOClient') @patch('splitio_cli.HARNESS_API_KEY', 'apikey') @patch('splitio_cli.SPLITIO_WORKSPACE_ID', 'workspace_id') def test_entry_point_successful_create_flow( mock_client, mock_parse_args, mock_create_flag, mock_logger ): """entry_point should call create_flag with parsed args.""" mock_parse_args.return_value = argparse.Namespace( command='create', name='my-flag', owners='alice,bob', teams='platform', jira='ABC-123', description='Some desc', traffic_type='user' ) with patch('sys.exit') as mock_exit: entry_point() mock_create_flag.assert_called_once_with( name='my-flag', owners=['alice', 'bob'], teams=['platform'], jira='ABC-123', description='Some desc', traffic_type='user', splitio=mock_client.return_value ) mock_exit.assert_not_called() mock_logger.error.assert_not_called() @patch('splitio_cli.logger') @patch('splitio_cli.audit_feature_flags') @patch('splitio_cli.parse_args') @patch('splitio_cli.SplitIOClient') @patch('splitio_cli.HARNESS_API_KEY', 'apikey') @patch('splitio_cli.SPLITIO_WORKSPACE_ID', 'workspace_id') def test_entry_point_successful_audit_flow( mock_client, mock_parse_args, mock_audit_feature_flags, mock_logger ): """entry_point should call audit_feature_flags with parsed args.""" mock_parse_args.return_value = argparse.Namespace( command='audit', output='excel', filter_team='test team' ) with patch('sys.exit') as mock_exit: entry_point() mock_audit_feature_flags.assert_called_once_with( splitio=mock_client.return_value, output='excel', filter_team='test team' ) mock_exit.assert_not_called() mock_logger.error.assert_not_called() @patch('splitio_cli.logger') @patch('splitio_cli.parse_args') @patch('splitio_cli.SplitIOClient') @patch('splitio_cli.HARNESS_API_KEY', 'apikey') @patch('splitio_cli.SPLITIO_WORKSPACE_ID', 'workspace_id') def test_entry_point_unknown_command(mock_client, mock_parse_args, mock_logger): """Test entry_point raises error for unknown command.""" mock_parse_args.return_value = argparse.Namespace( command='delete', # Invalid command name='some_flag', owners='user@example.com', teams='teamX', jira='TICKET-1', description='desc', traffic_type='user' ) with patch('sys.exit') as mock_exit: entry_point() mock_logger.error.assert_called_once_with('Unexpected error: Unknown command: delete') mock_exit.assert_called_once_with(1) @patch('splitio_cli.logger') @patch('splitio_cli.parse_args') @patch('splitio_cli.SplitIOClient') @patch('splitio_cli.HARNESS_API_KEY', 'apikey') @patch('splitio_cli.SPLITIO_WORKSPACE_ID', 'workspace_id') def test_entry_point_unexpected_exception(mock_client, mock_parse_args, mock_logger): """entry_point should catch and log unexpected exceptions.""" mock_parse_args.side_effect = Exception('Boom!') with patch('sys.exit') as mock_exit: entry_point() mock_logger.error.assert_called_once_with('Unexpected error: Boom!') mock_exit.assert_called_once_with(1)