recce-nightly 1.9.0.20250623__py3-none-any.whl → 1.25.0.20251112a2066__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- recce/VERSION +1 -1
- recce/__init__.py +5 -0
- recce/adapter/dbt_adapter/__init__.py +318 -240
- recce/artifact.py +76 -3
- recce/cli.py +703 -71
- recce/config.py +3 -3
- recce/connect_to_cloud.py +138 -0
- recce/core.py +3 -3
- recce/data/404.html +1 -22
- recce/data/__next.__PAGE__.txt +10 -0
- recce/data/__next._full.txt +23 -0
- recce/data/__next._index.txt +8 -0
- recce/data/__next._tree.txt +12 -0
- recce/data/_next/static/6LypcDXgyuSaiSCrsmUub/_buildManifest.js +11 -0
- recce/data/_next/static/6LypcDXgyuSaiSCrsmUub/_clientMiddlewareManifest.json +1 -0
- recce/data/_next/static/chunks/0a2b2dd4b57049c2.js +1 -0
- recce/data/_next/static/chunks/19c10d219a6a21ff.js +1 -0
- recce/data/_next/static/chunks/24fd885c7180a612.js +1 -0
- recce/data/_next/static/chunks/27e66b2eab4adc32.js +19 -0
- recce/data/_next/static/chunks/71f88fcc615bf282.js +1 -0
- recce/data/_next/static/chunks/917619ab62a32388.js +1 -0
- recce/data/_next/static/chunks/93ba5a62932b704f.js +4 -0
- recce/data/_next/static/chunks/a43a2a5e06d5a92b.js +1 -0
- recce/data/_next/static/chunks/a6c78b24bd8b84fc.js +1 -0
- recce/data/_next/static/chunks/b2610ba997ff8c4f.js +110 -0
- recce/data/_next/static/chunks/ba2d87265a68599d.css +2 -0
- recce/data/_next/static/chunks/c117fd1c1382dd83.js +11 -0
- recce/data/_next/static/chunks/c9425ca46eebdde9.js +1 -0
- recce/data/_next/static/chunks/cc8a9eadba012be0.css +6 -0
- recce/data/_next/static/chunks/e124bccf574a3361.css +1 -0
- recce/data/_next/static/chunks/e392ad92847c3e17.js +1 -0
- recce/data/_next/static/chunks/e4ce95efe88dae79.js +11 -0
- recce/data/_next/static/chunks/e69c777814fea6ed.js +2 -0
- recce/data/_next/static/chunks/turbopack-21cfd73037ff57ab.js +3 -0
- recce/data/_next/static/media/favicon.a8d38d84.ico +0 -0
- recce/data/_next/static/media/montserrat-cyrillic-800-normal.d80d830d.woff2 +0 -0
- recce/data/_next/static/media/{montserrat-cyrillic-800-normal.bd5c9f50.woff → montserrat-cyrillic-800-normal.f9d58125.woff} +0 -0
- recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.076c2a93.woff2 +0 -0
- recce/data/_next/static/media/montserrat-latin-800-normal.cde454cc.woff2 +0 -0
- recce/data/_next/static/media/{montserrat-latin-800-normal.fc315020.woff → montserrat-latin-800-normal.d5761935.woff} +0 -0
- recce/data/_next/static/media/montserrat-latin-ext-800-normal.40ec0659.woff2 +0 -0
- recce/data/_next/static/media/{montserrat-latin-ext-800-normal.2e5381b2.woff → montserrat-latin-ext-800-normal.b671449b.woff} +0 -0
- recce/data/_next/static/media/{montserrat-vietnamese-800-normal.20c545e6.woff → montserrat-vietnamese-800-normal.9f7b8541.woff} +0 -0
- recce/data/_next/static/media/montserrat-vietnamese-800-normal.f9eb854e.woff2 +0 -0
- recce/data/_not-found/__next._full.txt +17 -0
- recce/data/_not-found/__next._index.txt +8 -0
- recce/data/_not-found/__next._not-found.__PAGE__.txt +5 -0
- recce/data/_not-found/__next._not-found.txt +4 -0
- recce/data/_not-found/__next._tree.txt +10 -0
- recce/data/_not-found.html +1 -0
- recce/data/_not-found.txt +17 -0
- recce/data/auth_callback.html +68 -0
- recce/data/index.html +1 -27
- recce/data/index.txt +23 -8
- recce/event/__init__.py +9 -8
- recce/event/collector.py +6 -2
- recce/event/track.py +10 -0
- recce/github.py +1 -1
- recce/mcp_server.py +632 -0
- recce/models/types.py +23 -2
- recce/pull_request.py +1 -1
- recce/run.py +23 -16
- recce/server.py +194 -19
- recce/state/__init__.py +31 -0
- recce/state/cloud.py +632 -0
- recce/state/const.py +26 -0
- recce/state/local.py +56 -0
- recce/state/state.py +119 -0
- recce/state/state_loader.py +174 -0
- recce/summary.py +2 -1
- recce/tasks/dataframe.py +59 -2
- recce/tasks/rowcount.py +4 -1
- recce/tasks/schema.py +4 -1
- recce/tasks/valuediff.py +1 -1
- recce/util/api_token.py +11 -2
- recce/util/breaking.py +9 -0
- recce/util/cll.py +1 -2
- recce/util/io.py +2 -2
- recce/util/lineage.py +19 -18
- recce/util/perf_tracking.py +85 -0
- recce/util/recce_cloud.py +229 -5
- recce/yaml/__init__.py +2 -2
- recce_cloud/__init__.py +15 -0
- recce_cloud/api/__init__.py +17 -0
- recce_cloud/api/base.py +104 -0
- recce_cloud/api/client.py +150 -0
- recce_cloud/api/exceptions.py +26 -0
- recce_cloud/api/factory.py +63 -0
- recce_cloud/api/github.py +72 -0
- recce_cloud/api/gitlab.py +78 -0
- recce_cloud/artifact.py +57 -0
- recce_cloud/ci_providers/__init__.py +9 -0
- recce_cloud/ci_providers/base.py +82 -0
- recce_cloud/ci_providers/detector.py +147 -0
- recce_cloud/ci_providers/github_actions.py +136 -0
- recce_cloud/ci_providers/gitlab_ci.py +130 -0
- recce_cloud/cli.py +303 -0
- recce_cloud/upload.py +213 -0
- {recce_nightly-1.9.0.20250623.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/METADATA +31 -27
- recce_nightly-1.25.0.20251112a2066.dist-info/RECORD +178 -0
- {recce_nightly-1.9.0.20250623.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/top_level.txt +1 -0
- tests/adapter/dbt_adapter/test_dbt_cll.py +412 -79
- tests/recce_cloud/__init__.py +0 -0
- tests/recce_cloud/test_ci_providers.py +351 -0
- tests/recce_cloud/test_cli.py +372 -0
- tests/recce_cloud/test_client.py +273 -0
- tests/recce_cloud/test_platform_clients.py +279 -0
- tests/test_cli.py +106 -3
- tests/test_cli_mcp_optional.py +45 -0
- tests/test_cloud_listing_cli.py +324 -0
- tests/test_connect_to_cloud.py +82 -0
- tests/test_core.py +148 -3
- tests/test_mcp_server.py +332 -0
- tests/test_server.py +6 -6
- tests/test_summary.py +14 -6
- recce/data/_next/static/WrRUb3nV8BhAZG_R8kVma/_buildManifest.js +0 -1
- recce/data/_next/static/chunks/181-acc61ddada3bc0ca.js +0 -43
- recce/data/_next/static/chunks/1bff33f1-1ef85cf5e658a751.js +0 -1
- recce/data/_next/static/chunks/217-879a84d70f7a907c.js +0 -2
- recce/data/_next/static/chunks/29e3cc0d-60045b2e47aa3916.js +0 -1
- recce/data/_next/static/chunks/36e1c10d-8e7be4a6c1f6ab2d.js +0 -1
- recce/data/_next/static/chunks/3998a672-03adacad07b346ac.js +0 -1
- recce/data/_next/static/chunks/3a92ee20-1081c360214f9602.js +0 -1
- recce/data/_next/static/chunks/42-cd3c06533f5fd47c.js +0 -9
- recce/data/_next/static/chunks/450c323b-fd94e7ffaa4a5efa.js +0 -1
- recce/data/_next/static/chunks/47d8844f-929aed9b1c73a905.js +0 -1
- recce/data/_next/static/chunks/608-3b079b544e5d5f5e.js +0 -15
- recce/data/_next/static/chunks/6dc81886-adbfa45836061d79.js +0 -1
- recce/data/_next/static/chunks/7a8a3e83-edf6dc64b5d5f0a5.js +0 -1
- recce/data/_next/static/chunks/7f27ae6c-d5f0438edd5c2a5b.js +0 -1
- recce/data/_next/static/chunks/86730205-cfb14e3f051bab35.js +0 -1
- recce/data/_next/static/chunks/8d700b6a.8bb140898499c512.js +0 -1
- recce/data/_next/static/chunks/92-7ab55ae02606193c.js +0 -1
- recce/data/_next/static/chunks/9746af58-a42b7d169cacadf0.js +0 -1
- recce/data/_next/static/chunks/a30376cd-de84559016d7e133.js +0 -1
- recce/data/_next/static/chunks/app/_not-found/page-01ed58b7f971d311.js +0 -1
- recce/data/_next/static/chunks/app/layout-177a410a97e0d018.js +0 -1
- recce/data/_next/static/chunks/app/page-59241c42b7dd4fcf.js +0 -1
- recce/data/_next/static/chunks/b63b1b3f-4282bdcf459e075c.js +0 -1
- recce/data/_next/static/chunks/bbda5537-9ec25eb1dd62348a.js +0 -1
- recce/data/_next/static/chunks/c132bf7d-08cb668a789d6afd.js +0 -1
- recce/data/_next/static/chunks/ce84277d-2e5d1d46910cf052.js +0 -1
- recce/data/_next/static/chunks/febdd86e-c6b525341634b860.js +0 -54
- recce/data/_next/static/chunks/fee69bc6-2dbccaf9b90474e6.js +0 -1
- recce/data/_next/static/chunks/framework-ded83d71b51ce901.js +0 -1
- recce/data/_next/static/chunks/main-app-39061b0166c47f55.js +0 -1
- recce/data/_next/static/chunks/main-b5b3ae20a1405261.js +0 -1
- recce/data/_next/static/chunks/pages/_app-437c455677d62394.js +0 -1
- recce/data/_next/static/chunks/pages/_error-e7650df18ca04bde.js +0 -1
- recce/data/_next/static/chunks/webpack-7b49d5ba7e3a434d.js +0 -1
- recce/data/_next/static/css/17a96168e3a9db13.css +0 -1
- recce/data/_next/static/css/1b121dc4d36aeb4d.css +0 -3
- recce/data/_next/static/css/35c6679a098e1e34.css +0 -1
- recce/data/_next/static/css/951e2e0eea2d4a5b.css +0 -14
- recce/data/_next/static/media/montserrat-cyrillic-800-normal.22628180.woff2 +0 -0
- recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.94a63aea.woff2 +0 -0
- recce/data/_next/static/media/montserrat-latin-800-normal.6f8fa298.woff2 +0 -0
- recce/data/_next/static/media/montserrat-latin-ext-800-normal.013b84f9.woff2 +0 -0
- recce/data/_next/static/media/montserrat-vietnamese-800-normal.c0035377.woff2 +0 -0
- recce/state.py +0 -785
- recce_nightly-1.9.0.20250623.dist-info/RECORD +0 -151
- tests/test_state.py +0 -134
- /recce/data/_next/static/{WrRUb3nV8BhAZG_R8kVma → 6LypcDXgyuSaiSCrsmUub}/_ssgManifest.js +0 -0
- /recce/data/_next/static/chunks/{polyfills-42372ed130431b0a.js → a6dad97d9634a72d.js} +0 -0
- /recce/data/_next/static/media/{montserrat-cyrillic-ext-800-normal.e6e0d8d0.woff → montserrat-cyrillic-ext-800-normal.a4fa76b5.woff} +0 -0
- /recce/data/_next/static/media/{reload-image.79aabb7d.svg → reload-image.7aa931c7.svg} +0 -0
- {recce_nightly-1.9.0.20250623.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/WHEEL +0 -0
- {recce_nightly-1.9.0.20250623.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/entry_points.txt +0 -0
- {recce_nightly-1.9.0.20250623.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from unittest.mock import Mock, patch
|
|
3
|
+
|
|
4
|
+
from click.testing import CliRunner
|
|
5
|
+
|
|
6
|
+
from recce.cli import list_organizations, list_projects, list_sessions
|
|
7
|
+
from recce.exceptions import RecceConfigException
|
|
8
|
+
from recce.util.recce_cloud import RecceCloudException
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestCloudListingCLI(unittest.TestCase):
|
|
12
|
+
"""Test cases for the cloud listing CLI commands."""
|
|
13
|
+
|
|
14
|
+
def setUp(self):
|
|
15
|
+
"""Set up test fixtures."""
|
|
16
|
+
self.runner = CliRunner()
|
|
17
|
+
|
|
18
|
+
@patch("recce.cli.prepare_api_token")
|
|
19
|
+
@patch("recce.util.recce_cloud.RecceCloud")
|
|
20
|
+
def test_list_organizations_success(self, mock_recce_cloud, mock_prepare_token):
|
|
21
|
+
"""Test successful list-organizations command."""
|
|
22
|
+
# Setup mocks
|
|
23
|
+
mock_prepare_token.return_value = "test-token"
|
|
24
|
+
mock_cloud_instance = Mock()
|
|
25
|
+
mock_cloud_instance.list_organizations.return_value = [
|
|
26
|
+
{"id": 1, "name": "org1", "display_name": "Organization 1"},
|
|
27
|
+
{"id": 2, "name": "org2", "display_name": "Organization 2"},
|
|
28
|
+
]
|
|
29
|
+
mock_recce_cloud.return_value = mock_cloud_instance
|
|
30
|
+
|
|
31
|
+
# Test command
|
|
32
|
+
result = self.runner.invoke(list_organizations, [])
|
|
33
|
+
|
|
34
|
+
# Assertions
|
|
35
|
+
self.assertEqual(result.exit_code, 0)
|
|
36
|
+
self.assertIn("Organizations", result.output)
|
|
37
|
+
self.assertIn("org1", result.output)
|
|
38
|
+
self.assertIn("Organization 1", result.output)
|
|
39
|
+
self.assertIn("org2", result.output)
|
|
40
|
+
self.assertIn("Organization 2", result.output)
|
|
41
|
+
mock_cloud_instance.list_organizations.assert_called_once()
|
|
42
|
+
|
|
43
|
+
@patch("recce.cli.prepare_api_token")
|
|
44
|
+
@patch("recce.util.recce_cloud.RecceCloud")
|
|
45
|
+
def test_list_organizations_empty(self, mock_recce_cloud, mock_prepare_token):
|
|
46
|
+
"""Test list-organizations command with no organizations."""
|
|
47
|
+
# Setup mocks
|
|
48
|
+
mock_prepare_token.return_value = "test-token"
|
|
49
|
+
mock_cloud_instance = Mock()
|
|
50
|
+
mock_cloud_instance.list_organizations.return_value = []
|
|
51
|
+
mock_recce_cloud.return_value = mock_cloud_instance
|
|
52
|
+
|
|
53
|
+
# Test command
|
|
54
|
+
result = self.runner.invoke(list_organizations, [])
|
|
55
|
+
|
|
56
|
+
# Assertions
|
|
57
|
+
self.assertEqual(result.exit_code, 0)
|
|
58
|
+
self.assertIn("No organizations found", result.output)
|
|
59
|
+
|
|
60
|
+
@patch("recce.cli.prepare_api_token")
|
|
61
|
+
def test_list_organizations_invalid_token(self, mock_prepare_token):
|
|
62
|
+
"""Test list-organizations command with invalid token."""
|
|
63
|
+
# Setup mock to raise exception
|
|
64
|
+
mock_prepare_token.side_effect = RecceConfigException("Invalid token")
|
|
65
|
+
|
|
66
|
+
# Test command
|
|
67
|
+
result = self.runner.invoke(list_organizations, [])
|
|
68
|
+
|
|
69
|
+
# Assertions
|
|
70
|
+
self.assertEqual(result.exit_code, 1)
|
|
71
|
+
|
|
72
|
+
@patch("recce.cli.prepare_api_token")
|
|
73
|
+
@patch("recce.util.recce_cloud.RecceCloud")
|
|
74
|
+
def test_list_projects_with_cli_arg(self, mock_recce_cloud, mock_prepare_token):
|
|
75
|
+
"""Test list-projects command with CLI argument."""
|
|
76
|
+
# Setup mocks
|
|
77
|
+
mock_prepare_token.return_value = "test-token"
|
|
78
|
+
mock_cloud_instance = Mock()
|
|
79
|
+
mock_cloud_instance.list_projects.return_value = [
|
|
80
|
+
{"id": 1, "name": "project1", "display_name": "Project 1"},
|
|
81
|
+
{"id": 2, "name": "project2", "display_name": "Project 2"},
|
|
82
|
+
]
|
|
83
|
+
mock_recce_cloud.return_value = mock_cloud_instance
|
|
84
|
+
|
|
85
|
+
# Test command with CLI argument
|
|
86
|
+
result = self.runner.invoke(list_projects, ["--organization", "8"])
|
|
87
|
+
|
|
88
|
+
# Assertions
|
|
89
|
+
self.assertEqual(result.exit_code, 0)
|
|
90
|
+
self.assertIn("Projects in Organization 8", result.output)
|
|
91
|
+
self.assertIn("project1", result.output)
|
|
92
|
+
self.assertIn("Project 1", result.output)
|
|
93
|
+
mock_cloud_instance.list_projects.assert_called_once_with("8")
|
|
94
|
+
|
|
95
|
+
@patch("recce.cli.prepare_api_token")
|
|
96
|
+
@patch("recce.util.recce_cloud.RecceCloud")
|
|
97
|
+
def test_list_projects_with_env_var(self, mock_recce_cloud, mock_prepare_token):
|
|
98
|
+
"""Test list-projects command with environment variable."""
|
|
99
|
+
# Setup mocks
|
|
100
|
+
mock_prepare_token.return_value = "test-token"
|
|
101
|
+
mock_cloud_instance = Mock()
|
|
102
|
+
mock_cloud_instance.list_projects.return_value = [{"id": 1, "name": "project1", "display_name": "Project 1"}]
|
|
103
|
+
mock_recce_cloud.return_value = mock_cloud_instance
|
|
104
|
+
|
|
105
|
+
# Test command with environment variable
|
|
106
|
+
result = self.runner.invoke(list_projects, [], env={"RECCE_ORGANIZATION_ID": "8"})
|
|
107
|
+
|
|
108
|
+
# Assertions
|
|
109
|
+
self.assertEqual(result.exit_code, 0)
|
|
110
|
+
self.assertIn("Projects in Organization 8", result.output)
|
|
111
|
+
self.assertIn("project1", result.output)
|
|
112
|
+
mock_cloud_instance.list_projects.assert_called_once_with("8")
|
|
113
|
+
|
|
114
|
+
@patch("recce.cli.prepare_api_token")
|
|
115
|
+
@patch("recce.util.recce_cloud.RecceCloud")
|
|
116
|
+
def test_list_projects_cli_overrides_env(self, mock_recce_cloud, mock_prepare_token):
|
|
117
|
+
"""Test list-projects command where CLI argument overrides environment variable."""
|
|
118
|
+
# Setup mocks
|
|
119
|
+
mock_prepare_token.return_value = "test-token"
|
|
120
|
+
mock_cloud_instance = Mock()
|
|
121
|
+
mock_cloud_instance.list_projects.return_value = [{"id": 1, "name": "project1", "display_name": "Project 1"}]
|
|
122
|
+
mock_recce_cloud.return_value = mock_cloud_instance
|
|
123
|
+
|
|
124
|
+
# Test command with both env var and CLI arg (CLI should win)
|
|
125
|
+
result = self.runner.invoke(list_projects, ["--organization", "10"], env={"RECCE_ORGANIZATION_ID": "8"})
|
|
126
|
+
|
|
127
|
+
# Assertions
|
|
128
|
+
self.assertEqual(result.exit_code, 0)
|
|
129
|
+
self.assertIn("Projects in Organization 10", result.output)
|
|
130
|
+
# Verify CLI argument (10) was used, not env var (8)
|
|
131
|
+
mock_cloud_instance.list_projects.assert_called_once_with("10")
|
|
132
|
+
|
|
133
|
+
@patch("recce.cli.prepare_api_token")
|
|
134
|
+
def test_list_projects_missing_organization(self, mock_prepare_token):
|
|
135
|
+
"""Test list-projects command with missing organization ID."""
|
|
136
|
+
# Setup mocks
|
|
137
|
+
mock_prepare_token.return_value = "test-token"
|
|
138
|
+
|
|
139
|
+
# Test command without organization ID
|
|
140
|
+
result = self.runner.invoke(list_projects, [])
|
|
141
|
+
|
|
142
|
+
# Assertions
|
|
143
|
+
self.assertEqual(result.exit_code, 1)
|
|
144
|
+
self.assertIn("Organization ID is required", result.output)
|
|
145
|
+
self.assertIn("--organization", result.output)
|
|
146
|
+
self.assertIn("RECCE_ORGANIZATION_ID", result.output)
|
|
147
|
+
|
|
148
|
+
@patch("recce.cli.prepare_api_token")
|
|
149
|
+
@patch("recce.util.recce_cloud.RecceCloud")
|
|
150
|
+
def test_list_projects_empty(self, mock_recce_cloud, mock_prepare_token):
|
|
151
|
+
"""Test list-projects command with no projects."""
|
|
152
|
+
# Setup mocks
|
|
153
|
+
mock_prepare_token.return_value = "test-token"
|
|
154
|
+
mock_cloud_instance = Mock()
|
|
155
|
+
mock_cloud_instance.list_projects.return_value = []
|
|
156
|
+
mock_recce_cloud.return_value = mock_cloud_instance
|
|
157
|
+
|
|
158
|
+
# Test command
|
|
159
|
+
result = self.runner.invoke(list_projects, ["--organization", "8"])
|
|
160
|
+
|
|
161
|
+
# Assertions
|
|
162
|
+
self.assertEqual(result.exit_code, 0)
|
|
163
|
+
self.assertIn("No projects found in organization 8", result.output)
|
|
164
|
+
|
|
165
|
+
@patch("recce.cli.prepare_api_token")
|
|
166
|
+
@patch("recce.util.recce_cloud.RecceCloud")
|
|
167
|
+
def test_list_sessions_with_cli_args(self, mock_recce_cloud, mock_prepare_token):
|
|
168
|
+
"""Test list-sessions command with CLI arguments."""
|
|
169
|
+
# Setup mocks
|
|
170
|
+
mock_prepare_token.return_value = "test-token"
|
|
171
|
+
mock_cloud_instance = Mock()
|
|
172
|
+
mock_cloud_instance.list_sessions.return_value = [
|
|
173
|
+
{"id": "session1", "name": "PR-123", "is_base": False},
|
|
174
|
+
{"id": "session2", "name": "Base Session", "is_base": True},
|
|
175
|
+
]
|
|
176
|
+
mock_recce_cloud.return_value = mock_cloud_instance
|
|
177
|
+
|
|
178
|
+
# Test command with CLI arguments
|
|
179
|
+
result = self.runner.invoke(list_sessions, ["--organization", "8", "--project", "7"])
|
|
180
|
+
|
|
181
|
+
# Assertions
|
|
182
|
+
self.assertEqual(result.exit_code, 0)
|
|
183
|
+
self.assertIn("Sessions in Project 7", result.output)
|
|
184
|
+
self.assertIn("PR-123", result.output)
|
|
185
|
+
self.assertIn("Base Session", result.output)
|
|
186
|
+
self.assertIn("✓", result.output) # Base session marker
|
|
187
|
+
mock_cloud_instance.list_sessions.assert_called_once_with("8", "7")
|
|
188
|
+
|
|
189
|
+
@patch("recce.cli.prepare_api_token")
|
|
190
|
+
@patch("recce.util.recce_cloud.RecceCloud")
|
|
191
|
+
def test_list_sessions_with_env_vars(self, mock_recce_cloud, mock_prepare_token):
|
|
192
|
+
"""Test list-sessions command with environment variables."""
|
|
193
|
+
# Setup mocks
|
|
194
|
+
mock_prepare_token.return_value = "test-token"
|
|
195
|
+
mock_cloud_instance = Mock()
|
|
196
|
+
mock_cloud_instance.list_sessions.return_value = [{"id": "session1", "name": "Session 1", "is_base": False}]
|
|
197
|
+
mock_recce_cloud.return_value = mock_cloud_instance
|
|
198
|
+
|
|
199
|
+
# Test command with environment variables
|
|
200
|
+
result = self.runner.invoke(list_sessions, [], env={"RECCE_ORGANIZATION_ID": "8", "RECCE_PROJECT_ID": "7"})
|
|
201
|
+
|
|
202
|
+
# Assertions
|
|
203
|
+
self.assertEqual(result.exit_code, 0)
|
|
204
|
+
self.assertIn("Sessions in Project 7", result.output)
|
|
205
|
+
self.assertIn("Session 1", result.output)
|
|
206
|
+
mock_cloud_instance.list_sessions.assert_called_once_with("8", "7")
|
|
207
|
+
|
|
208
|
+
@patch("recce.cli.prepare_api_token")
|
|
209
|
+
@patch("recce.util.recce_cloud.RecceCloud")
|
|
210
|
+
def test_list_sessions_mixed_env_and_cli(self, mock_recce_cloud, mock_prepare_token):
|
|
211
|
+
"""Test list-sessions command with mixed environment variables and CLI args."""
|
|
212
|
+
# Setup mocks
|
|
213
|
+
mock_prepare_token.return_value = "test-token"
|
|
214
|
+
mock_cloud_instance = Mock()
|
|
215
|
+
mock_cloud_instance.list_sessions.return_value = [{"id": "session1", "name": "Session 1", "is_base": False}]
|
|
216
|
+
mock_recce_cloud.return_value = mock_cloud_instance
|
|
217
|
+
|
|
218
|
+
# Test command with env var for org and CLI arg for project
|
|
219
|
+
result = self.runner.invoke(list_sessions, ["--project", "9"], env={"RECCE_ORGANIZATION_ID": "8"})
|
|
220
|
+
|
|
221
|
+
# Assertions
|
|
222
|
+
self.assertEqual(result.exit_code, 0)
|
|
223
|
+
self.assertIn("Sessions in Project 9", result.output)
|
|
224
|
+
# Verify it used env var for org (8) and CLI arg for project (9)
|
|
225
|
+
mock_cloud_instance.list_sessions.assert_called_once_with("8", "9")
|
|
226
|
+
|
|
227
|
+
@patch("recce.cli.prepare_api_token")
|
|
228
|
+
def test_list_sessions_missing_organization(self, mock_prepare_token):
|
|
229
|
+
"""Test list-sessions command with missing organization ID."""
|
|
230
|
+
# Setup mocks
|
|
231
|
+
mock_prepare_token.return_value = "test-token"
|
|
232
|
+
|
|
233
|
+
# Test command without organization ID
|
|
234
|
+
result = self.runner.invoke(list_sessions, ["--project", "7"])
|
|
235
|
+
|
|
236
|
+
# Assertions
|
|
237
|
+
self.assertEqual(result.exit_code, 1)
|
|
238
|
+
self.assertIn("Organization ID is required", result.output)
|
|
239
|
+
self.assertIn("--organization", result.output)
|
|
240
|
+
self.assertIn("RECCE_ORGANIZATION_ID", result.output)
|
|
241
|
+
|
|
242
|
+
@patch("recce.cli.prepare_api_token")
|
|
243
|
+
def test_list_sessions_missing_project(self, mock_prepare_token):
|
|
244
|
+
"""Test list-sessions command with missing project ID."""
|
|
245
|
+
# Setup mocks
|
|
246
|
+
mock_prepare_token.return_value = "test-token"
|
|
247
|
+
|
|
248
|
+
# Test command without project ID
|
|
249
|
+
result = self.runner.invoke(list_sessions, ["--organization", "8"])
|
|
250
|
+
|
|
251
|
+
# Assertions
|
|
252
|
+
self.assertEqual(result.exit_code, 1)
|
|
253
|
+
self.assertIn("Project ID is required", result.output)
|
|
254
|
+
self.assertIn("--project", result.output)
|
|
255
|
+
self.assertIn("RECCE_PROJECT_ID", result.output)
|
|
256
|
+
|
|
257
|
+
@patch("recce.cli.prepare_api_token")
|
|
258
|
+
@patch("recce.util.recce_cloud.RecceCloud")
|
|
259
|
+
def test_list_sessions_empty(self, mock_recce_cloud, mock_prepare_token):
|
|
260
|
+
"""Test list-sessions command with no sessions."""
|
|
261
|
+
# Setup mocks
|
|
262
|
+
mock_prepare_token.return_value = "test-token"
|
|
263
|
+
mock_cloud_instance = Mock()
|
|
264
|
+
mock_cloud_instance.list_sessions.return_value = []
|
|
265
|
+
mock_recce_cloud.return_value = mock_cloud_instance
|
|
266
|
+
|
|
267
|
+
# Test command
|
|
268
|
+
result = self.runner.invoke(list_sessions, ["--organization", "8", "--project", "7"])
|
|
269
|
+
|
|
270
|
+
# Assertions
|
|
271
|
+
self.assertEqual(result.exit_code, 0)
|
|
272
|
+
self.assertIn("No sessions found in project 7", result.output)
|
|
273
|
+
|
|
274
|
+
@patch("recce.cli.prepare_api_token")
|
|
275
|
+
@patch("recce.util.recce_cloud.RecceCloud")
|
|
276
|
+
def test_list_sessions_api_error(self, mock_recce_cloud, mock_prepare_token):
|
|
277
|
+
"""Test list-sessions command with API error."""
|
|
278
|
+
# Setup mocks
|
|
279
|
+
mock_prepare_token.return_value = "test-token"
|
|
280
|
+
mock_cloud_instance = Mock()
|
|
281
|
+
mock_cloud_instance.list_sessions.side_effect = RecceCloudException("Access denied", "Forbidden", 403)
|
|
282
|
+
mock_recce_cloud.return_value = mock_cloud_instance
|
|
283
|
+
|
|
284
|
+
# Test command
|
|
285
|
+
result = self.runner.invoke(list_sessions, ["--organization", "8", "--project", "7"])
|
|
286
|
+
|
|
287
|
+
# Assertions
|
|
288
|
+
self.assertEqual(result.exit_code, 1)
|
|
289
|
+
self.assertIn("Error", result.output)
|
|
290
|
+
|
|
291
|
+
@patch("recce.cli.prepare_api_token")
|
|
292
|
+
@patch("recce.util.recce_cloud.RecceCloud")
|
|
293
|
+
def test_sessions_base_session_display(self, mock_recce_cloud, mock_prepare_token):
|
|
294
|
+
"""Test that base sessions are properly marked with checkmark."""
|
|
295
|
+
# Setup mocks
|
|
296
|
+
mock_prepare_token.return_value = "test-token"
|
|
297
|
+
mock_cloud_instance = Mock()
|
|
298
|
+
mock_cloud_instance.list_sessions.return_value = [
|
|
299
|
+
{"id": "session1", "name": "Regular Session", "is_base": False},
|
|
300
|
+
{"id": "session2", "name": "Base Session", "is_base": True},
|
|
301
|
+
{"id": "session3", "name": "Another Regular", "is_base": False},
|
|
302
|
+
]
|
|
303
|
+
mock_recce_cloud.return_value = mock_cloud_instance
|
|
304
|
+
|
|
305
|
+
# Test command
|
|
306
|
+
result = self.runner.invoke(list_sessions, ["--organization", "8", "--project", "7"])
|
|
307
|
+
|
|
308
|
+
# Assertions
|
|
309
|
+
self.assertEqual(result.exit_code, 0)
|
|
310
|
+
output_lines = result.output.split("\n")
|
|
311
|
+
|
|
312
|
+
# Find the base session line and verify it has the checkmark
|
|
313
|
+
base_session_line = None
|
|
314
|
+
for line in output_lines:
|
|
315
|
+
if "Base Session" in line:
|
|
316
|
+
base_session_line = line
|
|
317
|
+
break
|
|
318
|
+
|
|
319
|
+
self.assertIsNotNone(base_session_line)
|
|
320
|
+
self.assertIn("✓", base_session_line)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
if __name__ == "__main__":
|
|
324
|
+
unittest.main()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import unittest
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
from cryptography.hazmat.primitives import hashes
|
|
7
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
8
|
+
|
|
9
|
+
from recce.connect_to_cloud import (
|
|
10
|
+
connect_to_cloud_background_task,
|
|
11
|
+
decrypt_code,
|
|
12
|
+
generate_key_pair,
|
|
13
|
+
is_callback_server_running,
|
|
14
|
+
prepare_connection_url,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConnectToCloudTests(unittest.TestCase):
|
|
19
|
+
|
|
20
|
+
def test_generate_key_pair(self):
|
|
21
|
+
private_key, public_key = generate_key_pair()
|
|
22
|
+
self.assertIsNotNone(private_key)
|
|
23
|
+
self.assertIsNotNone(public_key)
|
|
24
|
+
self.assertEqual(private_key.public_key().public_numbers(), public_key.public_numbers())
|
|
25
|
+
|
|
26
|
+
def test_prepare_connection_url(self):
|
|
27
|
+
_, public_key = generate_key_pair()
|
|
28
|
+
url, port = prepare_connection_url(public_key)
|
|
29
|
+
self.assertIn("connect?", url)
|
|
30
|
+
self.assertTrue(port >= 10000 and port <= 15000)
|
|
31
|
+
|
|
32
|
+
def test_decrypt_code(self):
|
|
33
|
+
private_key, public_key = generate_key_pair()
|
|
34
|
+
test_string = "recce-api-token-123"
|
|
35
|
+
ciphertext = public_key.encrypt(
|
|
36
|
+
test_string.encode(),
|
|
37
|
+
padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA1()), algorithm=hashes.SHA1(), label=None),
|
|
38
|
+
)
|
|
39
|
+
b64_ciphertext = base64.b64encode(ciphertext).decode()
|
|
40
|
+
result = decrypt_code(private_key, b64_ciphertext)
|
|
41
|
+
self.assertEqual(result, test_string)
|
|
42
|
+
|
|
43
|
+
@patch("recce.connect_to_cloud.update_recce_api_token")
|
|
44
|
+
@patch("recce.connect_to_cloud.update_onboarding_state")
|
|
45
|
+
@patch("recce.connect_to_cloud.RecceCloud")
|
|
46
|
+
def test_handle_callback_request_success(self, mock_recce_cloud, mock_update_state, mock_update_token):
|
|
47
|
+
private_key, public_key = generate_key_pair()
|
|
48
|
+
|
|
49
|
+
# Prepare encrypted token
|
|
50
|
+
test_token = "recce-api-token-xyz"
|
|
51
|
+
ciphertext = public_key.encrypt(
|
|
52
|
+
test_token.encode(),
|
|
53
|
+
padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA1()), algorithm=hashes.SHA1(), label=None),
|
|
54
|
+
)
|
|
55
|
+
encrypted_b64 = base64.b64encode(ciphertext).decode()
|
|
56
|
+
|
|
57
|
+
# Set up mocks
|
|
58
|
+
mock_recce_cloud.return_value.verify_token.return_value = True
|
|
59
|
+
|
|
60
|
+
from recce.connect_to_cloud import handle_callback_request
|
|
61
|
+
|
|
62
|
+
result = handle_callback_request(f"code={quote(encrypted_b64)}", private_key)
|
|
63
|
+
|
|
64
|
+
assert result == test_token
|
|
65
|
+
mock_update_token.assert_called_once_with(test_token)
|
|
66
|
+
mock_update_state.assert_called_once_with(test_token, False)
|
|
67
|
+
|
|
68
|
+
def test_is_callback_server_running(self):
|
|
69
|
+
# Should return False by default
|
|
70
|
+
self.assertFalse(is_callback_server_running())
|
|
71
|
+
|
|
72
|
+
@patch("recce.connect_to_cloud.run_one_time_http_server")
|
|
73
|
+
def test_connect_to_cloud_background_task_runs(self, mock_server):
|
|
74
|
+
private_key, public_key = generate_key_pair()
|
|
75
|
+
url, port = prepare_connection_url(public_key)
|
|
76
|
+
|
|
77
|
+
connect_to_cloud_background_task(private_key, port, url)
|
|
78
|
+
mock_server.assert_called_once()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
unittest.main()
|
tests/test_core.py
CHANGED
|
@@ -1,6 +1,153 @@
|
|
|
1
1
|
# noinspection PyUnresolvedReferences
|
|
2
|
+
import os
|
|
3
|
+
import unittest
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from recce.core import RecceContext
|
|
7
|
+
from recce.models import Check, Run, RunType
|
|
8
|
+
from recce.state import ArtifactsRoot, FileStateLoader, RecceState
|
|
2
9
|
from tests.adapter.dbt_adapter.conftest import dbt_test_helper # noqa: F401
|
|
3
10
|
|
|
11
|
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestRecceState(unittest.TestCase):
|
|
15
|
+
def test_load(self):
|
|
16
|
+
run = Run(type=RunType.QUERY, params=dict(sql_template="select * from users"))
|
|
17
|
+
check = Check(name="check 1", description="desc 1", type=run.type, params=run.params)
|
|
18
|
+
|
|
19
|
+
state = RecceState(runs=[run], checks=[check])
|
|
20
|
+
json_content = state.to_json()
|
|
21
|
+
new_state = RecceState.from_json(json_content)
|
|
22
|
+
|
|
23
|
+
run_loaded = new_state.runs[0]
|
|
24
|
+
check_loaded = new_state.checks[0]
|
|
25
|
+
|
|
26
|
+
assert run.run_id == run_loaded.run_id
|
|
27
|
+
assert check.check_id == check_loaded.check_id
|
|
28
|
+
|
|
29
|
+
def test_merge_checks(self):
|
|
30
|
+
check1 = Check(name="test1", description="", type="query")
|
|
31
|
+
check2 = Check(name="test2", description="", type="query", updated_at=datetime(2000, 1, 1))
|
|
32
|
+
check2_2 = Check(
|
|
33
|
+
name="test2_2", description="", type="query", updated_at=datetime(2020, 1, 1), check_id=check2.check_id
|
|
34
|
+
)
|
|
35
|
+
check3 = Check(name="test3", description="", type="query")
|
|
36
|
+
|
|
37
|
+
context = RecceContext()
|
|
38
|
+
state = RecceState(checks=[check1], runs=[])
|
|
39
|
+
context.import_state(state)
|
|
40
|
+
self.assertEqual(1, len(context.checks))
|
|
41
|
+
self.assertEqual(check1.name, context.checks[0].name)
|
|
42
|
+
|
|
43
|
+
context = RecceContext(checks=[check1, check2])
|
|
44
|
+
state = RecceState(checks=[check1, check2_2, check3], runs=[])
|
|
45
|
+
context.import_state(state)
|
|
46
|
+
self.assertEqual(3, len(context.checks))
|
|
47
|
+
self.assertEqual(check2_2.name, context.checks[1].name)
|
|
48
|
+
|
|
49
|
+
def test_merge_preset_checks(self):
|
|
50
|
+
check1 = Check(
|
|
51
|
+
name="test1",
|
|
52
|
+
description="test1",
|
|
53
|
+
type="query",
|
|
54
|
+
params=dict(foo="bar"),
|
|
55
|
+
updated_at=datetime(2000, 1, 1),
|
|
56
|
+
is_preset=True,
|
|
57
|
+
)
|
|
58
|
+
check2 = Check(
|
|
59
|
+
name="test2",
|
|
60
|
+
description="test2",
|
|
61
|
+
type="query",
|
|
62
|
+
params=dict(foo="bar"),
|
|
63
|
+
updated_at=datetime(2001, 1, 1),
|
|
64
|
+
is_preset=True,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
context = RecceContext(checks=[check1])
|
|
68
|
+
state = RecceState(checks=[check2], runs=[])
|
|
69
|
+
context.import_state(state)
|
|
70
|
+
self.assertEqual(1, len(context.checks))
|
|
71
|
+
self.assertEqual(check2.name, context.checks[0].name)
|
|
72
|
+
|
|
73
|
+
context = RecceContext(checks=[check2])
|
|
74
|
+
state = RecceState(checks=[check1], runs=[])
|
|
75
|
+
context.import_state(state)
|
|
76
|
+
self.assertEqual(1, len(context.checks))
|
|
77
|
+
self.assertEqual(check2.name, context.checks[0].name)
|
|
78
|
+
|
|
79
|
+
def test_revert_checks(self):
|
|
80
|
+
check1 = Check(name="test1", description="", type="query")
|
|
81
|
+
check2 = Check(name="test2", description="", type="query")
|
|
82
|
+
check2_2 = Check(name="test2_2", description="", type="query", check_id=check2.check_id)
|
|
83
|
+
check3 = Check(name="test3", description="", type="query")
|
|
84
|
+
|
|
85
|
+
context = RecceContext(checks=[check1, check2])
|
|
86
|
+
state = RecceState(checks=[check2_2, check3], runs=[])
|
|
87
|
+
context.import_state(state, merge=False)
|
|
88
|
+
self.assertEqual(2, len(context.checks))
|
|
89
|
+
self.assertEqual(check2_2.name, context.checks[0].name)
|
|
90
|
+
|
|
91
|
+
def test_merge_runs(self):
|
|
92
|
+
run1 = Run(type="query")
|
|
93
|
+
run2 = Run(type="query")
|
|
94
|
+
run3 = Run(type="query")
|
|
95
|
+
|
|
96
|
+
context = RecceContext(runs=[])
|
|
97
|
+
state = RecceState(runs=[run1])
|
|
98
|
+
context.import_state(state)
|
|
99
|
+
self.assertEqual(1, len(context.runs))
|
|
100
|
+
|
|
101
|
+
context = RecceContext(runs=[run1, run2])
|
|
102
|
+
state = RecceState(runs=[run2, run3])
|
|
103
|
+
context.import_state(state)
|
|
104
|
+
self.assertEqual(3, len(context.runs))
|
|
105
|
+
|
|
106
|
+
def test_merge_dbt_artifacts(self):
|
|
107
|
+
import json
|
|
108
|
+
import os
|
|
109
|
+
|
|
110
|
+
with open(os.path.join(current_dir, "manifest.json"), "r") as f:
|
|
111
|
+
manifest = json.load(f)
|
|
112
|
+
manifest["metadata"]["generated_at"] = "2000-01-01T00:00:00Z"
|
|
113
|
+
artifacts = ArtifactsRoot(
|
|
114
|
+
base=dict(
|
|
115
|
+
manifest=manifest,
|
|
116
|
+
),
|
|
117
|
+
current=dict(
|
|
118
|
+
manifest=manifest,
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
from tests.adapter.dbt_adapter.dbt_test_helper import DbtTestHelper
|
|
123
|
+
|
|
124
|
+
adapter = DbtTestHelper().adapter
|
|
125
|
+
adapter.import_artifacts(artifacts)
|
|
126
|
+
self.assertNotEqual(adapter.base_manifest.metadata.invocation_id, manifest.get("metadata").get("invocation_id"))
|
|
127
|
+
|
|
128
|
+
manifest["metadata"]["generated_at"] = "2099-01-01T00:00:00Z"
|
|
129
|
+
adapter.import_artifacts(artifacts)
|
|
130
|
+
self.assertEqual(adapter.base_manifest.metadata.invocation_id, manifest.get("metadata").get("invocation_id"))
|
|
131
|
+
|
|
132
|
+
def test_state_loader(self):
|
|
133
|
+
# copy ./recce_state.json to temp and open
|
|
134
|
+
|
|
135
|
+
# use library to create a temp file in the context
|
|
136
|
+
import os
|
|
137
|
+
import shutil
|
|
138
|
+
import tempfile
|
|
139
|
+
|
|
140
|
+
with tempfile.NamedTemporaryFile() as f:
|
|
141
|
+
# copy ./recce_state.json to temp file
|
|
142
|
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
143
|
+
state_file = os.path.join(current_dir, "recce_state.json")
|
|
144
|
+
shutil.copy(state_file, f.name)
|
|
145
|
+
|
|
146
|
+
# load the state file
|
|
147
|
+
state_loader = FileStateLoader(state_file=f.name)
|
|
148
|
+
state = state_loader.load()
|
|
149
|
+
assert len(state.runs) == 17
|
|
150
|
+
|
|
4
151
|
|
|
5
152
|
def test_lineage_diff(dbt_test_helper):
|
|
6
153
|
sql_model1 = """
|
|
@@ -24,6 +171,4 @@ def test_lineage_diff(dbt_test_helper):
|
|
|
24
171
|
nodediff = result.diff.get("model1")
|
|
25
172
|
assert nodediff is None
|
|
26
173
|
nodediff2 = result.diff.get("model2")
|
|
27
|
-
assert
|
|
28
|
-
nodediff2 is not None and nodediff2.change_status == "modified" and nodediff2.change.category == "non_breaking"
|
|
29
|
-
)
|
|
174
|
+
assert nodediff2 is not None and nodediff2.change_status == "modified"
|