Skip to content

Commit 5d2e135

Browse files
authored
feat: Seed commands tests, rework projects internals (#189)
The focus of this diff is to seed example tests using the responses library for the commands.py file, which currently has no test coverage. The `projects` and `submit` command, which are among the more utilized functions are used here as examples. In the process of adding tests, the projects internals in commands.py was found to be confusing and made to be more intuitive. A deprecation warning is attached to the `-i` option in the `projects` command. Instead of requiring an input which is unused, this turns out into a flag and renamed to `--names`.
1 parent ebec6bc commit 5d2e135

11 files changed

Lines changed: 470 additions & 58 deletions

File tree

CHANGELOG.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ Added
99
~~~~~
1010

1111
- isort for automatic import sorting
12+
- Example initial tests for `commands` file using `responses` pattern, starting with
13+
`submit` and `projects`.
14+
- Deprecation warning for existing `-i` option for `projects` command.
1215
- Binder build cache step
1316

1417
Updated
@@ -20,7 +23,16 @@ Fixed
2023
~~~~~
2124

2225
- Issue with CodeCov for GitHub action CI
26+
- `-i` option for `projects` command did not output anything to console when called from
27+
cli.
28+
- Pinned numpy to <=1.19.5 due to an incompatibility issue with numpy 1.20.0 on python 3.7
2329

30+
Updated
31+
~~~~~~~
32+
33+
- Added new option "--names" to `projects` CLI command. This is meant as a better
34+
named and more intuitive replacement for the existing `-i` option.
35+
- Returned more explicit error statuses for `projects` and `submit` commands.
2436

2537
v9.0.0
2638
------

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ def run_tests(self):
5252
analysis_deps = [
5353
"autoprotocol>=7.1,<8",
5454
"matplotlib>=3,<4",
55-
"numpy>=1.14,<2",
55+
# incompatibilities with release 1.20.0
56+
"numpy>=1.14,<=1.19.5",
5657
"pandas>=0.23,<1",
5758
"pillow>=3,<4",
5859
"plotly>=1.13,<2",

test/cli_test.py

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from click.testing import CliRunner
66
from Crypto.PublicKey import RSA
7+
from transcriptic import commands
78
from transcriptic.cli import cli
89

910

@@ -40,7 +41,9 @@ def temp_ssh_key(tmpdir_factory):
4041

4142
@pytest.fixture
4243
def cli_test_runner(temp_tx_dotfile):
43-
runner = CliRunner(env={"TRANSCRIPTIC_CONFIG": str(temp_tx_dotfile)})
44+
runner = CliRunner(
45+
env={"TRANSCRIPTIC_CONFIG": str(temp_tx_dotfile)}, mix_stderr=False
46+
)
4447
yield runner
4548

4649

@@ -58,7 +61,7 @@ def test_login_nonexistent_key_path(cli_test_runner):
5861
["login", "--rsa-key", "/temp/path/invalid_key.pem"],
5962
input="\n".join(["email@foo.com", "barpw"]),
6063
)
61-
assert "Invalid value for '--rsa-key'" in result.output
64+
assert "Invalid value for '--rsa-key'" in result.stderr
6265

6366

6467
def test_login_random_file_for_key(cli_test_runner, tmpdir_factory):
@@ -72,8 +75,9 @@ def test_login_random_file_for_key(cli_test_runner, tmpdir_factory):
7275
)
7376
assert (
7477
"Error loading RSA key: Could not parse the specified RSA Key, "
75-
"ensure it is a PRIVATE key in PEM format" in result.output
78+
"ensure it is a PRIVATE key in PEM format" in result.stderr
7679
)
80+
assert result.exit_code == 1
7781

7882

