FossilRepo

fossilrepo / tests / test_cli.py
Blame History Raw 1208 lines
1
"""Unit tests for fossil/cli.py -- FossilCLI subprocess wrapper.
2
3
Tests mock subprocess.run throughout since FossilCLI is a thin wrapper
4
around the fossil binary. We verify that:
5
- Correct commands are assembled for every method
6
- Success/failure return values are propagated correctly
7
- Environment variables are set properly (_env property)
8
- Timeouts and exceptions are handled gracefully
9
- Edge-case inputs (empty strings, special characters) work
10
"""
11
12
import os
13
import subprocess
14
from pathlib import Path
15
from unittest.mock import MagicMock, patch
16
17
import pytest
18
19
from fossil.cli import FossilCLI
20
21
# ---------------------------------------------------------------------------
22
# Helpers
23
# ---------------------------------------------------------------------------
24
25
26
def _ok(stdout="", stderr="", returncode=0):
27
"""Build a mock CompletedProcess for a successful command."""
28
return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr=stderr)
29
30
31
def _fail(stdout="", stderr="error", returncode=1):
32
"""Build a mock CompletedProcess for a failed command."""
33
return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr=stderr)
34
35
36
def _ok_bytes(stdout=b"", stderr=b"", returncode=0):
37
"""Build a mock CompletedProcess returning raw bytes (not text)."""
38
return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr=stderr)
39
40
41
# ---------------------------------------------------------------------------
42
# Constructor and _env
43
# ---------------------------------------------------------------------------
44
45
46
class TestFossilCLIInit:
47
"""Constructor: explicit binary path vs constance fallback."""
48
49
def test_explicit_binary(self):
50
cli = FossilCLI(binary="/usr/local/bin/fossil")
51
assert cli.binary == "/usr/local/bin/fossil"
52
53
def test_constance_fallback(self):
54
mock_config = MagicMock()
55
mock_config.FOSSIL_BINARY_PATH = "/opt/fossil/bin/fossil"
56
with patch("constance.config", mock_config):
57
cli = FossilCLI()
58
assert cli.binary == "/opt/fossil/bin/fossil"
59
60
61
class TestEnvProperty:
62
"""_env injects USER=fossilrepo into the inherited environment."""
63
64
def test_env_sets_user(self):
65
cli = FossilCLI(binary="/bin/false")
66
env = cli._env
67
assert env["USER"] == "fossilrepo"
68
69
def test_env_inherits_system_env(self):
70
cli = FossilCLI(binary="/bin/false")
71
env = cli._env
72
# PATH should come from os.environ
73
assert "PATH" in env
74
75
76
# ---------------------------------------------------------------------------
77
# _run helper
78
# ---------------------------------------------------------------------------
79
80
81
class TestRunHelper:
82
"""_run assembles the command and delegates to subprocess.run."""
83
84
def test_run_builds_correct_command(self):
85
cli = FossilCLI(binary="/usr/bin/fossil")
86
with patch("subprocess.run", return_value=_ok("ok")) as mock_run:
87
cli._run("version")
88
mock_run.assert_called_once()
89
cmd = mock_run.call_args[0][0]
90
assert cmd == ["/usr/bin/fossil", "version"]
91
92
def test_run_passes_env(self):
93
cli = FossilCLI(binary="/usr/bin/fossil")
94
with patch("subprocess.run", return_value=_ok()) as mock_run:
95
cli._run("version")
96
env = mock_run.call_args[1]["env"]
97
assert env["USER"] == "fossilrepo"
98
99
def test_run_uses_check_true(self):
100
"""_run uses check=True so CalledProcessError is raised on failure."""
101
cli = FossilCLI(binary="/usr/bin/fossil")
102
with (
103
patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "fossil")),
104
pytest.raises(subprocess.CalledProcessError),
105
):
106
cli._run("bad-command")
107
108
def test_run_custom_timeout(self):
109
cli = FossilCLI(binary="/usr/bin/fossil")
110
with patch("subprocess.run", return_value=_ok()) as mock_run:
111
cli._run("clone", "http://example.com", timeout=120)
112
assert mock_run.call_args[1]["timeout"] == 120
113
114
def test_run_multiple_args(self):
115
cli = FossilCLI(binary="/usr/bin/fossil")
116
with patch("subprocess.run", return_value=_ok()) as mock_run:
117
cli._run("push", "-R", "/tmp/repo.fossil")
118
cmd = mock_run.call_args[0][0]
119
assert cmd == ["/usr/bin/fossil", "push", "-R", "/tmp/repo.fossil"]
120
121
122
# ---------------------------------------------------------------------------
123
# init
124
# ---------------------------------------------------------------------------
125
126
127
class TestInit:
128
def test_init_creates_parent_dirs_and_runs_fossil_init(self, tmp_path):
129
cli = FossilCLI(binary="/usr/bin/fossil")
130
target = tmp_path / "sub" / "dir" / "repo.fossil"
131
with patch("subprocess.run", return_value=_ok()) as mock_run:
132
result = cli.init(target)
133
assert result == target
134
# Parent dirs created
135
assert target.parent.exists()
136
cmd = mock_run.call_args[0][0]
137
assert cmd == ["/usr/bin/fossil", "init", str(target)]
138
139
def test_init_returns_path(self, tmp_path):
140
cli = FossilCLI(binary="/usr/bin/fossil")
141
target = tmp_path / "repo.fossil"
142
with patch("subprocess.run", return_value=_ok()):
143
path = cli.init(target)
144
assert isinstance(path, Path)
145
assert path == target
146
147
148
# ---------------------------------------------------------------------------
149
# version
150
# ---------------------------------------------------------------------------
151
152
153
class TestVersion:
154
def test_version_returns_stripped_stdout(self):
155
cli = FossilCLI(binary="/usr/bin/fossil")
156
with patch("subprocess.run", return_value=_ok(" This is fossil version 2.24\n")):
157
result = cli.version()
158
assert result == "This is fossil version 2.24"
159
160
def test_version_propagates_error(self):
161
cli = FossilCLI(binary="/usr/bin/fossil")
162
with (
163
patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "fossil")),
164
pytest.raises(subprocess.CalledProcessError),
165
):
166
cli.version()
167
168
169
# ---------------------------------------------------------------------------
170
# is_available
171
# ---------------------------------------------------------------------------
172
173
174
class TestIsAvailable:
175
def test_available_when_version_works(self):
176
cli = FossilCLI(binary="/usr/bin/fossil")
177
with patch("subprocess.run", return_value=_ok("2.24")):
178
assert cli.is_available() is True
179
180
def test_not_available_on_file_not_found(self):
181
cli = FossilCLI(binary="/nonexistent/fossil")
182
with patch("subprocess.run", side_effect=FileNotFoundError):
183
assert cli.is_available() is False
184
185
def test_not_available_on_called_process_error(self):
186
cli = FossilCLI(binary="/usr/bin/fossil")
187
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "fossil")):
188
assert cli.is_available() is False
189
190
191
# ---------------------------------------------------------------------------
192
# render_pikchr
193
# ---------------------------------------------------------------------------
194
195
196
class TestRenderPikchr:
197
def test_renders_svg_on_success(self):
198
cli = FossilCLI(binary="/usr/bin/fossil")
199
svg = '<svg viewBox="0 0 100 100"></svg>'
200
result_proc = subprocess.CompletedProcess(args=[], returncode=0, stdout=svg, stderr="")
201
with patch("subprocess.run", return_value=result_proc) as mock_run:
202
result = cli.render_pikchr("circle")
203
assert result == svg
204
cmd = mock_run.call_args[0][0]
205
assert cmd == ["/usr/bin/fossil", "pikchr", "-"]
206
assert mock_run.call_args[1]["input"] == "circle"
207
208
def test_returns_empty_on_failure(self):
209
cli = FossilCLI(binary="/usr/bin/fossil")
210
result_proc = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="error")
211
with patch("subprocess.run", return_value=result_proc):
212
assert cli.render_pikchr("bad") == ""
213
214
def test_returns_empty_on_file_not_found(self):
215
cli = FossilCLI(binary="/usr/bin/fossil")
216
with patch("subprocess.run", side_effect=FileNotFoundError):
217
assert cli.render_pikchr("test") == ""
218
219
def test_returns_empty_on_timeout(self):
220
cli = FossilCLI(binary="/usr/bin/fossil")
221
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("cmd", 10)):
222
assert cli.render_pikchr("test") == ""
223
224
225
# ---------------------------------------------------------------------------
226
# ensure_default_user
227
# ---------------------------------------------------------------------------
228
229
230
class TestEnsureDefaultUser:
231
def test_creates_user_when_missing(self, tmp_path):
232
cli = FossilCLI(binary="/usr/bin/fossil")
233
repo_path = tmp_path / "repo.fossil"
234
# First call: user list (user not present), second: create, third: default
235
with patch("subprocess.run") as mock_run:
236
mock_run.side_effect = [
237
_ok(stdout="admin\n"), # user list -- "fossilrepo" not in output
238
_ok(), # user new
239
_ok(), # user default
240
]
241
cli.ensure_default_user(repo_path)
242
assert mock_run.call_count == 3
243
# Verify the user new call
244
new_cmd = mock_run.call_args_list[1][0][0]
245
assert "user" in new_cmd
246
assert "new" in new_cmd
247
assert "fossilrepo" in new_cmd
248
249
def test_skips_create_when_user_exists(self, tmp_path):
250
cli = FossilCLI(binary="/usr/bin/fossil")
251
repo_path = tmp_path / "repo.fossil"
252
with patch("subprocess.run") as mock_run:
253
mock_run.side_effect = [
254
_ok(stdout="admin\nfossilrepo\n"), # user list -- fossilrepo IS present
255
_ok(), # user default
256
]
257
cli.ensure_default_user(repo_path)
258
assert mock_run.call_count == 2 # no "new" call
259
260
def test_custom_username(self, tmp_path):
261
cli = FossilCLI(binary="/usr/bin/fossil")
262
repo_path = tmp_path / "repo.fossil"
263
with patch("subprocess.run") as mock_run:
264
mock_run.side_effect = [
265
_ok(stdout="admin\n"), # user list -- custom not present
266
_ok(), # user new
267
_ok(), # user default
268
]
269
cli.ensure_default_user(repo_path, username="custom-bot")
270
new_cmd = mock_run.call_args_list[1][0][0]
271
assert "custom-bot" in new_cmd
272
273
def test_silently_swallows_exceptions(self, tmp_path):
274
"""ensure_default_user has a bare except -- should not raise."""
275
cli = FossilCLI(binary="/usr/bin/fossil")
276
repo_path = tmp_path / "repo.fossil"
277
with patch("subprocess.run", side_effect=Exception("kaboom")):
278
cli.ensure_default_user(repo_path) # should not raise
279
280
281
# ---------------------------------------------------------------------------
282
# tarball
283
# ---------------------------------------------------------------------------
284
285
286
class TestTarball:
287
def test_returns_bytes_on_success(self, tmp_path):
288
cli = FossilCLI(binary="/usr/bin/fossil")
289
repo_path = tmp_path / "repo.fossil"
290
fake_tar = b"\x1f\x8b\x08\x00" + b"\x00" * 100 # fake gzip header
291
with patch("subprocess.run", return_value=_ok_bytes(stdout=fake_tar)) as mock_run:
292
result = cli.tarball(repo_path, "trunk")
293
assert result == fake_tar
294
cmd = mock_run.call_args[0][0]
295
assert cmd == ["/usr/bin/fossil", "tarball", "trunk", "-R", str(repo_path), "/dev/stdout"]
296
297
def test_returns_empty_bytes_on_failure(self, tmp_path):
298
cli = FossilCLI(binary="/usr/bin/fossil")
299
repo_path = tmp_path / "repo.fossil"
300
with patch("subprocess.run", return_value=_ok_bytes(returncode=1)):
301
result = cli.tarball(repo_path, "trunk")
302
assert result == b""
303
304
305
# ---------------------------------------------------------------------------
306
# zip_archive
307
# ---------------------------------------------------------------------------
308
309
310
class TestZipArchive:
311
def test_returns_bytes_on_success(self, tmp_path):
312
cli = FossilCLI(binary="/usr/bin/fossil")
313
repo_path = tmp_path / "repo.fossil"
314
fake_zip = b"PK\x03\x04" + b"\x00" * 100
315
316
def side_effect(cmd, **kwargs):
317
# Write content to the tempfile that fossil would create
318
# The tempfile path is in the command args
319
outfile = cmd[3] # zip <checkin> <outfile> -R <repo>
320
Path(outfile).write_bytes(fake_zip)
321
return _ok()
322
323
with patch("subprocess.run", side_effect=side_effect):
324
result = cli.zip_archive(repo_path, "trunk")
325
assert result == fake_zip
326
327
def test_returns_empty_bytes_on_failure(self, tmp_path):
328
cli = FossilCLI(binary="/usr/bin/fossil")
329
repo_path = tmp_path / "repo.fossil"
330
with patch("subprocess.run", return_value=_fail()):
331
result = cli.zip_archive(repo_path, "trunk")
332
assert result == b""
333
334
335
# ---------------------------------------------------------------------------
336
# blame
337
# ---------------------------------------------------------------------------
338
339
340
class TestBlame:
341
def test_parses_blame_output(self, tmp_path):
342
cli = FossilCLI(binary="/usr/bin/fossil")
343
repo_path = tmp_path / "repo.fossil"
344
blame_output = (
345
"abc12345 2026-01-15 ragelink: def hello():\n"
346
"abc12345 2026-01-15 ragelink: return 'world'\n"
347
"def67890 2026-01-20 contributor: pass\n"
348
)
349
with patch("subprocess.run") as mock_run:
350
mock_run.side_effect = [
351
_ok(), # fossil open
352
_ok(stdout=blame_output), # fossil blame
353
_ok(), # fossil close
354
]
355
lines = cli.blame(repo_path, "main.py")
356
assert len(lines) == 3
357
assert lines[0]["uuid"] == "abc12345"
358
assert lines[0]["date"] == "2026-01-15"
359
assert lines[0]["user"] == "ragelink"
360
assert lines[0]["text"] == "def hello():"
361
assert lines[2]["user"] == "contributor"
362
363
def test_returns_empty_on_failure(self, tmp_path):
364
cli = FossilCLI(binary="/usr/bin/fossil")
365
repo_path = tmp_path / "repo.fossil"
366
with patch("subprocess.run") as mock_run:
367
mock_run.side_effect = [
368
_ok(), # fossil open
369
_fail(), # fossil blame fails
370
_ok(), # fossil close
371
]
372
lines = cli.blame(repo_path, "nonexistent.py")
373
assert lines == []
374
375
def test_returns_empty_on_exception(self, tmp_path):
376
"""blame has a broad except -- should not raise."""
377
cli = FossilCLI(binary="/usr/bin/fossil")
378
repo_path = tmp_path / "repo.fossil"
379
with patch("subprocess.run", side_effect=Exception("error")):
380
lines = cli.blame(repo_path, "file.py")
381
assert lines == []
382
383
def test_cleans_up_tmpdir(self, tmp_path):
384
"""Temp directory must be cleaned up even on error."""
385
cli = FossilCLI(binary="/usr/bin/fossil")
386
repo_path = tmp_path / "repo.fossil"
387
388
created_dirs = []
389
original_mkdtemp = __import__("tempfile").mkdtemp
390
391
def tracking_mkdtemp(**kwargs):
392
d = original_mkdtemp(**kwargs)
393
created_dirs.append(d)
394
return d
395
396
with (
397
patch("subprocess.run", side_effect=Exception("fail")),
398
patch("tempfile.mkdtemp", side_effect=tracking_mkdtemp),
399
):
400
cli.blame(repo_path, "file.py")
401
402
# The tmpdir should have been cleaned up by shutil.rmtree
403
for d in created_dirs:
404
assert not Path(d).exists()
405
406
407
# ---------------------------------------------------------------------------
408
# push
409
# ---------------------------------------------------------------------------
410
411
412
class TestPush:
413
def test_push_success_with_artifacts(self, tmp_path):
414
cli = FossilCLI(binary="/usr/bin/fossil")
415
repo_path = tmp_path / "repo.fossil"
416
with patch("subprocess.run", return_value=_ok(stdout="Round-trips: 1 Artifacts sent: 5 sent: 5")):
417
result = cli.push(repo_path)
418
assert result["success"] is True
419
assert result["artifacts_sent"] == 5
420
421
def test_push_with_remote_url(self, tmp_path):
422
cli = FossilCLI(binary="/usr/bin/fossil")
423
repo_path = tmp_path / "repo.fossil"
424
with patch("subprocess.run", return_value=_ok(stdout="sent: 3")) as mock_run:
425
result = cli.push(repo_path, remote_url="https://fossil.example.com/repo")
426
cmd = mock_run.call_args[0][0]
427
assert "https://fossil.example.com/repo" in cmd
428
assert result["artifacts_sent"] == 3
429
430
def test_push_no_artifacts_in_output(self, tmp_path):
431
cli = FossilCLI(binary="/usr/bin/fossil")
432
repo_path = tmp_path / "repo.fossil"
433
with patch("subprocess.run", return_value=_ok(stdout="nothing to push")):
434
result = cli.push(repo_path)
435
assert result["success"] is True
436
assert result["artifacts_sent"] == 0
437
438
def test_push_failure(self, tmp_path):
439
cli = FossilCLI(binary="/usr/bin/fossil")
440
repo_path = tmp_path / "repo.fossil"
441
with patch("subprocess.run", return_value=_fail(stdout="connection refused")):
442
result = cli.push(repo_path)
443
assert result["success"] is False
444
445
def test_push_timeout(self, tmp_path):
446
cli = FossilCLI(binary="/usr/bin/fossil")
447
repo_path = tmp_path / "repo.fossil"
448
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("cmd", 120)):
449
result = cli.push(repo_path)
450
assert result["success"] is False
451
assert result["artifacts_sent"] == 0
452
assert "timed out" in result["message"].lower()
453
454
def test_push_file_not_found(self, tmp_path):
455
cli = FossilCLI(binary="/nonexistent/fossil")
456
repo_path = tmp_path / "repo.fossil"
457
with patch("subprocess.run", side_effect=FileNotFoundError("No such file")):
458
result = cli.push(repo_path)
459
assert result["success"] is False
460
assert result["artifacts_sent"] == 0
461
462
463
# ---------------------------------------------------------------------------
464
# sync
465
# ---------------------------------------------------------------------------
466
467
468
class TestSync:
469
def test_sync_success(self, tmp_path):
470
cli = FossilCLI(binary="/usr/bin/fossil")
471
repo_path = tmp_path / "repo.fossil"
472
with patch("subprocess.run", return_value=_ok(stdout="sync complete")):
473
result = cli.sync(repo_path)
474
assert result["success"] is True
475
assert result["message"] == "sync complete"
476
477
def test_sync_with_remote_url(self, tmp_path):
478
cli = FossilCLI(binary="/usr/bin/fossil")
479
repo_path = tmp_path / "repo.fossil"
480
with patch("subprocess.run", return_value=_ok(stdout="ok")) as mock_run:
481
cli.sync(repo_path, remote_url="https://fossil.example.com/repo")
482
cmd = mock_run.call_args[0][0]
483
assert "https://fossil.example.com/repo" in cmd
484
485
def test_sync_failure(self, tmp_path):
486
cli = FossilCLI(binary="/usr/bin/fossil")
487
repo_path = tmp_path / "repo.fossil"
488
with patch("subprocess.run", return_value=_fail(stdout="error")):
489
result = cli.sync(repo_path)
490
assert result["success"] is False
491
492
def test_sync_timeout(self, tmp_path):
493
cli = FossilCLI(binary="/usr/bin/fossil")
494
repo_path = tmp_path / "repo.fossil"
495
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("cmd", 120)):
496
result = cli.sync(repo_path)
497
assert result["success"] is False
498
assert "timed out" in result["message"].lower()
499
500
def test_sync_file_not_found(self, tmp_path):
501
cli = FossilCLI(binary="/nonexistent/fossil")
502
repo_path = tmp_path / "repo.fossil"
503
with patch("subprocess.run", side_effect=FileNotFoundError("No such file")):
504
result = cli.sync(repo_path)
505
assert result["success"] is False
506
507
508
# ---------------------------------------------------------------------------
509
# pull
510
# ---------------------------------------------------------------------------
511
512
513
class TestPull:
514
def test_pull_success_with_artifacts(self, tmp_path):
515
cli = FossilCLI(binary="/usr/bin/fossil")
516
repo_path = tmp_path / "repo.fossil"
517
with patch("subprocess.run", return_value=_ok(stdout="Round-trips: 1 received: 12")):
518
result = cli.pull(repo_path)
519
assert result["success"] is True
520
assert result["artifacts_received"] == 12
521
522
def test_pull_no_artifacts(self, tmp_path):
523
cli = FossilCLI(binary="/usr/bin/fossil")
524
repo_path = tmp_path / "repo.fossil"
525
with patch("subprocess.run", return_value=_ok(stdout="nothing new")):
526
result = cli.pull(repo_path)
527
assert result["success"] is True
528
assert result["artifacts_received"] == 0
529
530
def test_pull_failure(self, tmp_path):
531
cli = FossilCLI(binary="/usr/bin/fossil")
532
repo_path = tmp_path / "repo.fossil"
533
with patch("subprocess.run", return_value=_fail(stdout="connection refused")):
534
result = cli.pull(repo_path)
535
assert result["success"] is False
536
537
def test_pull_timeout(self, tmp_path):
538
cli = FossilCLI(binary="/usr/bin/fossil")
539
repo_path = tmp_path / "repo.fossil"
540
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("cmd", 60)):
541
result = cli.pull(repo_path)
542
assert result["success"] is False
543
assert result["artifacts_received"] == 0
544
545
def test_pull_file_not_found(self, tmp_path):
546
cli = FossilCLI(binary="/nonexistent/fossil")
547
repo_path = tmp_path / "repo.fossil"
548
with patch("subprocess.run", side_effect=FileNotFoundError("No such file")):
549
result = cli.pull(repo_path)
550
assert result["success"] is False
551
assert result["artifacts_received"] == 0
552
553
554
# ---------------------------------------------------------------------------
555
# get_remote_url
556
# ---------------------------------------------------------------------------
557
558
559
class TestGetRemoteUrl:
560
def test_returns_url_on_success(self, tmp_path):
561
cli = FossilCLI(binary="/usr/bin/fossil")
562
repo_path = tmp_path / "repo.fossil"
563
result_proc = subprocess.CompletedProcess(args=[], returncode=0, stdout="https://fossil.example.com/repo\n", stderr="")
564
with patch("subprocess.run", return_value=result_proc):
565
url = cli.get_remote_url(repo_path)
566
assert url == "https://fossil.example.com/repo"
567
568
def test_returns_empty_on_failure(self, tmp_path):
569
cli = FossilCLI(binary="/usr/bin/fossil")
570
repo_path = tmp_path / "repo.fossil"
571
result_proc = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="not configured")
572
with patch("subprocess.run", return_value=result_proc):
573
url = cli.get_remote_url(repo_path)
574
assert url == ""
575
576
def test_returns_empty_on_file_not_found(self, tmp_path):
577
cli = FossilCLI(binary="/nonexistent/fossil")
578
repo_path = tmp_path / "repo.fossil"
579
with patch("subprocess.run", side_effect=FileNotFoundError):
580
url = cli.get_remote_url(repo_path)
581
assert url == ""
582
583
def test_returns_empty_on_timeout(self, tmp_path):
584
cli = FossilCLI(binary="/usr/bin/fossil")
585
repo_path = tmp_path / "repo.fossil"
586
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("cmd", 10)):
587
url = cli.get_remote_url(repo_path)
588
assert url == ""
589
590
591
# ---------------------------------------------------------------------------
592
# wiki_commit
593
# ---------------------------------------------------------------------------
594
595
596
class TestWikiCommit:
597
def test_success(self, tmp_path):
598
cli = FossilCLI(binary="/usr/bin/fossil")
599
repo_path = tmp_path / "repo.fossil"
600
with patch("subprocess.run", return_value=_ok()) as mock_run:
601
result = cli.wiki_commit(repo_path, "Home", "# Welcome")
602
assert result is True
603
assert mock_run.call_args[1]["input"] == "# Welcome"
604
cmd = mock_run.call_args[0][0]
605
assert cmd == ["/usr/bin/fossil", "wiki", "commit", "Home", "-R", str(repo_path)]
606
607
def test_with_user(self, tmp_path):
608
cli = FossilCLI(binary="/usr/bin/fossil")
609
repo_path = tmp_path / "repo.fossil"
610
with patch("subprocess.run", return_value=_ok()) as mock_run:
611
cli.wiki_commit(repo_path, "Home", "content", user="admin")
612
cmd = mock_run.call_args[0][0]
613
assert "--technote-user" in cmd
614
assert "admin" in cmd
615
616
def test_failure(self, tmp_path):
617
cli = FossilCLI(binary="/usr/bin/fossil")
618
repo_path = tmp_path / "repo.fossil"
619
with patch("subprocess.run", return_value=_fail()):
620
result = cli.wiki_commit(repo_path, "Missing", "content")
621
assert result is False
622
623
624
# ---------------------------------------------------------------------------
625
# wiki_create
626
# ---------------------------------------------------------------------------
627
628
629
class TestWikiCreate:
630
def test_success(self, tmp_path):
631
cli = FossilCLI(binary="/usr/bin/fossil")
632
repo_path = tmp_path / "repo.fossil"
633
with patch("subprocess.run", return_value=_ok()) as mock_run:
634
result = cli.wiki_create(repo_path, "NewPage", "# New content")
635
assert result is True
636
cmd = mock_run.call_args[0][0]
637
assert cmd == ["/usr/bin/fossil", "wiki", "create", "NewPage", "-R", str(repo_path)]
638
assert mock_run.call_args[1]["input"] == "# New content"
639
640
def test_failure(self, tmp_path):
641
cli = FossilCLI(binary="/usr/bin/fossil")
642
repo_path = tmp_path / "repo.fossil"
643
with patch("subprocess.run", return_value=_fail()):
644
result = cli.wiki_create(repo_path, "Dup", "content")
645
assert result is False
646
647
648
# ---------------------------------------------------------------------------
649
# ticket_add
650
# ---------------------------------------------------------------------------
651
652
653
class TestTicketAdd:
654
def test_success_with_fields(self, tmp_path):
655
cli = FossilCLI(binary="/usr/bin/fossil")
656
repo_path = tmp_path / "repo.fossil"
657
fields = {"title": "Bug report", "status": "open", "type": "bug"}
658
with patch("subprocess.run", return_value=_ok()) as mock_run:
659
result = cli.ticket_add(repo_path, fields)
660
assert result is True
661
cmd = mock_run.call_args[0][0]
662
# Should have: fossil ticket add -R <path> title "Bug report" status open type bug
663
assert cmd[:4] == ["/usr/bin/fossil", "ticket", "add", "-R"]
664
assert "title" in cmd
665
assert "Bug report" in cmd
666
667
def test_empty_fields(self, tmp_path):
668
cli = FossilCLI(binary="/usr/bin/fossil")
669
repo_path = tmp_path / "repo.fossil"
670
with patch("subprocess.run", return_value=_ok()) as mock_run:
671
result = cli.ticket_add(repo_path, {})
672
assert result is True
673
cmd = mock_run.call_args[0][0]
674
assert cmd == ["/usr/bin/fossil", "ticket", "add", "-R", str(repo_path)]
675
676
def test_failure(self, tmp_path):
677
cli = FossilCLI(binary="/usr/bin/fossil")
678
repo_path = tmp_path / "repo.fossil"
679
with patch("subprocess.run", return_value=_fail()):
680
result = cli.ticket_add(repo_path, {"title": "test"})
681
assert result is False
682
683
684
# ---------------------------------------------------------------------------
685
# ticket_change
686
# ---------------------------------------------------------------------------
687
688
689
class TestTicketChange:
690
def test_success(self, tmp_path):
691
cli = FossilCLI(binary="/usr/bin/fossil")
692
repo_path = tmp_path / "repo.fossil"
693
uuid = "abc123def456"
694
with patch("subprocess.run", return_value=_ok()) as mock_run:
695
result = cli.ticket_change(repo_path, uuid, {"status": "closed"})
696
assert result is True
697
cmd = mock_run.call_args[0][0]
698
assert cmd[:5] == ["/usr/bin/fossil", "ticket", "change", uuid, "-R"]
699
assert "status" in cmd
700
assert "closed" in cmd
701
702
def test_failure(self, tmp_path):
703
cli = FossilCLI(binary="/usr/bin/fossil")
704
repo_path = tmp_path / "repo.fossil"
705
with patch("subprocess.run", return_value=_fail()):
706
result = cli.ticket_change(repo_path, "badid", {"status": "open"})
707
assert result is False
708
709
710
# ---------------------------------------------------------------------------
711
# technote_create
712
# ---------------------------------------------------------------------------
713
714
715
class TestTechnoteCreate:
716
def test_with_explicit_timestamp(self, tmp_path):
717
cli = FossilCLI(binary="/usr/bin/fossil")
718
repo_path = tmp_path / "repo.fossil"
719
with patch("subprocess.run", return_value=_ok()) as mock_run:
720
result = cli.technote_create(repo_path, "Release v1.0", "Details here", timestamp="2026-04-07T12:00:00")
721
assert result is True
722
cmd = mock_run.call_args[0][0]
723
assert "--technote" in cmd
724
assert "2026-04-07T12:00:00" in cmd
725
assert mock_run.call_args[1]["input"] == "Details here"
726
727
def test_auto_generates_timestamp(self, tmp_path):
728
cli = FossilCLI(binary="/usr/bin/fossil")
729
repo_path = tmp_path / "repo.fossil"
730
with patch("subprocess.run", return_value=_ok()) as mock_run:
731
cli.technote_create(repo_path, "Note", "body")
732
cmd = mock_run.call_args[0][0]
733
# Should have generated a timestamp in ISO format
734
ts_idx = cmd.index("--technote") + 1
735
assert "T" in cmd[ts_idx] # ISO datetime has T separator
736
737
def test_with_user(self, tmp_path):
738
cli = FossilCLI(binary="/usr/bin/fossil")
739
repo_path = tmp_path / "repo.fossil"
740
with patch("subprocess.run", return_value=_ok()) as mock_run:
741
cli.technote_create(repo_path, "Note", "body", timestamp="2026-01-01T00:00:00", user="author")
742
cmd = mock_run.call_args[0][0]
743
assert "--technote-user" in cmd
744
assert "author" in cmd
745
746
def test_failure(self, tmp_path):
747
cli = FossilCLI(binary="/usr/bin/fossil")
748
repo_path = tmp_path / "repo.fossil"
749
with patch("subprocess.run", return_value=_fail()):
750
result = cli.technote_create(repo_path, "Fail", "body", timestamp="2026-01-01T00:00:00")
751
assert result is False
752
753
754
# ---------------------------------------------------------------------------
755
# technote_edit
756
# ---------------------------------------------------------------------------
757
758
759
class TestTechnoteEdit:
760
def test_success(self, tmp_path):
761
cli = FossilCLI(binary="/usr/bin/fossil")
762
repo_path = tmp_path / "repo.fossil"
763
with patch("subprocess.run", return_value=_ok()) as mock_run:
764
result = cli.technote_edit(repo_path, "abc123", "Updated body")
765
assert result is True
766
cmd = mock_run.call_args[0][0]
767
assert "--technote" in cmd
768
assert "abc123" in cmd
769
assert mock_run.call_args[1]["input"] == "Updated body"
770
771
def test_with_user(self, tmp_path):
772
cli = FossilCLI(binary="/usr/bin/fossil")
773
repo_path = tmp_path / "repo.fossil"
774
with patch("subprocess.run", return_value=_ok()) as mock_run:
775
cli.technote_edit(repo_path, "abc123", "body", user="editor")
776
cmd = mock_run.call_args[0][0]
777
assert "--technote-user" in cmd
778
assert "editor" in cmd
779
780
def test_failure(self, tmp_path):
781
cli = FossilCLI(binary="/usr/bin/fossil")
782
repo_path = tmp_path / "repo.fossil"
783
with patch("subprocess.run", return_value=_fail()):
784
result = cli.technote_edit(repo_path, "badid", "body")
785
assert result is False
786
787
788
# ---------------------------------------------------------------------------
789
# uv_add
790
# ---------------------------------------------------------------------------
791
792
793
class TestUvAdd:
794
def test_success(self, tmp_path):
795
cli = FossilCLI(binary="/usr/bin/fossil")
796
repo_path = tmp_path / "repo.fossil"
797
filepath = tmp_path / "logo.png"
798
with patch("subprocess.run", return_value=_ok()) as mock_run:
799
result = cli.uv_add(repo_path, "logo.png", filepath)
800
assert result is True
801
cmd = mock_run.call_args[0][0]
802
assert cmd == ["/usr/bin/fossil", "uv", "add", str(filepath), "--as", "logo.png", "-R", str(repo_path)]
803
804
def test_failure(self, tmp_path):
805
cli = FossilCLI(binary="/usr/bin/fossil")
806
repo_path = tmp_path / "repo.fossil"
807
with patch("subprocess.run", return_value=_fail()):
808
result = cli.uv_add(repo_path, "file.txt", tmp_path / "file.txt")
809
assert result is False
810
811
812
# ---------------------------------------------------------------------------
813
# uv_cat
814
# ---------------------------------------------------------------------------
815
816
817
class TestUvCat:
818
def test_returns_bytes_on_success(self, tmp_path):
819
cli = FossilCLI(binary="/usr/bin/fossil")
820
repo_path = tmp_path / "repo.fossil"
821
content = b"\x89PNG\r\n\x1a\n" # PNG header bytes
822
with patch("subprocess.run", return_value=_ok_bytes(stdout=content)) as mock_run:
823
result = cli.uv_cat(repo_path, "logo.png")
824
assert result == content
825
cmd = mock_run.call_args[0][0]
826
assert cmd == ["/usr/bin/fossil", "uv", "cat", "logo.png", "-R", str(repo_path)]
827
828
def test_raises_file_not_found_on_failure(self, tmp_path):
829
cli = FossilCLI(binary="/usr/bin/fossil")
830
repo_path = tmp_path / "repo.fossil"
831
with (
832
patch("subprocess.run", return_value=_ok_bytes(returncode=1)),
833
pytest.raises(FileNotFoundError, match="Unversioned file not found"),
834
):
835
cli.uv_cat(repo_path, "missing.txt")
836
837
838
# ---------------------------------------------------------------------------
839
# git_export (supplements TestGitExportTokenHandling in test_security.py)
840
# ---------------------------------------------------------------------------
841
842
843
class TestGitExport:
844
def test_basic_export_no_autopush(self, tmp_path):
845
cli = FossilCLI(binary="/usr/bin/fossil")
846
repo_path = tmp_path / "repo.fossil"
847
mirror_dir = tmp_path / "mirror"
848
with patch("subprocess.run", return_value=_ok(stdout="exported 5 commits")) as mock_run:
849
result = cli.git_export(repo_path, mirror_dir)
850
assert result["success"] is True
851
assert result["message"] == "exported 5 commits"
852
cmd = mock_run.call_args[0][0]
853
assert cmd == ["/usr/bin/fossil", "git", "export", str(mirror_dir), "-R", str(repo_path)]
854
# mirror_dir should be created
855
assert mirror_dir.exists()
856
857
def test_with_autopush_url(self, tmp_path):
858
cli = FossilCLI(binary="/usr/bin/fossil")
859
repo_path = tmp_path / "repo.fossil"
860
mirror_dir = tmp_path / "mirror"
861
with patch("subprocess.run", return_value=_ok(stdout="pushed")) as mock_run:
862
cli.git_export(repo_path, mirror_dir, autopush_url="https://github.com/user/repo.git")
863
cmd = mock_run.call_args[0][0]
864
assert "--autopush" in cmd
865
assert "https://github.com/user/repo.git" in cmd
866
867
def test_timeout_returns_failure(self, tmp_path):
868
cli = FossilCLI(binary="/usr/bin/fossil")
869
repo_path = tmp_path / "repo.fossil"
870
mirror_dir = tmp_path / "mirror"
871
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("cmd", 300)):
872
result = cli.git_export(repo_path, mirror_dir)
873
assert result["success"] is False
874
assert "timed out" in result["message"].lower()
875
876
def test_failure_returncode(self, tmp_path):
877
cli = FossilCLI(binary="/usr/bin/fossil")
878
repo_path = tmp_path / "repo.fossil"
879
mirror_dir = tmp_path / "mirror"
880
with patch("subprocess.run", return_value=_ok(stdout="fatal error", returncode=1)):
881
result = cli.git_export(repo_path, mirror_dir)
882
assert result["success"] is False
883
884
def test_temp_files_cleaned_on_success(self, tmp_path):
885
"""Askpass and token temp files are removed after successful export."""
886
cli = FossilCLI(binary="/usr/bin/fossil")
887
repo_path = tmp_path / "repo.fossil"
888
mirror_dir = tmp_path / "mirror"
889
890
created_files = []
891
892
original_mkstemp = __import__("tempfile").mkstemp
893
894
def tracking_mkstemp(**kwargs):
895
fd, path = original_mkstemp(**kwargs)
896
created_files.append(path)
897
return fd, path
898
899
with (
900
patch("subprocess.run", return_value=_ok(stdout="ok")),
901
patch("tempfile.mkstemp", side_effect=tracking_mkstemp),
902
):
903
cli.git_export(repo_path, mirror_dir, autopush_url="https://github.com/u/r.git", auth_token="tok123")
904
905
# Both temp files should be cleaned up
906
assert len(created_files) == 2
907
for f in created_files:
908
assert not os.path.exists(f)
909
910
def test_temp_files_cleaned_on_timeout(self, tmp_path):
911
"""Askpass and token temp files are removed even when subprocess times out."""
912
cli = FossilCLI(binary="/usr/bin/fossil")
913
repo_path = tmp_path / "repo.fossil"
914
mirror_dir = tmp_path / "mirror"
915
916
created_files = []
917
original_mkstemp = __import__("tempfile").mkstemp
918
919
def tracking_mkstemp(**kwargs):
920
fd, path = original_mkstemp(**kwargs)
921
created_files.append(path)
922
return fd, path
923
924
with (
925
patch("subprocess.run", side_effect=subprocess.TimeoutExpired("cmd", 300)),
926
patch("tempfile.mkstemp", side_effect=tracking_mkstemp),
927
):
928
cli.git_export(repo_path, mirror_dir, autopush_url="https://github.com/u/r.git", auth_token="tok123")
929
930
for f in created_files:
931
assert not os.path.exists(f)
932
933
def test_no_redaction_when_no_token(self, tmp_path):
934
cli = FossilCLI(binary="/usr/bin/fossil")
935
repo_path = tmp_path / "repo.fossil"
936
mirror_dir = tmp_path / "mirror"
937
with patch("subprocess.run", return_value=_ok(stdout="push ok")):
938
result = cli.git_export(repo_path, mirror_dir, autopush_url="https://github.com/u/r.git")
939
assert result["message"] == "push ok"
940
assert "[REDACTED]" not in result["message"]
941
942
def test_combines_stdout_and_stderr(self, tmp_path):
943
cli = FossilCLI(binary="/usr/bin/fossil")
944
repo_path = tmp_path / "repo.fossil"
945
mirror_dir = tmp_path / "mirror"
946
with patch("subprocess.run", return_value=_ok(stdout="out\n", stderr="err")):
947
result = cli.git_export(repo_path, mirror_dir)
948
assert "out" in result["message"]
949
assert "err" in result["message"]
950
951
952
# ---------------------------------------------------------------------------
953
# generate_ssh_key
954
# ---------------------------------------------------------------------------
955
956
957
class TestGenerateSSHKey:
958
def test_success(self, tmp_path):
959
cli = FossilCLI(binary="/usr/bin/fossil")
960
key_path = tmp_path / "keys" / "id_ed25519"
961
pub_key_content = "ssh-ed25519 AAAAC3Nza...== fossilrepo"
962
fingerprint_output = "256 SHA256:abcdef123456 fossilrepo (ED25519)"
963
964
# Create the public key file that generate_ssh_key will try to read
965
key_path.parent.mkdir(parents=True, exist_ok=True)
966
967
with patch("subprocess.run") as mock_run:
968
# ssh-keygen creates the key, then we read pubkey, then fingerprint
969
def side_effect(cmd, **kwargs):
970
if "-t" in cmd:
971
# Write fake pub key file on "creation"
972
key_path.with_suffix(".pub").write_text(pub_key_content)
973
return _ok()
974
elif "-lf" in cmd:
975
return _ok(stdout=fingerprint_output)
976
return _ok()
977
978
mock_run.side_effect = side_effect
979
result = cli.generate_ssh_key(key_path, comment="fossilrepo")
980
981
assert result["success"] is True
982
assert result["public_key"] == pub_key_content
983
assert result["fingerprint"] == "SHA256:abcdef123456"
984
985
def test_creates_parent_dirs(self, tmp_path):
986
cli = FossilCLI(binary="/usr/bin/fossil")
987
key_path = tmp_path / "deep" / "nested" / "id_ed25519"
988
with patch("subprocess.run", return_value=_fail()):
989
cli.generate_ssh_key(key_path)
990
# Parent dirs should exist even if ssh-keygen fails
991
assert key_path.parent.exists()
992
993
def test_failure_returns_error_dict(self, tmp_path):
994
cli = FossilCLI(binary="/usr/bin/fossil")
995
key_path = tmp_path / "id_ed25519"
996
with patch("subprocess.run", return_value=_fail()):
997
result = cli.generate_ssh_key(key_path)
998
assert result["success"] is False
999
assert result["public_key"] == ""
1000
assert result["fingerprint"] == ""
1001
1002
def test_exception_returns_error_dict(self, tmp_path):
1003
cli = FossilCLI(binary="/usr/bin/fossil")
1004
key_path = tmp_path / "id_ed25519"
1005
with patch("subprocess.run", side_effect=Exception("ssh-keygen not found")):
1006
result = cli.generate_ssh_key(key_path)
1007
assert result["success"] is False
1008
assert "ssh-keygen not found" in result["error"]
1009
1010
def test_keygen_command_uses_ed25519(self, tmp_path):
1011
cli = FossilCLI(binary="/usr/bin/fossil")
1012
key_path = tmp_path / "id_ed25519"
1013
with patch("subprocess.run", return_value=_fail()) as mock_run:
1014
cli.generate_ssh_key(key_path, comment="test-key")
1015
cmd = mock_run.call_args[0][0]
1016
assert cmd == ["ssh-keygen", "-t", "ed25519", "-f", str(key_path), "-N", "", "-C", "test-key"]
1017
1018
def test_fingerprint_empty_on_keygen_lf_failure(self, tmp_path):
1019
"""If ssh-keygen -lf fails, fingerprint should be empty but success still True."""
1020
cli = FossilCLI(binary="/usr/bin/fossil")
1021
key_path = tmp_path / "id_ed25519"
1022
pub_key_content = "ssh-ed25519 AAAAC3Nza...== test"
1023
1024
with patch("subprocess.run") as mock_run:
1025
1026
def side_effect(cmd, **kwargs):
1027
if "-t" in cmd:
1028
key_path.with_suffix(".pub").write_text(pub_key_content)
1029
return _ok()
1030
elif "-lf" in cmd:
1031
return _fail()
1032
return _ok()
1033
1034
mock_run.side_effect = side_effect
1035
result = cli.generate_ssh_key(key_path)
1036
assert result["success"] is True
1037
assert result["public_key"] == pub_key_content
1038
assert result["fingerprint"] == ""
1039
1040
1041
# ---------------------------------------------------------------------------
1042
# http_proxy
1043
# ---------------------------------------------------------------------------
1044
1045
1046
class TestHttpProxy:
1047
def test_parses_crlf_response(self, tmp_path):
1048
"""Standard HTTP response with \\r\\n\\r\\n separator."""
1049
cli = FossilCLI(binary="/usr/bin/fossil")
1050
repo_path = tmp_path / "repo.fossil"
1051
raw_response = b"HTTP/1.1 200 OK\r\nContent-Type: application/x-fossil\r\n\r\n\x00\x01\x02\x03"
1052
with patch("subprocess.run", return_value=_ok_bytes(stdout=raw_response)):
1053
body, content_type = cli.http_proxy(repo_path, b"request_body")
1054
assert body == b"\x00\x01\x02\x03"
1055
assert content_type == "application/x-fossil"
1056
1057
def test_parses_lf_response(self, tmp_path):
1058
"""Fallback: \\n\\n separator (no \\r)."""
1059
cli = FossilCLI(binary="/usr/bin/fossil")
1060
repo_path = tmp_path / "repo.fossil"
1061
raw_response = b"Content-Type: text/html\n\n<html>body</html>"
1062
with patch("subprocess.run", return_value=_ok_bytes(stdout=raw_response)):
1063
body, content_type = cli.http_proxy(repo_path, b"req")
1064
assert body == b"<html>body</html>"
1065
assert content_type == "text/html"
1066
1067
def test_no_separator_returns_entire_body(self, tmp_path):
1068
"""If no header/body separator, treat entire output as body."""
1069
cli = FossilCLI(binary="/usr/bin/fossil")
1070
repo_path = tmp_path / "repo.fossil"
1071
raw_response = b"raw binary data with no headers"
1072
with patch("subprocess.run", return_value=_ok_bytes(stdout=raw_response)):
1073
body, content_type = cli.http_proxy(repo_path, b"req")
1074
assert body == raw_response
1075
assert content_type == "application/x-fossil"
1076
1077
def test_localauth_flag(self, tmp_path):
1078
cli = FossilCLI(binary="/usr/bin/fossil")
1079
repo_path = tmp_path / "repo.fossil"
1080
with patch("subprocess.run", return_value=_ok_bytes(stdout=b"\r\n\r\n")) as mock_run:
1081
cli.http_proxy(repo_path, b"body", localauth=True)
1082
cmd = mock_run.call_args[0][0]
1083
assert "--localauth" in cmd
1084
1085
def test_no_localauth_flag(self, tmp_path):
1086
cli = FossilCLI(binary="/usr/bin/fossil")
1087
repo_path = tmp_path / "repo.fossil"
1088
with patch("subprocess.run", return_value=_ok_bytes(stdout=b"\r\n\r\n")) as mock_run:
1089
cli.http_proxy(repo_path, b"body", localauth=False)
1090
cmd = mock_run.call_args[0][0]
1091
assert "--localauth" not in cmd
1092
1093
def test_builds_http_request_on_stdin(self, tmp_path):
1094
cli = FossilCLI(binary="/usr/bin/fossil")
1095
repo_path = tmp_path / "repo.fossil"
1096
request_body = b"\x00\x01binary-data"
1097
with patch("subprocess.run", return_value=_ok_bytes(stdout=b"\r\n\r\n")) as mock_run:
1098
cli.http_proxy(repo_path, request_body, content_type="application/x-fossil")
1099
http_input = mock_run.call_args[1]["input"]
1100
# Should contain POST, Host, Content-Type, Content-Length headers + body
1101
assert b"POST /xfer HTTP/1.1\r\n" in http_input
1102
assert b"Host: localhost\r\n" in http_input
1103
assert b"Content-Type: application/x-fossil\r\n" in http_input
1104
assert f"Content-Length: {len(request_body)}".encode() in http_input
1105
assert http_input.endswith(request_body)
1106
1107
def test_default_content_type(self, tmp_path):
1108
"""When no content_type provided, defaults to application/x-fossil."""
1109
cli = FossilCLI(binary="/usr/bin/fossil")
1110
repo_path = tmp_path / "repo.fossil"
1111
with patch("subprocess.run", return_value=_ok_bytes(stdout=b"\r\n\r\n")) as mock_run:
1112
cli.http_proxy(repo_path, b"body")
1113
http_input = mock_run.call_args[1]["input"]
1114
assert b"Content-Type: application/x-fossil\r\n" in http_input
1115
1116
def test_timeout_raises(self, tmp_path):
1117
cli = FossilCLI(binary="/usr/bin/fossil")
1118
repo_path = tmp_path / "repo.fossil"
1119
with (
1120
patch("subprocess.run", side_effect=subprocess.TimeoutExpired("cmd", 120)),
1121
pytest.raises(subprocess.TimeoutExpired),
1122
):
1123
cli.http_proxy(repo_path, b"body")
1124
1125
def test_file_not_found_raises(self, tmp_path):
1126
cli = FossilCLI(binary="/nonexistent/fossil")
1127
repo_path = tmp_path / "repo.fossil"
1128
with (
1129
patch("subprocess.run", side_effect=FileNotFoundError),
1130
pytest.raises(FileNotFoundError),
1131
):
1132
cli.http_proxy(repo_path, b"body")
1133
1134
def test_nonzero_returncode_does_not_raise(self, tmp_path):
1135
"""Non-zero exit code logs a warning but does not raise."""
1136
cli = FossilCLI(binary="/usr/bin/fossil")
1137
repo_path = tmp_path / "repo.fossil"
1138
raw_response = b"Content-Type: application/x-fossil\r\n\r\nbody"
1139
with patch("subprocess.run", return_value=_ok_bytes(stdout=raw_response, returncode=1)):
1140
body, ct = cli.http_proxy(repo_path, b"req")
1141
assert body == b"body"
1142
1143
def test_gateway_interface_stripped(self, tmp_path):
1144
"""GATEWAY_INTERFACE must not be in the env passed to fossil http."""
1145
cli = FossilCLI(binary="/usr/bin/fossil")
1146
repo_path = tmp_path / "repo.fossil"
1147
with (
1148
patch.dict(os.environ, {"GATEWAY_INTERFACE": "CGI/1.1"}),
1149
patch("subprocess.run", return_value=_ok_bytes(stdout=b"\r\n\r\n")) as mock_run,
1150
):
1151
cli.http_proxy(repo_path, b"body")
1152
env = mock_run.call_args[1]["env"]
1153
assert "GATEWAY_INTERFACE" not in env
1154
1155
1156
# ---------------------------------------------------------------------------
1157
# shun / shun_list
1158
# ---------------------------------------------------------------------------
1159
1160
1161
class TestShun:
1162
def test_shun_success(self, tmp_path):
1163
cli = FossilCLI(binary="/usr/bin/fossil")
1164
repo_path = tmp_path / "repo.fossil"
1165
with patch("subprocess.run", return_value=_ok(stdout="Shunned")):
1166
result = cli.shun(repo_path, "abc123def456")
1167
assert result["success"] is True
1168
assert "Shunned" in result["message"]
1169
1170
def test_shun_failure(self, tmp_path):
1171
cli = FossilCLI(binary="/usr/bin/fossil")
1172
repo_path = tmp_path / "repo.fossil"
1173
with patch("subprocess.run", return_value=_fail(stdout="", stderr="not found")):
1174
result = cli.shun(repo_path, "badid")
1175
assert result["success"] is False
1176
assert "not found" in result["message"]
1177
1178
def test_shun_combines_stdout_stderr(self, tmp_path):
1179
cli = FossilCLI(binary="/usr/bin/fossil")
1180
repo_path = tmp_path / "repo.fossil"
1181
with patch("subprocess.run", return_value=_ok(stdout="out\n", stderr="warning")):
1182
result = cli.shun(repo_path, "abc123")
1183
assert "out" in result["message"]
1184
assert "warning" in result["message"]
1185
1186
1187
class TestShunList:
1188
def test_returns_uuids(self, tmp_path):
1189
cli = FossilCLI(binary="/usr/bin/fossil")
1190
repo_path = tmp_path / "repo.fossil"
1191
with patch("subprocess.run", return_value=_ok(stdout="abc123\ndef456\nghi789\n")):
1192
result = cli.shun_list(repo_path)
1193
assert result == ["abc123", "def456", "ghi789"]
1194
1195
def test_returns_empty_on_failure(self, tmp_path):
1196
cli = FossilCLI(binary="/usr/bin/fossil")
1197
repo_path = tmp_path / "repo.fossil"
1198
with patch("subprocess.run", return_value=_fail()):
1199
result = cli.shun_list(repo_path)
1200
assert result == []
1201
1202
def test_strips_whitespace_and_empty_lines(self, tmp_path):
1203
cli = FossilCLI(binary="/usr/bin/fossil")
1204
repo_path = tmp_path / "repo.fossil"
1205
with patch("subprocess.run", return_value=_ok(stdout="\n abc123 \n\n def456\n\n")):
1206
result = cli.shun_list(repo_path)
1207
assert result == ["abc123", "def456"]
1208

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button