-
-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathtest_cli_custom_command.py
More file actions
406 lines (324 loc) · 14.4 KB
/
Copy pathtest_cli_custom_command.py
File metadata and controls
406 lines (324 loc) · 14.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
"""Test the 'api-admin custom' command."""
from __future__ import annotations
import io
import os
import sys
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
import typer
from app.api_admin import app
from app.commands.custom import (
choose_license,
choose_version,
get_case_insensitive_dict,
get_data,
get_licenses,
init,
)
from app.config.helpers import LICENCES
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from types import ModuleType
from typer.testing import CliRunner
def is_running_in_docker() -> bool:
"""Check for the .dockerenv file."""
return os.path.exists("/.dockerenv") # noqa: PTH110
class TestCLI:
"""Test the custom CLI commands."""
metadata_file = "metadata.json"
mock_get_config_path = "app.commands.custom.get_config_path"
home_dir = Path("/home/test")
metadata_path = home_dir / metadata_file
test_data = {
"title": "Test Title",
"name": "test-name",
"desc": "Test Description",
"version": "0.5.0",
"repo": "https://myrepo.com",
"license": {
"name": "MIT",
"url": "https://opensource.org/licenses/MIT",
},
"author": "test_user",
"email": "test_user@test.com",
"website": "https://mysite.com",
}
test_input = (
"Test Title\ntest-name\nTest Description\n0.5.0\nhttps://myrepo.com\nMIT\ntest_user\n"
"test_user@test.com\nhttps://mysite.com\n\n"
)
@pytest.fixture
def fs_setup(self, fs, mocker) -> None:
"""Set up the fake filesystem and patch the 'get_data' function."""
fs.create_dir("/home/test/app/config")
fs.create_file(
"/home/test/app/config/metadata.py",
contents="""
# This file contains Custom Metadata for your API Project.
""",
)
fs.create_file(
"/home/test/pyproject.toml",
contents="""[project]
name = 'old-project'
version = '0.1.0'
description = 'Initial project description'
authors = [{name='Old Author',email='oldauthor@example.com'}]""",
)
mocker.patch(
"app.config.helpers.get_project_root",
return_value=Path("/home/test"),
)
mocker.patch(
"app.commands.custom.get_data", return_value=self.test_data
)
def test_no_command_should_give_help(self, runner: CliRunner) -> None:
"""Test that running with no command should give help."""
result = runner.invoke(app, ["custom"])
assert result.exit_code == 0
command_list = ["metadata"]
assert "Usage:" in result.output
assert all(command in result.output for command in command_list)
# ----------------------- test the 'init' function ----------------------- #
@pytest.mark.xfail
def test_init_function(self, mocker, fs) -> None:
"""Test that running 'init' should create a default metadata.
We use 'os.path' to check for the existence of the file, as the
filesystem mock does not work with Path objects created outside of
the test function (though seems to work in Python >=3.10).
"""
metadata_file_path = str(self.home_dir / self.metadata_file)
fs.create_dir(str(self.home_dir))
mock_get_config_path = mocker.patch(
self.mock_get_config_path,
return_value=self.home_dir / self.metadata_file,
)
assert not os.path.exists(metadata_file_path) # noqa: PTH110
init()
mock_get_config_path.assert_called_once()
assert os.path.exists(metadata_file_path) # noqa: PTH110
@pytest.mark.xfail
def test_init_function_with_existing_metadata(self, fs, mocker) -> None:
"""Test that running 'init' should overwrite existing metadata.
We use 'os.path' to check for the existence of the file, as the
filesystem mock does not work with Path objects created outside of
the test function (though seems to work in Python >=3.10).
"""
# Setup
fs.create_dir(str(self.home_dir))
metadata_file_path = str(
self.home_dir / self.metadata_file
) # Use string path
mocker.patch(
self.mock_get_config_path,
return_value=self.home_dir / self.metadata_file,
)
original_content = '{"title": "Old Title"}'
# Create an existing "metadata.json" with some content
with open(metadata_file_path, "w") as file: # noqa: PTH123
file.write(original_content)
# Ensure the metadata file exists with old content
assert os.path.exists(metadata_file_path) # noqa: PTH110
with open(metadata_file_path) as file: # noqa: PTH123
assert file.read() == original_content, "Precondition check failed"
# Action
init()
# Assert
assert os.path.exists( # noqa: PTH110
metadata_file_path
), "Metadata file does not exist after init."
with open(metadata_file_path) as file: # noqa: PTH123
content = file.read()
assert content != original_content, (
"Metadata file was not overwritten with default content."
)
def test_init_function_fails_write(self, fs, mocker, capsys) -> None:
"""Test that running 'init' should fail if it cannot write."""
mocker.patch(
self.mock_get_config_path,
side_effect=OSError("File Error"),
)
fs.create_dir(self.home_dir)
with pytest.raises(typer.Exit):
init()
output = capsys.readouterr().out
assert "Cannot Write the metadata" in output
assert "File Error" in output
# ----------------------- test individual functions ---------------------- #
def test_get_input_from_user(self, mocker) -> None:
"""Test the 'get_data' function to read from user."""
mocker.patch.object(sys, "stdin", io.StringIO(self.test_input))
input_values = get_data()
assert isinstance(input_values, dict)
assert input_values["title"] == "Test Title"
assert input_values["name"] == "test-name"
assert input_values["desc"] == "Test Description"
assert input_values["version"] == "0.5.0"
assert input_values["repo"] == "https://myrepo.com"
assert input_values["license"]["name"] == "MIT"
assert input_values["author"] == "test_user"
assert input_values["email"] == "test_user@test.com"
assert input_values["website"] == "https://mysite.com"
def test_get_licenses_function(self) -> None:
"""Test that running 'get_licenses' should return a list of licenses."""
licenses = get_licenses()
expected_licenses = [licence["name"] for licence in LICENCES]
assert isinstance(licenses, list)
assert len(licenses) > 0
assert all(
license_name in expected_licenses for license_name in licenses
)
def test_case_insensitive_dict(self) -> None:
"""Test that the case insensitive License function works."""
license_name = get_case_insensitive_dict("mit")
assert isinstance(license_name, dict)
assert license_name["name"] == "MIT"
def test_case_insensitive_dict_not_found(self) -> None:
"""Test that the case insensitive License function works."""
license_name = get_case_insensitive_dict("not_found")
assert not isinstance(license_name, dict)
assert license_name == "Unknown"
def test_choose_version(self, mocker, capsys) -> None:
"""Test that the choose version function works."""
mocker.patch.object(sys, "stdin", io.StringIO("2.0.0\n"))
version = choose_version("1.0.0")
output = capsys.readouterr().out
assert "Version Number (" in output
assert version == "2.0.0"
def test_choose_version_reset(self, mocker) -> None:
"""Test that the choose version function works."""
mocker.patch.object(sys, "stdin", io.StringIO("*\n"))
version = choose_version("1.0.0")
assert version == "0.0.1"
def test_choose_license(self, mocker, capsys) -> None:
"""Test that the choose license function works."""
mock_stdin = mocker.patch.object(sys, "stdin", io.StringIO("mit\n"))
license_string = ", ".join(
[license_name["name"] for license_name in LICENCES]
)
license_choice = choose_license()
output = capsys.readouterr().out
assert "Choose a license" in output
assert license_string in output
assert isinstance(license_choice, dict)
assert license_choice["name"] == "MIT"
assert mock_stdin.read
# ---------------------- test the actual CLI command --------------------- #
def test_full_metadata_command(self, runner, fs_setup) -> None:
"""Run the metadata command and verify the output."""
result = runner.invoke(app, ["custom", "metadata"], input="\n")
# Verify command execution was successful
assert result.exit_code == 0, (
"The command did not complete successfully"
)
assert "You have entered the following data:" in result.output, (
"Expected output was not found"
)
# Verify the contents of metadata.py in the app/config subdirectory
metadata_path = "/home/test/app/config/metadata.py"
with open(metadata_path) as f: # noqa: PTH123
metadata_contents = f.read()
for key, value in self.test_data.items():
if key == "version":
continue # no version in metadata.py
if isinstance(
value, dict
): # For nested structures like 'license'
for nested_key, nested_value in value.items():
assert str(nested_value) in metadata_contents, (
f"{nested_key} was not updated in metadata.py"
)
else:
assert str(value) in metadata_contents, (
f"{key} was not updated correctly in metadata.py"
)
# Verify the contents of pyproject.toml were updated
with open("/home/test/pyproject.toml") as f: # noqa: PTH123
pyproject_contents = f.read()
assert str(self.test_data["version"]) in pyproject_contents, (
"pyproject.toml version was not updated correctly"
)
assert str(self.test_data["name"]) in pyproject_contents, (
"pyproject.toml title was not updated correctly"
)
@pytest.mark.skipif(
is_running_in_docker(), reason="This test fails under docker"
)
def test_full_metadata_command_cant_write_metadata(
self, runner, fs_setup
) -> None:
"""Run the metadata command and verify the output."""
os.chmod("/home/test/app/config/metadata.py", 0) # noqa: PTH101
result = runner.invoke(app, ["custom", "metadata"], input="\n")
# Verify command execution was not successful
assert result.exit_code == 2, "The metadata file should not be writable" # noqa: PLR2004
assert "Cannot Write the metadata" in result.output
def test_metadata_command_cant_write_toml(self, runner, fs_setup) -> None:
"""Test the metadata command fails if pyproject.toml is not writable."""
# Make the file readable but not writable
os.chmod("/home/test/pyproject.toml", 0o444) # noqa: PTH101
result = runner.invoke(app, ["custom", "metadata"], input="\n")
assert result.exit_code == 3, ( # noqa: PLR2004
"The pyproject.toml file should not be writable"
)
assert "Cannot update the pyproject.toml file" in result.output
def test_metadata_module_not_found(
self, monkeypatch, fs_setup, capsys
) -> None:
"""This is a convoluted (hacky!) test to simulate a ModuleNotFoundError.
It tests the case where the 'metadata' module is not found. Note that if
this test is run asynchonously, it will most likely cause many other
tests to fail!
"""
# Store references to the original modules
original_modules = {}
for name in list(sys.modules.keys()):
if name == "app.config.metadata" or name.startswith(
"app.config.metadata."
):
original_modules[name] = sys.modules.pop(name)
# Define a custom import function that raises ModuleNotFoundError for
# our metadate module
def mock_import(
name: str,
globals: Mapping[str, object] | None = None, # noqa: A002
locals: Mapping[str, object] | None = None, # noqa: A002
fromlist: Sequence[str] = (),
level: int = 0,
) -> ModuleType:
"""Mock the import mechanism.
We want to to raise ModuleNotFoundError for our specific module.
"""
if name == "app.config.metadata" or name.startswith(
"app.config.metadata."
):
err = f"No module named '{name}'"
raise ModuleNotFoundError(err)
return original_import(name, globals, locals, fromlist, level)
def reimport_custom_module() -> None:
"""Re-import or import the app.commands.custom module.
Putting it in a separate function to avoid multiple statements in
the 'pytest.raises' context manager and upsetting 'ruff' linter.
"""
if "app.commands.custom" in sys.modules:
import importlib # noqa: PLC0415
importlib.reload(sys.modules["app.commands.custom"])
else:
import app.commands.custom # noqa: F401, PLC0415
# Store and patch the import mechanism
original_import = __import__
monkeypatch.setattr("builtins.__import__", mock_import)
try:
# We want to capture the Exit exception but let everything else run
# normally
with pytest.raises(typer.Exit) as excinfo:
reimport_custom_module()
# Verify the exit code is 1
assert excinfo.value.exit_code == 1
captured = capsys.readouterr()
assert "metadata file could not be found" in captured.out
finally:
# Restore the original modules
for name, module in original_modules.items():
sys.modules[name] = module