7983
def test_login_public_key(cli_test_runner, temp_ssh_key):
@@ -84,3 +88,88 @@ def test_login_public_key(cli_test_runner, temp_ssh_key):
8488
input="\n".join(["email@foo.com", "barpw"]),
8589
)
8690
assert "Error connecting to host: This is not a private key" in result.output
91+
assert result.exit_code == 1
92+
93+
94+
def test_projects_exception(cli_test_runner, monkeypatch):
95+
def mocked_exception(api, i, json_flag, names_only):
96+
raise RuntimeError("Some runtime error")
97+
98+
monkeypatch.setattr(commands, "projects", mocked_exception)
99+
100+
result = cli_test_runner.invoke(
101+
cli,
102+
["projects"],
103+
)
104+
assert result.stderr == (
105+
"There was an error listing the projects in your "
106+
"organization. Make sure your login details are correct.\n"
107+
)
108+
109+
110+
def test_projects_names_only(cli_test_runner, monkeypatch):
111+
mocked_return = {"p123": "Foo"}
112+
monkeypatch.setattr(
113+
commands, "projects", lambda api, i, json_flag, names_only: mocked_return
114+
)
115+
116+
result = cli_test_runner.invoke(
117+
cli,
118+
["projects", "--names"],
119+
)
120+
assert result.output == f"{mocked_return}\n"
121+
122+
123+
def test_projects_json_flag(cli_test_runner, monkeypatch):
124+
mocked_return = [{"archived_at": "some datetime", "id": "p123", "name": "Foo"}]
125+
monkeypatch.setattr(
126+
commands, "projects", lambda api, i, json_flag, names_only: mocked_return
127+
)
128+
129+
result = cli_test_runner.invoke(
130+
cli,
131+
["projects", "--json"],
132+
)
133+
assert result.output == f"{json.dumps(mocked_return)}\n"
134+
135+
136+
def test_projects_default(cli_test_runner, monkeypatch):
137+
mocked_return = {"p123": "Foo (archived)"}
138+
monkeypatch.setattr(
139+
commands, "projects", lambda api, i, json_flag, names_only: mocked_return
140+
)
141+
142+
result = cli_test_runner.invoke(
143+
cli,
144+
["projects"],
145+
)
146+
assert result.output == (
147+
"\n"
148+
" PROJECTS:\n"
149+
" \n"
150+
" PROJECT NAME | PROJECT ID \n"
151+
"--------------------------------------------------------------------------------\n"
152+
"Foo (archived) | p123 \n"
153+
"--------------------------------------------------------------------------------\n"
154+
)
155+
156+
157+
def test_submit_exception_handling(cli_test_runner, monkeypatch):
158+
runtime_error = "Some runtime error message"
159+
160+
def mocked_exception(api, file, project, title, test, pm):
161+
raise RuntimeError(runtime_error)
162+
163+
monkeypatch.setattr(commands, "submit", mocked_exception)
164+
result = cli_test_runner.invoke(cli, ["submit", "--project", "some project"])
165+
assert result.stderr == f"{runtime_error}\n"
166+
assert result.exit_code == 1
167+
168+
169+
def test_submit_success(cli_test_runner, monkeypatch):
170+
mock_url = "http://mock-api/mock/p123/runs/r123"
171+
monkeypatch.setattr(
172+
commands, "submit", lambda api, file, project, title, test, pm: mock_url
173+
)
174+
result = cli_test_runner.invoke(cli, ["submit", "--project", "some project"])
175+
assert result.output == f"Run created: {mock_url}\n"

test/commands/__init__.py

Whitespace-only changes.

test/commands/projects_test.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import json
2+
3+
import pytest
4+
import responses
5+
6+
from transcriptic import commands
7+
8+
9+
class TestProjects:
10+
@responses.activate
11+
def test_projects_invalid(self, test_connection):
12+
responses.add(
13+
responses.GET,
14+
test_connection.get_route("get_projects"),
15+
json="some verbose error",
16+
status=404,
17+
)
18+
19+
with pytest.raises(RuntimeError):
20+
commands.projects(test_connection)
21+
22+
@responses.activate
23+
def test_projects_names_only(self, test_connection):
24+
responses.add(
25+
responses.GET,
26+
test_connection.get_route("get_projects"),
27+
json={
28+
"projects": [
29+
{"archived_at": "some datetime", "id": "p123", "name": "Foo"}
30+
]
31+
},
32+
)
33+
34+
actual = commands.projects(test_connection, names_only=True)
35+
36+
expected = {"p123": "Foo"}
37+
assert actual == expected
38+
39+
@responses.activate
40+
def test_projects_deprecated_i(self, test_connection):
41+
responses.add(
42+
responses.GET,
43+
test_connection.get_route("get_projects"),
44+
json={
45+
"projects": [
46+
{"archived_at": "some datetime", "id": "p123", "name": "Foo"}
47+
]
48+
},
49+
)
50+
51+
with pytest.warns(FutureWarning):
52+
actual = commands.projects(test_connection, i=True)
53+
54+
expected = {"p123": "Foo"}
55+
assert actual == expected
56+
57+
@responses.activate
58+
def test_projects_json_flag(self, test_connection):
59+
responses.add(
60+
responses.GET,
61+
test_connection.get_route("get_projects"),
62+
json={
63+
"projects": [
64+
{"archived_at": "some datetime", "id": "p123", "name": "Foo"}
65+
]
66+
},
67+
)
68+
69+
actual = commands.projects(test_connection, json_flag=True)
70+
71+
expected = [{"archived_at": "some datetime", "id": "p123", "name": "Foo"}]
72+
assert actual == expected
73+
74+
@responses.activate
75+
def test_projects_default_return(self, test_connection):
76+
responses.add(
77+
responses.GET,
78+
test_connection.get_route("get_projects"),
79+
json={
80+
"projects": [
81+
{"archived_at": "some datetime", "id": "p123", "name": "Foo"}
82+
]
83+
},
84+
)
85+
86+
actual = commands.projects(test_connection)
87+
88+
expected = {"p123": "Foo (archived)"}
89+
assert actual == expected

