pytest-neon 2.1.3__tar.gz → 2.1.4__tar.gz
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.
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/PKG-INFO +1 -1
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/pyproject.toml +1 -1
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/src/pytest_neon/__init__.py +1 -1
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/src/pytest_neon/plugin.py +94 -1
- pytest_neon-2.1.4/tests/test_branch_name_prefix.py +328 -0
- pytest_neon-2.1.4/tests/test_default_branch_safety.py +82 -0
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/uv.lock +1 -1
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/.env.example +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/.github/workflows/release.yml +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/.github/workflows/tests.yml +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/.gitignore +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/.neon +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/CLAUDE.md +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/LICENSE +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/README.md +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/src/pytest_neon/py.typed +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/tests/conftest.py +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/tests/test_branch_lifecycle.py +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/tests/test_cli_options.py +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/tests/test_env_var.py +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/tests/test_fixture_errors.py +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/tests/test_integration.py +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/tests/test_migrations.py +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/tests/test_readwrite_readonly_fixtures.py +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/tests/test_reset_behavior.py +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/tests/test_skip_behavior.py +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.1.4}/tests/test_xdist_worker_support.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-neon
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.4
|
|
4
4
|
Summary: Pytest plugin for Neon database branch isolation in tests
|
|
5
5
|
Project-URL: Homepage, https://github.com/ZainRizvi/pytest-neon
|
|
6
6
|
Project-URL: Repository, https://github.com/ZainRizvi/pytest-neon
|
|
@@ -67,6 +67,50 @@ def _get_xdist_worker_id() -> str:
|
|
|
67
67
|
return os.environ.get("PYTEST_XDIST_WORKER", "main")
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
def _sanitize_branch_name(name: str) -> str:
|
|
71
|
+
"""
|
|
72
|
+
Sanitize a string for use in Neon branch names.
|
|
73
|
+
|
|
74
|
+
Only allows alphanumeric characters, hyphens, and underscores.
|
|
75
|
+
All other characters (including non-ASCII) are replaced with hyphens.
|
|
76
|
+
"""
|
|
77
|
+
import re
|
|
78
|
+
|
|
79
|
+
# Replace anything that's not alphanumeric, hyphen, or underscore with hyphen
|
|
80
|
+
sanitized = re.sub(r"[^a-zA-Z0-9_-]", "-", name)
|
|
81
|
+
# Collapse multiple hyphens into one
|
|
82
|
+
sanitized = re.sub(r"-+", "-", sanitized)
|
|
83
|
+
# Remove leading/trailing hyphens
|
|
84
|
+
sanitized = sanitized.strip("-")
|
|
85
|
+
return sanitized
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _get_git_branch_name() -> str | None:
|
|
89
|
+
"""
|
|
90
|
+
Get the current git branch name (sanitized), or None if not in a git repo.
|
|
91
|
+
|
|
92
|
+
Used to include the git branch in Neon branch names, making it easier
|
|
93
|
+
to identify which git branch/PR created orphaned test branches.
|
|
94
|
+
|
|
95
|
+
The branch name is sanitized to replace special characters with hyphens.
|
|
96
|
+
"""
|
|
97
|
+
import subprocess
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
result = subprocess.run(
|
|
101
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
102
|
+
capture_output=True,
|
|
103
|
+
text=True,
|
|
104
|
+
timeout=5,
|
|
105
|
+
)
|
|
106
|
+
if result.returncode == 0:
|
|
107
|
+
branch = result.stdout.strip()
|
|
108
|
+
return _sanitize_branch_name(branch) if branch else None
|
|
109
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
110
|
+
pass
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
70
114
|
def _get_schema_fingerprint(connection_string: str) -> tuple[tuple[Any, ...], ...]:
|
|
71
115
|
"""
|
|
72
116
|
Get a fingerprint of the database schema for change detection.
|
|
@@ -110,6 +154,30 @@ class NeonBranch:
|
|
|
110
154
|
parent_id: str | None = None
|
|
111
155
|
|
|
112
156
|
|
|
157
|
+
def _get_default_branch_id(neon: NeonAPI, project_id: str) -> str | None:
|
|
158
|
+
"""
|
|
159
|
+
Get the default/primary branch ID for a project.
|
|
160
|
+
|
|
161
|
+
This is used as a safety check to ensure we never accidentally
|
|
162
|
+
perform destructive operations (like password reset) on the
|
|
163
|
+
production branch.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
The branch ID of the default branch, or None if not found.
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
response = neon.branches(project_id=project_id)
|
|
170
|
+
for branch in response.branches:
|
|
171
|
+
# Check both 'default' and 'primary' flags for compatibility
|
|
172
|
+
if getattr(branch, "default", False) or getattr(branch, "primary", False):
|
|
173
|
+
return branch.id
|
|
174
|
+
except Exception:
|
|
175
|
+
# If we can't fetch branches, don't block - the safety check
|
|
176
|
+
# will be skipped but tests can still run
|
|
177
|
+
pass
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
113
181
|
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
114
182
|
"""Add Neon-specific command line options and ini settings."""
|
|
115
183
|
group = parser.getgroup("neon", "Neon database branching")
|
|
@@ -279,8 +347,21 @@ def _create_neon_branch(
|
|
|
279
347
|
|
|
280
348
|
neon = NeonAPI(api_key=api_key)
|
|
281
349
|
|
|
350
|
+
# Cache the default branch ID for safety checks (only fetch once per session)
|
|
351
|
+
if not hasattr(config, "_neon_default_branch_id"):
|
|
352
|
+
config._neon_default_branch_id = _get_default_branch_id(neon, project_id) # type: ignore[attr-defined]
|
|
353
|
+
|
|
282
354
|
# Generate unique branch name
|
|
283
|
-
|
|
355
|
+
# Format: pytest-[git branch (first 15 chars)]-[random]-[suffix]
|
|
356
|
+
# This helps identify orphaned branches by showing which git branch created them
|
|
357
|
+
random_suffix = os.urandom(2).hex() # 2 bytes = 4 hex chars
|
|
358
|
+
git_branch = _get_git_branch_name()
|
|
359
|
+
if git_branch:
|
|
360
|
+
# Truncate git branch to 15 chars to keep branch names reasonable
|
|
361
|
+
git_prefix = git_branch[:15]
|
|
362
|
+
branch_name = f"pytest-{git_prefix}-{random_suffix}{branch_name_suffix}"
|
|
363
|
+
else:
|
|
364
|
+
branch_name = f"pytest-{random_suffix}{branch_name_suffix}"
|
|
284
365
|
|
|
285
366
|
# Build branch creation payload
|
|
286
367
|
branch_config: dict[str, Any] = {"name": branch_name}
|
|
@@ -341,6 +422,18 @@ def _create_neon_branch(
|
|
|
341
422
|
|
|
342
423
|
host = endpoint.host
|
|
343
424
|
|
|
425
|
+
# SAFETY CHECK: Ensure we never reset password on the default/production branch
|
|
426
|
+
# This should be impossible since we just created this branch, but we check
|
|
427
|
+
# defensively to prevent catastrophic mistakes if there's ever a bug
|
|
428
|
+
default_branch_id = getattr(config, "_neon_default_branch_id", None)
|
|
429
|
+
if default_branch_id and branch.id == default_branch_id:
|
|
430
|
+
raise RuntimeError(
|
|
431
|
+
f"SAFETY CHECK FAILED: Attempted to reset password on default branch "
|
|
432
|
+
f"{branch.id}. This should never happen - the plugin creates new "
|
|
433
|
+
f"branches and should never operate on the default branch. "
|
|
434
|
+
f"Please report this bug at https://github.com/ZainRizvi/pytest-neon/issues"
|
|
435
|
+
)
|
|
436
|
+
|
|
344
437
|
# Reset password to get the password value
|
|
345
438
|
# (newly created branches don't expose password)
|
|
346
439
|
password_response = neon.role_password_reset(
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Tests for git branch name in Neon branch names."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
from neon_api.schema import EndpointState
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestSanitizeBranchName:
|
|
10
|
+
"""Tests for _sanitize_branch_name helper."""
|
|
11
|
+
|
|
12
|
+
def test_replaces_slashes(self):
|
|
13
|
+
"""Replaces forward slashes with hyphens."""
|
|
14
|
+
from pytest_neon.plugin import _sanitize_branch_name
|
|
15
|
+
|
|
16
|
+
assert _sanitize_branch_name("feature/my-branch") == "feature-my-branch"
|
|
17
|
+
|
|
18
|
+
def test_replaces_multiple_special_chars(self):
|
|
19
|
+
"""Replaces various special characters with hyphens."""
|
|
20
|
+
from pytest_neon.plugin import _sanitize_branch_name
|
|
21
|
+
|
|
22
|
+
assert _sanitize_branch_name("feat@user#123") == "feat-user-123"
|
|
23
|
+
|
|
24
|
+
def test_collapses_multiple_hyphens(self):
|
|
25
|
+
"""Collapses multiple consecutive hyphens into one."""
|
|
26
|
+
from pytest_neon.plugin import _sanitize_branch_name
|
|
27
|
+
|
|
28
|
+
assert _sanitize_branch_name("feature//branch") == "feature-branch"
|
|
29
|
+
assert _sanitize_branch_name("a---b") == "a-b"
|
|
30
|
+
|
|
31
|
+
def test_strips_leading_trailing_hyphens(self):
|
|
32
|
+
"""Removes leading and trailing hyphens."""
|
|
33
|
+
from pytest_neon.plugin import _sanitize_branch_name
|
|
34
|
+
|
|
35
|
+
assert _sanitize_branch_name("/feature/") == "feature"
|
|
36
|
+
assert _sanitize_branch_name("--branch--") == "branch"
|
|
37
|
+
|
|
38
|
+
def test_preserves_valid_chars(self):
|
|
39
|
+
"""Preserves alphanumeric chars, hyphens, and underscores."""
|
|
40
|
+
from pytest_neon.plugin import _sanitize_branch_name
|
|
41
|
+
|
|
42
|
+
assert _sanitize_branch_name("my-branch_v1") == "my-branch_v1"
|
|
43
|
+
|
|
44
|
+
def test_replaces_dots(self):
|
|
45
|
+
"""Replaces dots with hyphens."""
|
|
46
|
+
from pytest_neon.plugin import _sanitize_branch_name
|
|
47
|
+
|
|
48
|
+
assert _sanitize_branch_name("v1.0.0") == "v1-0-0"
|
|
49
|
+
|
|
50
|
+
def test_replaces_non_ascii(self):
|
|
51
|
+
"""Replaces non-ASCII characters with hyphens."""
|
|
52
|
+
from pytest_neon.plugin import _sanitize_branch_name
|
|
53
|
+
|
|
54
|
+
assert _sanitize_branch_name("feature-über") == "feature-ber"
|
|
55
|
+
assert _sanitize_branch_name("日本語branch") == "branch"
|
|
56
|
+
assert _sanitize_branch_name("test™") == "test"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestGetGitBranchName:
|
|
60
|
+
"""Tests for _get_git_branch_name helper."""
|
|
61
|
+
|
|
62
|
+
def test_returns_branch_name_in_git_repo(self):
|
|
63
|
+
"""Returns git branch name when in a git repo."""
|
|
64
|
+
from pytest_neon.plugin import _get_git_branch_name
|
|
65
|
+
|
|
66
|
+
# We're running in a git repo, so this should return something
|
|
67
|
+
result = _get_git_branch_name()
|
|
68
|
+
assert result is not None
|
|
69
|
+
assert isinstance(result, str)
|
|
70
|
+
assert len(result) > 0
|
|
71
|
+
|
|
72
|
+
def test_returns_none_when_git_fails(self):
|
|
73
|
+
"""Returns None when git command fails."""
|
|
74
|
+
from pytest_neon.plugin import _get_git_branch_name
|
|
75
|
+
|
|
76
|
+
with patch("subprocess.run") as mock_run:
|
|
77
|
+
mock_run.return_value = MagicMock(returncode=1, stdout="")
|
|
78
|
+
result = _get_git_branch_name()
|
|
79
|
+
assert result is None
|
|
80
|
+
|
|
81
|
+
def test_returns_none_when_git_not_found(self):
|
|
82
|
+
"""Returns None when git is not installed."""
|
|
83
|
+
from pytest_neon.plugin import _get_git_branch_name
|
|
84
|
+
|
|
85
|
+
with patch("subprocess.run") as mock_run:
|
|
86
|
+
mock_run.side_effect = FileNotFoundError()
|
|
87
|
+
result = _get_git_branch_name()
|
|
88
|
+
assert result is None
|
|
89
|
+
|
|
90
|
+
def test_returns_none_on_timeout(self):
|
|
91
|
+
"""Returns None when git command times out."""
|
|
92
|
+
import subprocess
|
|
93
|
+
|
|
94
|
+
from pytest_neon.plugin import _get_git_branch_name
|
|
95
|
+
|
|
96
|
+
with patch("subprocess.run") as mock_run:
|
|
97
|
+
mock_run.side_effect = subprocess.TimeoutExpired(cmd="git", timeout=5)
|
|
98
|
+
result = _get_git_branch_name()
|
|
99
|
+
assert result is None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestBranchNameWithGitBranch:
|
|
103
|
+
"""Tests for branch name generation with git branch."""
|
|
104
|
+
|
|
105
|
+
def test_branch_name_includes_git_branch(self):
|
|
106
|
+
"""Branch name includes git branch when in a repo."""
|
|
107
|
+
from pytest_neon.plugin import _create_neon_branch
|
|
108
|
+
|
|
109
|
+
mock_request = MagicMock()
|
|
110
|
+
mock_config = MagicMock()
|
|
111
|
+
mock_request.config = mock_config
|
|
112
|
+
|
|
113
|
+
def mock_getoption(name, default=None):
|
|
114
|
+
if name == "neon_api_key":
|
|
115
|
+
return "test-api-key"
|
|
116
|
+
if name == "neon_project_id":
|
|
117
|
+
return "test-project"
|
|
118
|
+
if name == "neon_keep_branches":
|
|
119
|
+
return True
|
|
120
|
+
if name == "neon_branch_expiry":
|
|
121
|
+
return 0
|
|
122
|
+
return default
|
|
123
|
+
|
|
124
|
+
def mock_getini(name):
|
|
125
|
+
if name == "neon_database":
|
|
126
|
+
return "neondb"
|
|
127
|
+
if name == "neon_role":
|
|
128
|
+
return "neondb_owner"
|
|
129
|
+
if name == "neon_env_var":
|
|
130
|
+
return "DATABASE_URL"
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
mock_config.getoption.side_effect = mock_getoption
|
|
134
|
+
mock_config.getini.side_effect = mock_getini
|
|
135
|
+
|
|
136
|
+
with (
|
|
137
|
+
patch("pytest_neon.plugin.NeonAPI") as mock_neon_cls,
|
|
138
|
+
patch("pytest_neon.plugin._get_git_branch_name") as mock_git,
|
|
139
|
+
):
|
|
140
|
+
# _get_git_branch_name returns sanitized value (slashes -> hyphens)
|
|
141
|
+
mock_git.return_value = "feature-my-branch"
|
|
142
|
+
|
|
143
|
+
mock_api = MagicMock()
|
|
144
|
+
mock_neon_cls.return_value = mock_api
|
|
145
|
+
|
|
146
|
+
captured_branch_name = None
|
|
147
|
+
|
|
148
|
+
def capture_branch_create(**kwargs):
|
|
149
|
+
nonlocal captured_branch_name
|
|
150
|
+
branch_config = kwargs.get("branch", {})
|
|
151
|
+
captured_branch_name = branch_config.get("name")
|
|
152
|
+
|
|
153
|
+
mock_result = MagicMock()
|
|
154
|
+
mock_result.branch.id = "test-branch-id"
|
|
155
|
+
mock_result.branch.parent_id = "parent-id"
|
|
156
|
+
mock_result.operations = [MagicMock(endpoint_id="ep-123")]
|
|
157
|
+
return mock_result
|
|
158
|
+
|
|
159
|
+
mock_api.branch_create.side_effect = capture_branch_create
|
|
160
|
+
|
|
161
|
+
mock_endpoint_response = MagicMock()
|
|
162
|
+
mock_endpoint_response.endpoint.current_state = EndpointState.active
|
|
163
|
+
mock_endpoint_response.endpoint.host = "test.neon.tech"
|
|
164
|
+
mock_api.endpoint.return_value = mock_endpoint_response
|
|
165
|
+
|
|
166
|
+
mock_password = MagicMock()
|
|
167
|
+
mock_password.role.password = "testpass"
|
|
168
|
+
mock_api.role_password_reset.return_value = mock_password
|
|
169
|
+
|
|
170
|
+
gen = _create_neon_branch(mock_request, branch_name_suffix="-migrated")
|
|
171
|
+
with contextlib.suppress(StopIteration):
|
|
172
|
+
next(gen)
|
|
173
|
+
|
|
174
|
+
assert captured_branch_name is not None
|
|
175
|
+
# Git branch "feature/my-branch" sanitized to "feature-my-branch"
|
|
176
|
+
assert captured_branch_name.startswith("pytest-feature-my-bran-")
|
|
177
|
+
assert captured_branch_name.endswith("-migrated")
|
|
178
|
+
|
|
179
|
+
def test_branch_name_truncates_long_git_branch(self):
|
|
180
|
+
"""Git branch name is truncated to 15 characters."""
|
|
181
|
+
from pytest_neon.plugin import _create_neon_branch
|
|
182
|
+
|
|
183
|
+
mock_request = MagicMock()
|
|
184
|
+
mock_config = MagicMock()
|
|
185
|
+
mock_request.config = mock_config
|
|
186
|
+
|
|
187
|
+
def mock_getoption(name, default=None):
|
|
188
|
+
if name == "neon_api_key":
|
|
189
|
+
return "test-api-key"
|
|
190
|
+
if name == "neon_project_id":
|
|
191
|
+
return "test-project"
|
|
192
|
+
if name == "neon_keep_branches":
|
|
193
|
+
return True
|
|
194
|
+
if name == "neon_branch_expiry":
|
|
195
|
+
return 0
|
|
196
|
+
return default
|
|
197
|
+
|
|
198
|
+
def mock_getini(name):
|
|
199
|
+
if name == "neon_database":
|
|
200
|
+
return "neondb"
|
|
201
|
+
if name == "neon_role":
|
|
202
|
+
return "neondb_owner"
|
|
203
|
+
if name == "neon_env_var":
|
|
204
|
+
return "DATABASE_URL"
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
mock_config.getoption.side_effect = mock_getoption
|
|
208
|
+
mock_config.getini.side_effect = mock_getini
|
|
209
|
+
|
|
210
|
+
with (
|
|
211
|
+
patch("pytest_neon.plugin.NeonAPI") as mock_neon_cls,
|
|
212
|
+
patch("pytest_neon.plugin._get_git_branch_name") as mock_git,
|
|
213
|
+
):
|
|
214
|
+
# _get_git_branch_name returns sanitized value
|
|
215
|
+
mock_git.return_value = "feature-very-long-branch-name-truncated"
|
|
216
|
+
|
|
217
|
+
mock_api = MagicMock()
|
|
218
|
+
mock_neon_cls.return_value = mock_api
|
|
219
|
+
|
|
220
|
+
captured_branch_name = None
|
|
221
|
+
|
|
222
|
+
def capture_branch_create(**kwargs):
|
|
223
|
+
nonlocal captured_branch_name
|
|
224
|
+
branch_config = kwargs.get("branch", {})
|
|
225
|
+
captured_branch_name = branch_config.get("name")
|
|
226
|
+
|
|
227
|
+
mock_result = MagicMock()
|
|
228
|
+
mock_result.branch.id = "test-branch-id"
|
|
229
|
+
mock_result.branch.parent_id = "parent-id"
|
|
230
|
+
mock_result.operations = [MagicMock(endpoint_id="ep-123")]
|
|
231
|
+
return mock_result
|
|
232
|
+
|
|
233
|
+
mock_api.branch_create.side_effect = capture_branch_create
|
|
234
|
+
|
|
235
|
+
mock_endpoint_response = MagicMock()
|
|
236
|
+
mock_endpoint_response.endpoint.current_state = EndpointState.active
|
|
237
|
+
mock_endpoint_response.endpoint.host = "test.neon.tech"
|
|
238
|
+
mock_api.endpoint.return_value = mock_endpoint_response
|
|
239
|
+
|
|
240
|
+
mock_password = MagicMock()
|
|
241
|
+
mock_password.role.password = "testpass"
|
|
242
|
+
mock_api.role_password_reset.return_value = mock_password
|
|
243
|
+
|
|
244
|
+
gen = _create_neon_branch(mock_request, branch_name_suffix="-migrated")
|
|
245
|
+
with contextlib.suppress(StopIteration):
|
|
246
|
+
next(gen)
|
|
247
|
+
|
|
248
|
+
assert captured_branch_name is not None
|
|
249
|
+
# Long branch sanitized and truncated to first 15 chars
|
|
250
|
+
assert captured_branch_name.startswith("pytest-feature-very-lo-")
|
|
251
|
+
assert captured_branch_name.endswith("-migrated")
|
|
252
|
+
|
|
253
|
+
def test_branch_name_without_git(self):
|
|
254
|
+
"""Branch name uses old format when not in a git repo."""
|
|
255
|
+
from pytest_neon.plugin import _create_neon_branch
|
|
256
|
+
|
|
257
|
+
mock_request = MagicMock()
|
|
258
|
+
mock_config = MagicMock()
|
|
259
|
+
mock_request.config = mock_config
|
|
260
|
+
|
|
261
|
+
def mock_getoption(name, default=None):
|
|
262
|
+
if name == "neon_api_key":
|
|
263
|
+
return "test-api-key"
|
|
264
|
+
if name == "neon_project_id":
|
|
265
|
+
return "test-project"
|
|
266
|
+
if name == "neon_keep_branches":
|
|
267
|
+
return True
|
|
268
|
+
if name == "neon_branch_expiry":
|
|
269
|
+
return 0
|
|
270
|
+
return default
|
|
271
|
+
|
|
272
|
+
def mock_getini(name):
|
|
273
|
+
if name == "neon_database":
|
|
274
|
+
return "neondb"
|
|
275
|
+
if name == "neon_role":
|
|
276
|
+
return "neondb_owner"
|
|
277
|
+
if name == "neon_env_var":
|
|
278
|
+
return "DATABASE_URL"
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
mock_config.getoption.side_effect = mock_getoption
|
|
282
|
+
mock_config.getini.side_effect = mock_getini
|
|
283
|
+
|
|
284
|
+
with (
|
|
285
|
+
patch("pytest_neon.plugin.NeonAPI") as mock_neon_cls,
|
|
286
|
+
patch("pytest_neon.plugin._get_git_branch_name") as mock_git,
|
|
287
|
+
):
|
|
288
|
+
mock_git.return_value = None # Not in a git repo
|
|
289
|
+
|
|
290
|
+
mock_api = MagicMock()
|
|
291
|
+
mock_neon_cls.return_value = mock_api
|
|
292
|
+
|
|
293
|
+
captured_branch_name = None
|
|
294
|
+
|
|
295
|
+
def capture_branch_create(**kwargs):
|
|
296
|
+
nonlocal captured_branch_name
|
|
297
|
+
branch_config = kwargs.get("branch", {})
|
|
298
|
+
captured_branch_name = branch_config.get("name")
|
|
299
|
+
|
|
300
|
+
mock_result = MagicMock()
|
|
301
|
+
mock_result.branch.id = "test-branch-id"
|
|
302
|
+
mock_result.branch.parent_id = "parent-id"
|
|
303
|
+
mock_result.operations = [MagicMock(endpoint_id="ep-123")]
|
|
304
|
+
return mock_result
|
|
305
|
+
|
|
306
|
+
mock_api.branch_create.side_effect = capture_branch_create
|
|
307
|
+
|
|
308
|
+
mock_endpoint_response = MagicMock()
|
|
309
|
+
mock_endpoint_response.endpoint.current_state = EndpointState.active
|
|
310
|
+
mock_endpoint_response.endpoint.host = "test.neon.tech"
|
|
311
|
+
mock_api.endpoint.return_value = mock_endpoint_response
|
|
312
|
+
|
|
313
|
+
mock_password = MagicMock()
|
|
314
|
+
mock_password.role.password = "testpass"
|
|
315
|
+
mock_api.role_password_reset.return_value = mock_password
|
|
316
|
+
|
|
317
|
+
gen = _create_neon_branch(mock_request, branch_name_suffix="-migrated")
|
|
318
|
+
with contextlib.suppress(StopIteration):
|
|
319
|
+
next(gen)
|
|
320
|
+
|
|
321
|
+
assert captured_branch_name is not None
|
|
322
|
+
# Without git: pytest-[4 hex chars]-migrated
|
|
323
|
+
assert captured_branch_name.startswith("pytest-")
|
|
324
|
+
assert captured_branch_name.endswith("-migrated")
|
|
325
|
+
# Format: pytest-abcd-migrated (no git branch in the middle)
|
|
326
|
+
parts = captured_branch_name.split("-")
|
|
327
|
+
assert len(parts) == 3 # ['pytest', 'abcd', 'migrated']
|
|
328
|
+
assert len(parts[1]) == 4 # 2 bytes = 4 hex chars
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Tests for default branch safety check."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock
|
|
4
|
+
|
|
5
|
+
from pytest_neon.plugin import _get_default_branch_id
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestGetDefaultBranchId:
|
|
9
|
+
"""Test _get_default_branch_id helper function."""
|
|
10
|
+
|
|
11
|
+
def test_returns_default_branch_id(self):
|
|
12
|
+
"""Test that default branch ID is returned when found."""
|
|
13
|
+
mock_neon = MagicMock()
|
|
14
|
+
mock_branch_default = MagicMock()
|
|
15
|
+
mock_branch_default.id = "br-default-123"
|
|
16
|
+
mock_branch_default.default = True
|
|
17
|
+
mock_branch_default.primary = False
|
|
18
|
+
|
|
19
|
+
mock_branch_other = MagicMock()
|
|
20
|
+
mock_branch_other.id = "br-other-456"
|
|
21
|
+
mock_branch_other.default = False
|
|
22
|
+
mock_branch_other.primary = False
|
|
23
|
+
|
|
24
|
+
mock_response = MagicMock()
|
|
25
|
+
mock_response.branches = [mock_branch_other, mock_branch_default]
|
|
26
|
+
mock_neon.branches.return_value = mock_response
|
|
27
|
+
|
|
28
|
+
result = _get_default_branch_id(mock_neon, "proj-123")
|
|
29
|
+
assert result == "br-default-123"
|
|
30
|
+
|
|
31
|
+
def test_returns_primary_branch_id_as_fallback(self):
|
|
32
|
+
"""Test that primary branch ID is returned when default flag not set."""
|
|
33
|
+
mock_neon = MagicMock()
|
|
34
|
+
mock_branch_primary = MagicMock()
|
|
35
|
+
mock_branch_primary.id = "br-primary-123"
|
|
36
|
+
mock_branch_primary.default = False
|
|
37
|
+
mock_branch_primary.primary = True
|
|
38
|
+
|
|
39
|
+
mock_response = MagicMock()
|
|
40
|
+
mock_response.branches = [mock_branch_primary]
|
|
41
|
+
mock_neon.branches.return_value = mock_response
|
|
42
|
+
|
|
43
|
+
result = _get_default_branch_id(mock_neon, "proj-123")
|
|
44
|
+
assert result == "br-primary-123"
|
|
45
|
+
|
|
46
|
+
def test_returns_none_when_no_default_or_primary(self):
|
|
47
|
+
"""Test that None is returned when no default/primary branch exists."""
|
|
48
|
+
mock_neon = MagicMock()
|
|
49
|
+
mock_branch = MagicMock()
|
|
50
|
+
mock_branch.id = "br-other-123"
|
|
51
|
+
mock_branch.default = False
|
|
52
|
+
mock_branch.primary = False
|
|
53
|
+
|
|
54
|
+
mock_response = MagicMock()
|
|
55
|
+
mock_response.branches = [mock_branch]
|
|
56
|
+
mock_neon.branches.return_value = mock_response
|
|
57
|
+
|
|
58
|
+
result = _get_default_branch_id(mock_neon, "proj-123")
|
|
59
|
+
assert result is None
|
|
60
|
+
|
|
61
|
+
def test_returns_none_on_api_error(self):
|
|
62
|
+
"""Test that None is returned on API failure so tests can still run."""
|
|
63
|
+
mock_neon = MagicMock()
|
|
64
|
+
mock_neon.branches.side_effect = Exception("API error")
|
|
65
|
+
|
|
66
|
+
result = _get_default_branch_id(mock_neon, "proj-123")
|
|
67
|
+
assert result is None
|
|
68
|
+
|
|
69
|
+
def test_handles_missing_attributes_gracefully(self):
|
|
70
|
+
"""Test graceful handling when branch object lacks expected attributes."""
|
|
71
|
+
mock_neon = MagicMock()
|
|
72
|
+
# Branch with no default/primary attributes at all
|
|
73
|
+
mock_branch = MagicMock(spec=["id"])
|
|
74
|
+
mock_branch.id = "br-123"
|
|
75
|
+
|
|
76
|
+
mock_response = MagicMock()
|
|
77
|
+
mock_response.branches = [mock_branch]
|
|
78
|
+
mock_neon.branches.return_value = mock_response
|
|
79
|
+
|
|
80
|
+
# Should not raise, getattr with default handles this
|
|
81
|
+
result = _get_default_branch_id(mock_neon, "proj-123")
|
|
82
|
+
assert result is None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|