|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import logging |
| 4 | +from pathlib import Path |
| 5 | +from typing import Any |
| 6 | + |
| 7 | +import pytest |
| 8 | + |
| 9 | +from mycli import client_commands |
| 10 | +from mycli.client_commands import ClientCommandsMixin |
| 11 | +from mycli.packages.sqlresult import SQLResult |
| 12 | + |
| 13 | + |
| 14 | +class DummyClient(ClientCommandsMixin): |
| 15 | + def __init__(self) -> None: |
| 16 | + self.echo_calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] |
| 17 | + |
| 18 | + def echo(self, *args: Any, **kwargs: Any) -> None: |
| 19 | + self.echo_calls.append((args, kwargs)) |
| 20 | + |
| 21 | + def reconnect(self, database: str = '') -> bool: |
| 22 | + self.reconnect_database = database |
| 23 | + return True |
| 24 | + |
| 25 | + def refresh_completions(self, reset: bool = False) -> list[SQLResult]: |
| 26 | + return [SQLResult(status=f'refresh {reset}')] |
| 27 | + |
| 28 | + |
| 29 | +class FakeFormatter: |
| 30 | + def __init__(self, *, supported_formats: list[str] | None = None, fail: bool = False) -> None: |
| 31 | + self.supported_formats = supported_formats or ['ascii', 'csv'] |
| 32 | + self.fail = fail |
| 33 | + self.values: list[str] = [] |
| 34 | + |
| 35 | + @property |
| 36 | + def format_name(self) -> str: |
| 37 | + return self.values[-1] |
| 38 | + |
| 39 | + @format_name.setter |
| 40 | + def format_name(self, value: str) -> None: |
| 41 | + if self.fail: |
| 42 | + raise ValueError |
| 43 | + self.values.append(value) |
| 44 | + |
| 45 | + |
| 46 | +class FakeSQLExecute: |
| 47 | + def __init__(self, *, dbname: str = 'old_db', user: str = 'alice') -> None: |
| 48 | + self.dbname = dbname |
| 49 | + self.user = user |
| 50 | + self.changed_to: list[str] = [] |
| 51 | + self.runs: list[str] = [] |
| 52 | + |
| 53 | + def change_db(self, dbname: str) -> None: |
| 54 | + self.changed_to.append(dbname) |
| 55 | + self.dbname = dbname |
| 56 | + |
| 57 | + def run(self, query: str) -> list[SQLResult]: |
| 58 | + self.runs.append(query) |
| 59 | + return [SQLResult(status=f'ran {query}')] |
| 60 | + |
| 61 | + |
| 62 | +@pytest.fixture(autouse=True) |
| 63 | +def patch_sql_execute(monkeypatch: pytest.MonkeyPatch) -> None: |
| 64 | + monkeypatch.setattr(client_commands, 'SQLExecute', FakeSQLExecute) |
| 65 | + |
| 66 | + |
| 67 | +def result_statuses(results: Any) -> list[str | None]: |
| 68 | + return [result.status for result in list(results)] |
| 69 | + |
| 70 | + |
| 71 | +def test_register_special_commands_registers_expected_commands(monkeypatch: pytest.MonkeyPatch) -> None: |
| 72 | + client = DummyClient() |
| 73 | + calls: list[tuple[Any, ...]] = [] |
| 74 | + monkeypatch.setattr(client_commands.special, 'register_special_command', lambda *args, **kwargs: calls.append((*args, kwargs))) |
| 75 | + |
| 76 | + client.register_special_commands() |
| 77 | + |
| 78 | + assert [call[1] for call in calls] == ['use', 'connect', 'rehash', 'tableformat', 'redirectformat', 'source', 'prompt'] |
| 79 | + assert calls[0][0] == client.change_db |
| 80 | + assert calls[1][0] == client.manual_reconnect |
| 81 | + assert calls[2][0] == client.refresh_completions |
| 82 | + assert calls[3][0] == client.change_table_format |
| 83 | + assert calls[4][0] == client.change_redirect_format |
| 84 | + assert calls[5][0] == client.execute_from_file |
| 85 | + assert calls[6][0] == client.change_prompt_format |
| 86 | + |
| 87 | + |
| 88 | +def test_manual_reconnect_reports_not_connected() -> None: |
| 89 | + client = DummyClient() |
| 90 | + |
| 91 | + def fake_reconnect(database: str = '') -> bool: |
| 92 | + client.reconnect_database = database |
| 93 | + return False |
| 94 | + |
| 95 | + client.reconnect = fake_reconnect # type: ignore[method-assign] |
| 96 | + |
| 97 | + assert result_statuses(client.manual_reconnect('new_db')) == ['Not connected'] |
| 98 | + assert client.reconnect_database == 'new_db' |
| 99 | + |
| 100 | + |
| 101 | +def test_manual_reconnect_without_database_returns_empty_result() -> None: |
| 102 | + client = DummyClient() |
| 103 | + |
| 104 | + assert list(client.manual_reconnect()) == [SQLResult()] |
| 105 | + assert client.reconnect_database == '' |
| 106 | + |
| 107 | + |
| 108 | +def test_manual_reconnect_with_database_delegates_to_change_db(monkeypatch: pytest.MonkeyPatch) -> None: |
| 109 | + client = DummyClient() |
| 110 | + changed: list[str] = [] |
| 111 | + |
| 112 | + def fake_change_db(arg: str, **_: Any) -> Any: |
| 113 | + changed.append(arg) |
| 114 | + yield SQLResult(status='changed') |
| 115 | + |
| 116 | + monkeypatch.setattr(client, 'change_db', fake_change_db) |
| 117 | + |
| 118 | + assert result_statuses(client.manual_reconnect('new_db')) == ['changed'] |
| 119 | + assert changed == ['new_db'] |
| 120 | + |
| 121 | + |
| 122 | +def test_change_table_format_reports_supported_formats_on_error() -> None: |
| 123 | + client = DummyClient() |
| 124 | + client.main_formatter = FakeFormatter(supported_formats=['plain', 'csv'], fail=True) |
| 125 | + |
| 126 | + assert result_statuses(client.change_table_format('bad')) == ['Table format bad not recognized. Allowed formats:\n\tplain\n\tcsv'] |
| 127 | + |
| 128 | + |
| 129 | +def test_change_table_format_updates_formatter() -> None: |
| 130 | + client = DummyClient() |
| 131 | + client.main_formatter = FakeFormatter() |
| 132 | + |
| 133 | + assert result_statuses(client.change_table_format('csv')) == ['Changed table format to csv'] |
| 134 | + assert client.main_formatter.values == ['csv'] |
| 135 | + |
| 136 | + |
| 137 | +def test_change_redirect_format_updates_formatter() -> None: |
| 138 | + client = DummyClient() |
| 139 | + client.redirect_formatter = FakeFormatter() |
| 140 | + |
| 141 | + assert result_statuses(client.change_redirect_format('csv')) == ['Changed redirect format to csv'] |
| 142 | + assert client.redirect_formatter.values == ['csv'] |
| 143 | + |
| 144 | + |
| 145 | +def test_change_redirect_format_reports_supported_formats_on_error() -> None: |
| 146 | + client = DummyClient() |
| 147 | + client.redirect_formatter = FakeFormatter(supported_formats=['plain', 'json'], fail=True) |
| 148 | + |
| 149 | + assert result_statuses(client.change_redirect_format('bad')) == [ |
| 150 | + 'Redirect format bad not recognized. Allowed formats:\n\tplain\n\tjson' |
| 151 | + ] |
| 152 | + |
| 153 | + |
| 154 | +def test_change_db_unquotes_mysql_identifier(monkeypatch: pytest.MonkeyPatch) -> None: |
| 155 | + client = DummyClient() |
| 156 | + client.sqlexecute = FakeSQLExecute() |
| 157 | + title_calls: list[DummyClient] = [] |
| 158 | + monkeypatch.setattr(client_commands, 'set_all_external_titles', lambda value: title_calls.append(value)) |
| 159 | + |
| 160 | + assert result_statuses(client.change_db('`new``db`')) == ['You are now connected to database "new`db" as user "alice"'] |
| 161 | + assert client.sqlexecute.changed_to == ['new`db'] |
| 162 | + assert title_calls == [client] |
| 163 | + |
| 164 | + |
| 165 | +def test_change_db_reports_when_database_is_already_selected(monkeypatch: pytest.MonkeyPatch) -> None: |
| 166 | + client = DummyClient() |
| 167 | + client.sqlexecute = FakeSQLExecute(dbname='same_db') |
| 168 | + title_calls: list[DummyClient] = [] |
| 169 | + monkeypatch.setattr(client_commands, 'set_all_external_titles', lambda value: title_calls.append(value)) |
| 170 | + |
| 171 | + assert result_statuses(client.change_db('same_db')) == ['You are already connected to database "same_db" as user "alice"'] |
| 172 | + assert client.sqlexecute.changed_to == [] |
| 173 | + assert title_calls == [client] |
| 174 | + |
| 175 | + |
| 176 | +def test_change_db_without_argument_reports_error(monkeypatch: pytest.MonkeyPatch) -> None: |
| 177 | + client = DummyClient() |
| 178 | + secho_calls: list[tuple[str, dict[str, Any]]] = [] |
| 179 | + monkeypatch.setattr(client_commands.click, 'secho', lambda message, **kwargs: secho_calls.append((message, kwargs))) |
| 180 | + |
| 181 | + assert list(client.change_db('')) == [] |
| 182 | + assert secho_calls == [('No database selected', {'err': True, 'fg': 'red'})] |
| 183 | + |
| 184 | + |
| 185 | +def test_execute_from_file_requires_filename() -> None: |
| 186 | + client = DummyClient() |
| 187 | + |
| 188 | + assert list(client.execute_from_file('')) == [SQLResult(status='Missing required argument: filename.')] |
| 189 | + |
| 190 | + |
| 191 | +def test_execute_from_file_reports_open_errors() -> None: |
| 192 | + client = DummyClient() |
| 193 | + |
| 194 | + result = list(client.execute_from_file('/does/not/exist.sql')) |
| 195 | + |
| 196 | + assert len(result) == 1 |
| 197 | + assert result[0].status is not None |
| 198 | + assert '/does/not/exist.sql' in result[0].status |
| 199 | + |
| 200 | + |
| 201 | +def test_execute_from_file_stops_when_destructive_query_is_rejected( |
| 202 | + monkeypatch: pytest.MonkeyPatch, |
| 203 | + tmp_path: Path, |
| 204 | +) -> None: |
| 205 | + client = DummyClient() |
| 206 | + sql_file = tmp_path / 'query.sql' |
| 207 | + sql_file.write_text('drop table users;', encoding='utf-8') |
| 208 | + client.destructive_warning = True |
| 209 | + client.destructive_keywords = {'drop'} |
| 210 | + monkeypatch.setattr(client_commands, 'confirm_destructive_query', lambda keywords, query: False) |
| 211 | + |
| 212 | + assert list(client.execute_from_file(str(sql_file))) == [SQLResult(status='Wise choice. Command execution stopped.')] |
| 213 | + |
| 214 | + |
| 215 | +def test_execute_from_file_runs_file_query(tmp_path: Path) -> None: |
| 216 | + client = DummyClient() |
| 217 | + sql_file = tmp_path / 'query.sql' |
| 218 | + sql_file.write_text('select 1;', encoding='utf-8') |
| 219 | + client.destructive_warning = False |
| 220 | + client.destructive_keywords = set() |
| 221 | + client.sqlexecute = FakeSQLExecute() |
| 222 | + |
| 223 | + assert list(client.execute_from_file(str(sql_file))) == [SQLResult(status='ran select 1;')] |
| 224 | + assert client.sqlexecute.runs == ['select 1;'] |
| 225 | + |
| 226 | + |
| 227 | +def test_change_prompt_format_requires_argument() -> None: |
| 228 | + client = DummyClient() |
| 229 | + |
| 230 | + assert client.change_prompt_format('') == [SQLResult(status='Missing required argument, format.')] |
| 231 | + |
| 232 | + |
| 233 | +def test_change_prompt_format_updates_prompt_format() -> None: |
| 234 | + client = DummyClient() |
| 235 | + |
| 236 | + assert client.change_prompt_format('\\u> ') == [SQLResult(status='Changed prompt format to \\u> ')] |
| 237 | + assert client.prompt_format == '\\u> ' |
| 238 | + |
| 239 | + |
| 240 | +def test_initialize_logging_uses_null_handler_for_none_level(monkeypatch: pytest.MonkeyPatch) -> None: |
| 241 | + client = DummyClient() |
| 242 | + client.config = {'main': {'log_file': '/unused/mycli.log', 'log_level': 'NONE'}} |
| 243 | + capture_warning_calls: list[bool] = [] |
| 244 | + monkeypatch.setattr(client_commands.logging, 'captureWarnings', lambda value: capture_warning_calls.append(value)) |
| 245 | + logger = logging.getLogger('mycli') |
| 246 | + original_handlers = list(logger.handlers) |
| 247 | + try: |
| 248 | + client.initialize_logging() |
| 249 | + |
| 250 | + added_handlers = [handler for handler in logger.handlers if handler not in original_handlers] |
| 251 | + assert len(added_handlers) == 1 |
| 252 | + assert isinstance(added_handlers[0], logging.NullHandler) |
| 253 | + assert logger.level == logging.CRITICAL |
| 254 | + assert capture_warning_calls == [True] |
| 255 | + finally: |
| 256 | + for handler in logger.handlers: |
| 257 | + if handler not in original_handlers: |
| 258 | + logger.removeHandler(handler) |
| 259 | + |
| 260 | + |
| 261 | +def test_initialize_logging_uses_file_handler(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: |
| 262 | + log_file = tmp_path / 'mycli.log' |
| 263 | + client = DummyClient() |
| 264 | + client.config = {'main': {'log_file': str(log_file), 'log_level': 'DEBUG'}} |
| 265 | + capture_warning_calls: list[bool] = [] |
| 266 | + monkeypatch.setattr(client_commands.logging, 'captureWarnings', lambda value: capture_warning_calls.append(value)) |
| 267 | + logger = logging.getLogger('mycli') |
| 268 | + original_handlers = list(logger.handlers) |
| 269 | + try: |
| 270 | + client.initialize_logging() |
| 271 | + |
| 272 | + added_handlers = [handler for handler in logger.handlers if handler not in original_handlers] |
| 273 | + assert len(added_handlers) == 1 |
| 274 | + assert isinstance(added_handlers[0], logging.FileHandler) |
| 275 | + assert logger.level == logging.DEBUG |
| 276 | + assert capture_warning_calls == [True] |
| 277 | + finally: |
| 278 | + for handler in logger.handlers: |
| 279 | + if handler not in original_handlers: |
| 280 | + logger.removeHandler(handler) |
| 281 | + handler.close() |
| 282 | + |
| 283 | + |
| 284 | +def test_initialize_logging_reports_invalid_log_path() -> None: |
| 285 | + client = DummyClient() |
| 286 | + client.config = {'main': {'log_file': '/does/not/exist/mycli.log', 'log_level': 'INFO'}} |
| 287 | + |
| 288 | + client.initialize_logging() |
| 289 | + |
| 290 | + assert client.echo_calls == [(('Error: Unable to open the log file "/does/not/exist/mycli.log".',), {'err': True, 'fg': 'red'})] |
0 commit comments