test/commands/submit_test.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import json
2+
3+
import pytest
4+
import responses
5+
6+
from transcriptic import commands
7+
8+
9+
class TestSubmit:
10+
"""
11+
Note: Underlying helper functions are tested separately in `TestUtils` class. This
12+
uses monkeypatching to mock out those functions.
13+
"""
14+
15+
@pytest.fixture
16+
def valid_json_file(self, tmpdir):
17+
path = tmpdir.mkdir("foo").join("valid-input.json")
18+
path.write(json.dumps({"refs": {}, "instructions": []}))
19+
yield path
20+
21+
def test_invalid_pm(self, monkeypatch, test_connection):
22+
monkeypatch.setattr(commands, "is_valid_payment_method", lambda api, pm: False)
23+
24+
with pytest.raises(RuntimeError) as error:
25+
commands.submit(test_connection, "some file", "some project", pm="invalid")
26+
27+
assert f"{error.value}" == (
28+
"Payment method is invalid. Please specify a payment "
29+
"method from `transcriptic payments` or not specify the "
30+
"`--payment` flag to use the default payment method."
31+
)
32+
33+
def test_invalid_project(self, monkeypatch, test_connection):
34+
monkeypatch.setattr(commands, "get_project_id", lambda api, project: False)
35+
36+
with pytest.raises(RuntimeError) as error:
37+
commands.submit(test_connection, "some file", "invalid_project")
38+
39+
assert f"{error.value}" == "Invalid project invalid_project specified"
40+
41+
def test_invalid_file(self, monkeypatch, test_connection, tmpdir):
42+
path = tmpdir.mkdir("foo").join("invalid-input.txt")
43+
path.write("this is not json")
44+
45+
monkeypatch.setattr(commands, "get_project_id", lambda api, project: "p123")
46+
47+
with pytest.raises(RuntimeError) as error:
48+
commands.submit(test_connection, path, "project name")
49+
50+
assert (
51+
f"{error.value}"
52+
== "Error: Could not submit since your manifest.json file is improperly "
53+
"formatted."
54+
)
55+
56+
@responses.activate
57+
def test_valid_submission(self, monkeypatch, test_connection, valid_json_file):
58+
monkeypatch.setattr(commands, "get_project_id", lambda api, project: "p123")
59+
60+
responses.add(
61+
responses.POST,
62+
test_connection.get_route("submit_run", project_id="p123"),
63+
json={"id": "r123"},
64+
)
65+
66+
actual = commands.submit(test_connection, valid_json_file, "project name")
67+
68+
expected = "http://mock-api/mock/p123/runs/r123"
69+
assert actual == expected
70+
71+
@responses.activate
72+
def test_submit_exception_handling(
73+
self, monkeypatch, test_connection, valid_json_file
74+
):
75+
monkeypatch.setattr(commands, "get_project_id", lambda api, project: "p123")
76+
77+
responses.add(
78+
responses.POST,
79+
test_connection.get_route("submit_run", project_id="p123"),
80+
json="some verbose error",
81+
status=404,
82+
)
83+
84+
with pytest.raises(RuntimeError) as error:
85+
commands.submit(test_connection, valid_json_file, "project name")
86+
87+
assert "Error: Couldn't create run (404)" in f"{error.value}"

test/commands/utils_test.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import responses
2+
3+
from transcriptic import commands
4+
5+
6+
class TestUtils:
7+
@responses.activate
8+
def test_valid_payment_method_true(self, test_connection):
9+
responses.add(
10+
responses.GET,
11+
test_connection.get_route("get_payment_methods"),
12+
json=[{"id": "someId", "is_valid": True}],
13+
)
14+
assert commands.is_valid_payment_method(test_connection, "someId")
15+
16+
@responses.activate
17+
def test_valid_payment_method_false(self, test_connection):
18+
responses.add(
19+
responses.GET,
20+
test_connection.get_route("get_payment_methods"),
21+
json=[{"id": "someId", "is_valid": True}],
22+
)
23+
assert not commands.is_valid_payment_method(test_connection, "invalidId")
24+
25+
@responses.activate
26+
def test_get_project_id(self, test_connection):
27+
responses.add(
28+
responses.GET,
29+
test_connection.get_route("get_projects"),
30+
json={
31+
"projects": [
32+
{"archived_at": "some datetime", "id": "p123", "name": "Foo"}
33+
]
34+
},
35+
)
36+
37+
actual = commands.get_project_id(test_connection, "Foo")
38+
39+
expected = "p123"
40+
assert actual == expected

0 commit comments

Comments
 (0)