pytest-neon 2.1.2__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.
Files changed (27) hide show
  1. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/CLAUDE.md +1 -7
  2. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/PKG-INFO +1 -1
  3. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/pyproject.toml +1 -1
  4. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/src/pytest_neon/__init__.py +1 -1
  5. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/src/pytest_neon/plugin.py +96 -3
  6. pytest_neon-2.1.4/tests/test_branch_name_prefix.py +328 -0
  7. pytest_neon-2.1.4/tests/test_default_branch_safety.py +82 -0
  8. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/tests/test_reset_behavior.py +37 -0
  9. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/uv.lock +1 -1
  10. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/.env.example +0 -0
  11. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/.github/workflows/release.yml +0 -0
  12. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/.github/workflows/tests.yml +0 -0
  13. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/.gitignore +0 -0
  14. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/.neon +0 -0
  15. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/LICENSE +0 -0
  16. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/README.md +0 -0
  17. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/src/pytest_neon/py.typed +0 -0
  18. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/tests/conftest.py +0 -0
  19. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/tests/test_branch_lifecycle.py +0 -0
  20. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/tests/test_cli_options.py +0 -0
  21. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/tests/test_env_var.py +0 -0
  22. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/tests/test_fixture_errors.py +0 -0
  23. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/tests/test_integration.py +0 -0
  24. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/tests/test_migrations.py +0 -0
  25. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/tests/test_readwrite_readonly_fixtures.py +0 -0
  26. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/tests/test_skip_behavior.py +0 -0
  27. {pytest_neon-2.1.2 → pytest_neon-2.1.4}/tests/test_xdist_worker_support.py +0 -0
@@ -81,15 +81,9 @@ Tests in `tests/` use `pytester` for testing pytest plugins. The plugin itself c
81
81
 
82
82
  ## Publishing
83
83
 
84
- Use the GitHub Actions release workflow:
84
+ **Always use the GitHub Actions release workflow** - do not manually bump versions:
85
85
  1. Go to Actions → Release → Run workflow
86
86
  2. Choose patch/minor/major
87
87
  3. Workflow bumps version, commits, tags, and publishes to PyPI
88
88
 
89
- Or manually:
90
- ```bash
91
- uv build
92
- uv publish --token $PYPI_TOKEN
93
- ```
94
-
95
89
  Package name on PyPI: `pytest-neon`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-neon
3
- Version: 2.1.2
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pytest-neon"
7
- version = "2.1.2"
7
+ version = "2.1.4"
8
8
  description = "Pytest plugin for Neon database branch isolation in tests"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -9,7 +9,7 @@ from pytest_neon.plugin import (
9
9
  neon_engine,
10
10
  )
11
11
 
12
- __version__ = "2.1.2"
12
+ __version__ = "2.1.4"
13
13
  __all__ = [
14
14
  "NeonBranch",
15
15
  "neon_branch",
@@ -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
- branch_name = f"pytest-{os.urandom(4).hex()}{branch_name_suffix}"
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(
@@ -502,10 +595,10 @@ def _wait_for_operations(
502
595
  op_data = response.json().get("operation", {})
503
596
  status = op_data.get("status")
504
597
 
505
- if status == "error":
598
+ if status == "failed":
506
599
  err = op_data.get("error", "unknown error")
507
600
  raise RuntimeError(f"Operation {op_id} failed: {err}")
508
- if status not in ("finished", "skipped"):
601
+ if status not in ("finished", "skipped", "cancelled"):
509
602
  still_pending.append(op_id)
510
603
  except requests.RequestException:
511
604
  # On network error, assume still pending and retry
@@ -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
@@ -162,6 +162,43 @@ class TestResetRetryBehavior:
162
162
  # Should have polled until finished
163
163
  assert get_call_count[0] == 3
164
164
 
165
+ def test_reset_raises_on_failed_operation(self, mocker):
166
+ """Verify reset raises RuntimeError when operation fails."""
167
+ branch = NeonBranch(
168
+ branch_id="br-test",
169
+ project_id="proj-test",
170
+ connection_string="postgresql://test",
171
+ host="test.neon.tech",
172
+ parent_id="br-parent",
173
+ )
174
+
175
+ # Mock POST response with pending operation
176
+ mock_post_response = mocker.Mock()
177
+ mock_post_response.raise_for_status = mocker.Mock()
178
+ mock_post_response.json.return_value = {
179
+ "operations": [{"id": "op-123", "status": "running"}]
180
+ }
181
+
182
+ # Mock GET response: operation failed
183
+ mock_get_response = mocker.Mock()
184
+ mock_get_response.raise_for_status = mocker.Mock()
185
+ mock_get_response.json.return_value = {
186
+ "operation": {
187
+ "id": "op-123",
188
+ "status": "failed",
189
+ "error": "Something went wrong",
190
+ }
191
+ }
192
+
193
+ mocker.patch(
194
+ "pytest_neon.plugin.requests.post", return_value=mock_post_response
195
+ )
196
+ mocker.patch("pytest_neon.plugin.requests.get", return_value=mock_get_response)
197
+ mocker.patch("pytest_neon.plugin.time.sleep")
198
+
199
+ with pytest.raises(RuntimeError, match="Operation op-123 failed"):
200
+ _reset_branch_to_parent(branch, "fake-api-key")
201
+
165
202
 
166
203
  class TestResetBehavior:
167
204
  """Test that branch reset happens between tests."""
@@ -1200,7 +1200,7 @@ wheels = [
1200
1200
 
1201
1201
  [[package]]
1202
1202
  name = "pytest-neon"
1203
- version = "2.1.0"
1203
+ version = "2.1.2"
1204
1204
  source = { editable = "." }
1205
1205
  dependencies = [
1206
1206
  { name = "neon-api" },
File without changes
File without changes
File without changes
File without changes
File without changes