FossilRepo

fossilrepo / tests / test_agent_coordination.py
Blame History Raw 981 lines
1
"""Tests for agent coordination features: ticket claiming, SSE, code reviews.
2
3
Tests use session auth (admin_client) since the API endpoints accept session
4
cookies as well as Bearer tokens. We create Django-side objects directly rather
5
than going through Fossil's SQLite for ticket verification in claiming tests.
6
"""
7
8
import json
9
from unittest.mock import MagicMock, patch
10
11
import pytest
12
from django.contrib.auth.models import User
13
from django.test import Client
14
15
from fossil.agent_claims import TicketClaim
16
from fossil.code_reviews import CodeReview, ReviewComment
17
from fossil.models import FossilRepository
18
from fossil.workspaces import AgentWorkspace
19
from organization.models import Team
20
from projects.models import ProjectTeam
21
22
23
@pytest.fixture
24
def fossil_repo_obj(sample_project):
25
"""Return the auto-created FossilRepository for sample_project."""
26
return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
27
28
29
@pytest.fixture
30
def writer_user(db, admin_user, sample_project):
31
"""User with write access but not admin."""
32
writer = User.objects.create_user(username="writer_coord", password="testpass123")
33
team = Team.objects.create(name="Coord Writers", organization=sample_project.organization, created_by=admin_user)
34
team.members.add(writer)
35
ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
36
return writer
37
38
39
@pytest.fixture
40
def writer_client(writer_user):
41
client = Client()
42
client.login(username="writer_coord", password="testpass123")
43
return client
44
45
46
@pytest.fixture
47
def reader_user(db, admin_user, sample_project):
48
"""User with read-only access."""
49
reader = User.objects.create_user(username="reader_coord", password="testpass123")
50
team = Team.objects.create(name="Coord Readers", organization=sample_project.organization, created_by=admin_user)
51
team.members.add(reader)
52
ProjectTeam.objects.create(project=sample_project, team=team, role="read", created_by=admin_user)
53
return reader
54
55
56
@pytest.fixture
57
def reader_client(reader_user):
58
client = Client()
59
client.login(username="reader_coord", password="testpass123")
60
return client
61
62
63
@pytest.fixture
64
def workspace(fossil_repo_obj, admin_user):
65
"""An active agent workspace."""
66
return AgentWorkspace.objects.create(
67
repository=fossil_repo_obj,
68
name="agent-fix-42",
69
branch="workspace/agent-fix-42",
70
agent_id="claude-test",
71
status="active",
72
created_by=admin_user,
73
)
74
75
76
def _api_url(slug, path):
77
return f"/projects/{slug}/fossil/{path}"
78
79
80
# A mock FossilReader.get_ticket_detail that returns a fake ticket
81
def _mock_ticket_detail(uuid):
82
if uuid == "abc123def456":
83
ticket = MagicMock()
84
ticket.uuid = "abc123def456"
85
ticket.title = "Test Bug"
86
ticket.status = "Open"
87
ticket.type = "Bug"
88
ticket.priority = ""
89
ticket.severity = ""
90
ticket.created = None
91
return ticket
92
return None
93
94
95
def _mock_get_tickets(status=None, limit=1000):
96
"""Return a list of fake tickets for unclaimed listing."""
97
t1 = MagicMock()
98
t1.uuid = "ticket-111"
99
t1.title = "Bug One"
100
t1.status = "Open"
101
t1.type = "Bug"
102
t1.priority = "High"
103
t1.severity = ""
104
t1.created = None
105
106
t2 = MagicMock()
107
t2.uuid = "ticket-222"
108
t2.title = "Bug Two"
109
t2.status = "Open"
110
t2.type = "Bug"
111
t2.priority = "Medium"
112
t2.severity = ""
113
t2.created = None
114
115
return [t1, t2]
116
117
118
# ===== Ticket Claiming Tests =====
119
120
121
@pytest.mark.django_db
122
class TestTicketClaim:
123
def test_claim_ticket_success(self, admin_client, sample_project, fossil_repo_obj):
124
"""Claiming an unclaimed ticket returns 201."""
125
with patch("fossil.api_views.FossilReader") as mock_reader_cls:
126
instance = mock_reader_cls.return_value
127
instance.get_ticket_detail.side_effect = _mock_ticket_detail
128
129
response = admin_client.post(
130
_api_url(sample_project.slug, "api/tickets/abc123def456/claim"),
131
data=json.dumps({"agent_id": "claude-abc"}),
132
content_type="application/json",
133
)
134
135
assert response.status_code == 201
136
data = response.json()
137
assert data["ticket_uuid"] == "abc123def456"
138
assert data["agent_id"] == "claude-abc"
139
assert data["status"] == "claimed"
140
141
# Verify DB state
142
claim = TicketClaim.objects.get(repository=fossil_repo_obj, ticket_uuid="abc123def456")
143
assert claim.agent_id == "claude-abc"
144
assert claim.status == "claimed"
145
146
def test_claim_ticket_with_workspace(self, admin_client, sample_project, fossil_repo_obj, workspace):
147
"""Claiming with a workspace links the claim to the workspace."""
148
with patch("fossil.api_views.FossilReader") as mock_reader_cls:
149
instance = mock_reader_cls.return_value
150
instance.get_ticket_detail.side_effect = _mock_ticket_detail
151
152
response = admin_client.post(
153
_api_url(sample_project.slug, "api/tickets/abc123def456/claim"),
154
data=json.dumps({"agent_id": "claude-abc", "workspace": "agent-fix-42"}),
155
content_type="application/json",
156
)
157
158
assert response.status_code == 201
159
claim = TicketClaim.objects.get(repository=fossil_repo_obj, ticket_uuid="abc123def456")
160
assert claim.workspace == workspace
161
162
def test_claim_already_claimed_by_other(self, admin_client, sample_project, fossil_repo_obj, admin_user):
163
"""Claiming a ticket already claimed by another agent returns 409."""
164
TicketClaim.objects.create(
165
repository=fossil_repo_obj,
166
ticket_uuid="abc123def456",
167
agent_id="other-agent",
168
created_by=admin_user,
169
)
170
171
with patch("fossil.api_views.FossilReader") as mock_reader_cls:
172
instance = mock_reader_cls.return_value
173
instance.get_ticket_detail.side_effect = _mock_ticket_detail
174
175
response = admin_client.post(
176
_api_url(sample_project.slug, "api/tickets/abc123def456/claim"),
177
data=json.dumps({"agent_id": "claude-abc"}),
178
content_type="application/json",
179
)
180
181
assert response.status_code == 409
182
data = response.json()
183
assert data["error"] == "Ticket already claimed"
184
assert data["claimed_by"] == "other-agent"
185
186
def test_claim_idempotent_same_agent(self, admin_client, sample_project, fossil_repo_obj, admin_user):
187
"""Re-claiming a ticket by the same agent is idempotent (200, not 409)."""
188
TicketClaim.objects.create(
189
repository=fossil_repo_obj,
190
ticket_uuid="abc123def456",
191
agent_id="claude-abc",
192
created_by=admin_user,
193
)
194
195
with patch("fossil.api_views.FossilReader") as mock_reader_cls:
196
instance = mock_reader_cls.return_value
197
instance.get_ticket_detail.side_effect = _mock_ticket_detail
198
199
response = admin_client.post(
200
_api_url(sample_project.slug, "api/tickets/abc123def456/claim"),
201
data=json.dumps({"agent_id": "claude-abc"}),
202
content_type="application/json",
203
)
204
205
assert response.status_code == 200
206
assert response.json()["message"] == "Already claimed by you"
207
208
def test_claim_nonexistent_ticket(self, admin_client, sample_project, fossil_repo_obj):
209
"""Claiming a ticket that doesn't exist in Fossil returns 404."""
210
with patch("fossil.api_views.FossilReader") as mock_reader_cls:
211
instance = mock_reader_cls.return_value
212
instance.get_ticket_detail.return_value = None
213
214
response = admin_client.post(
215
_api_url(sample_project.slug, "api/tickets/nonexistent/claim"),
216
data=json.dumps({"agent_id": "claude-abc"}),
217
content_type="application/json",
218
)
219
220
assert response.status_code == 404
221
assert "not found" in response.json()["error"].lower()
222
223
def test_claim_missing_agent_id(self, admin_client, sample_project, fossil_repo_obj):
224
"""Claiming without agent_id returns 400."""
225
response = admin_client.post(
226
_api_url(sample_project.slug, "api/tickets/abc123def456/claim"),
227
data=json.dumps({}),
228
content_type="application/json",
229
)
230
assert response.status_code == 400
231
assert "agent_id" in response.json()["error"]
232
233
def test_claim_denied_for_reader(self, reader_client, sample_project, fossil_repo_obj):
234
"""Read-only users cannot claim tickets."""
235
response = reader_client.post(
236
_api_url(sample_project.slug, "api/tickets/abc123def456/claim"),
237
data=json.dumps({"agent_id": "claude-abc"}),
238
content_type="application/json",
239
)
240
assert response.status_code == 403
241
242
def test_claim_denied_for_anon(self, client, sample_project, fossil_repo_obj):
243
"""Anonymous users cannot claim tickets."""
244
response = client.post(
245
_api_url(sample_project.slug, "api/tickets/abc123def456/claim"),
246
data=json.dumps({"agent_id": "claude-abc"}),
247
content_type="application/json",
248
)
249
assert response.status_code == 401
250
251
def test_claim_wrong_method(self, admin_client, sample_project, fossil_repo_obj):
252
"""GET to claim endpoint returns 405."""
253
response = admin_client.get(_api_url(sample_project.slug, "api/tickets/abc123def456/claim"))
254
assert response.status_code == 405
255
256
257
@pytest.mark.django_db
258
class TestTicketRelease:
259
def test_release_claim(self, admin_client, sample_project, fossil_repo_obj, admin_user):
260
"""Releasing a claimed ticket soft-deletes the claim."""
261
TicketClaim.objects.create(
262
repository=fossil_repo_obj,
263
ticket_uuid="abc123def456",
264
agent_id="claude-abc",
265
created_by=admin_user,
266
)
267
268
response = admin_client.post(
269
_api_url(sample_project.slug, "api/tickets/abc123def456/release"),
270
data=json.dumps({"agent_id": "claude-abc"}),
271
content_type="application/json",
272
)
273
274
assert response.status_code == 200
275
data = response.json()
276
assert data["status"] == "released"
277
assert data["released_at"] is not None
278
279
# Claim should be soft-deleted (not visible via default manager)
280
assert TicketClaim.objects.filter(repository=fossil_repo_obj, ticket_uuid="abc123def456").count() == 0
281
# But still in all_objects
282
assert TicketClaim.all_objects.filter(repository=fossil_repo_obj, ticket_uuid="abc123def456").count() == 1
283
284
def test_release_wrong_agent_denied(self, admin_client, sample_project, fossil_repo_obj, admin_user):
285
"""Only the claiming agent can release the claim."""
286
TicketClaim.objects.create(
287
repository=fossil_repo_obj,
288
ticket_uuid="abc123def456",
289
agent_id="claude-abc",
290
created_by=admin_user,
291
)
292
293
response = admin_client.post(
294
_api_url(sample_project.slug, "api/tickets/abc123def456/release"),
295
data=json.dumps({"agent_id": "different-agent"}),
296
content_type="application/json",
297
)
298
assert response.status_code == 403
299
assert "claiming agent" in response.json()["error"].lower()
300
301
def test_release_requires_agent_id(self, admin_client, sample_project, fossil_repo_obj, admin_user):
302
"""Release without agent_id returns 400."""
303
TicketClaim.objects.create(
304
repository=fossil_repo_obj,
305
ticket_uuid="abc123def456",
306
agent_id="claude-abc",
307
created_by=admin_user,
308
)
309
310
response = admin_client.post(
311
_api_url(sample_project.slug, "api/tickets/abc123def456/release"),
312
content_type="application/json",
313
)
314
assert response.status_code == 400
315
316
def test_release_allows_reclaim(self, admin_client, sample_project, fossil_repo_obj, admin_user):
317
"""After releasing, another agent can claim the ticket."""
318
TicketClaim.objects.create(
319
repository=fossil_repo_obj,
320
ticket_uuid="abc123def456",
321
agent_id="claude-abc",
322
created_by=admin_user,
323
)
324
325
# Release the claim
326
admin_client.post(
327
_api_url(sample_project.slug, "api/tickets/abc123def456/release"),
328
data=json.dumps({"agent_id": "claude-abc"}),
329
content_type="application/json",
330
)
331
332
# Now a new claim should succeed
333
with patch("fossil.api_views.FossilReader") as mock_reader_cls:
334
instance = mock_reader_cls.return_value
335
instance.get_ticket_detail.side_effect = _mock_ticket_detail
336
337
response = admin_client.post(
338
_api_url(sample_project.slug, "api/tickets/abc123def456/claim"),
339
data=json.dumps({"agent_id": "other-agent"}),
340
content_type="application/json",
341
)
342
343
assert response.status_code == 201
344
assert response.json()["agent_id"] == "other-agent"
345
346
def test_release_nonexistent_claim(self, admin_client, sample_project, fossil_repo_obj):
347
"""Releasing when no claim exists returns 404."""
348
response = admin_client.post(
349
_api_url(sample_project.slug, "api/tickets/nonexistent/release"),
350
data=json.dumps({"agent_id": "claude-abc"}),
351
content_type="application/json",
352
)
353
assert response.status_code == 404
354
355
def test_release_denied_for_reader(self, reader_client, sample_project, fossil_repo_obj):
356
"""Read-only users cannot release claims."""
357
response = reader_client.post(
358
_api_url(sample_project.slug, "api/tickets/abc123def456/release"),
359
data=json.dumps({"agent_id": "claude-abc"}),
360
content_type="application/json",
361
)
362
assert response.status_code == 403
363
364
365
@pytest.mark.django_db
366
class TestTicketSubmit:
367
def test_submit_work(self, admin_client, sample_project, fossil_repo_obj, admin_user):
368
"""Submitting work updates claim status and records summary."""
369
TicketClaim.objects.create(
370
repository=fossil_repo_obj,
371
ticket_uuid="abc123def456",
372
agent_id="claude-abc",
373
created_by=admin_user,
374
)
375
376
with patch("fossil.cli.FossilCLI") as mock_cli_cls:
377
mock_cli_cls.return_value.ticket_change.return_value = True
378
379
response = admin_client.post(
380
_api_url(sample_project.slug, "api/tickets/abc123def456/submit"),
381
data=json.dumps(
382
{
383
"agent_id": "claude-abc",
384
"summary": "Fixed the null pointer bug",
385
"files_changed": ["src/auth.py", "tests/test_auth.py"],
386
}
387
),
388
content_type="application/json",
389
)
390
391
assert response.status_code == 200
392
data = response.json()
393
assert data["status"] == "submitted"
394
assert data["summary"] == "Fixed the null pointer bug"
395
assert data["files_changed"] == ["src/auth.py", "tests/test_auth.py"]
396
397
# Verify DB state
398
claim = TicketClaim.objects.get(repository=fossil_repo_obj, ticket_uuid="abc123def456")
399
assert claim.status == "submitted"
400
401
def test_submit_already_submitted(self, admin_client, sample_project, fossil_repo_obj, admin_user):
402
"""Submitting again on an already-submitted claim returns 409."""
403
TicketClaim.objects.create(
404
repository=fossil_repo_obj,
405
ticket_uuid="abc123def456",
406
agent_id="claude-abc",
407
status="submitted",
408
created_by=admin_user,
409
)
410
411
response = admin_client.post(
412
_api_url(sample_project.slug, "api/tickets/abc123def456/submit"),
413
data=json.dumps({"agent_id": "claude-abc", "summary": "more work"}),
414
content_type="application/json",
415
)
416
assert response.status_code == 409
417
418
def test_submit_wrong_agent_denied(self, admin_client, sample_project, fossil_repo_obj, admin_user):
419
"""Only the claiming agent can submit work."""
420
TicketClaim.objects.create(
421
repository=fossil_repo_obj,
422
ticket_uuid="abc123def456",
423
agent_id="claude-abc",
424
created_by=admin_user,
425
)
426
427
response = admin_client.post(
428
_api_url(sample_project.slug, "api/tickets/abc123def456/submit"),
429
data=json.dumps({"agent_id": "different-agent", "summary": "hijack"}),
430
content_type="application/json",
431
)
432
assert response.status_code == 403
433
assert "claiming agent" in response.json()["error"].lower()
434
435
def test_submit_requires_agent_id(self, admin_client, sample_project, fossil_repo_obj, admin_user):
436
"""Submit without agent_id returns 400."""
437
TicketClaim.objects.create(
438
repository=fossil_repo_obj,
439
ticket_uuid="abc123def456",
440
agent_id="claude-abc",
441
created_by=admin_user,
442
)
443
444
response = admin_client.post(
445
_api_url(sample_project.slug, "api/tickets/abc123def456/submit"),
446
data=json.dumps({"summary": "some work"}),
447
content_type="application/json",
448
)
449
assert response.status_code == 400
450
451
def test_submit_no_claim(self, admin_client, sample_project, fossil_repo_obj):
452
"""Submitting without an active claim returns 404."""
453
response = admin_client.post(
454
_api_url(sample_project.slug, "api/tickets/nonexistent/submit"),
455
data=json.dumps({"agent_id": "claude-abc", "summary": "some work"}),
456
content_type="application/json",
457
)
458
assert response.status_code == 404
459
460
def test_submit_denied_for_reader(self, reader_client, sample_project, fossil_repo_obj):
461
"""Read-only users cannot submit work."""
462
response = reader_client.post(
463
_api_url(sample_project.slug, "api/tickets/abc123def456/submit"),
464
data=json.dumps({"agent_id": "claude-abc", "summary": "some work"}),
465
content_type="application/json",
466
)
467
assert response.status_code == 403
468
469
470
@pytest.mark.django_db
471
class TestTicketsUnclaimed:
472
def test_list_unclaimed(self, admin_client, sample_project, fossil_repo_obj):
473
"""Returns tickets that have no active claims."""
474
with patch("fossil.api_views.FossilReader") as mock_reader_cls:
475
instance = mock_reader_cls.return_value
476
instance.get_tickets.side_effect = _mock_get_tickets
477
478
response = admin_client.get(
479
_api_url(sample_project.slug, "api/tickets/unclaimed"),
480
)
481
482
assert response.status_code == 200
483
data = response.json()
484
assert data["total"] == 2
485
uuids = [t["uuid"] for t in data["tickets"]]
486
assert "ticket-111" in uuids
487
assert "ticket-222" in uuids
488
489
def test_unclaimed_excludes_claimed(self, admin_client, sample_project, fossil_repo_obj, admin_user):
490
"""Claimed tickets are excluded from unclaimed listing."""
491
TicketClaim.objects.create(
492
repository=fossil_repo_obj,
493
ticket_uuid="ticket-111",
494
agent_id="claude-abc",
495
created_by=admin_user,
496
)
497
498
with patch("fossil.api_views.FossilReader") as mock_reader_cls:
499
instance = mock_reader_cls.return_value
500
instance.get_tickets.side_effect = _mock_get_tickets
501
502
response = admin_client.get(
503
_api_url(sample_project.slug, "api/tickets/unclaimed"),
504
)
505
506
assert response.status_code == 200
507
data = response.json()
508
assert data["total"] == 1
509
assert data["tickets"][0]["uuid"] == "ticket-222"
510
511
def test_unclaimed_denied_for_anon(self, client, sample_project, fossil_repo_obj):
512
"""Anonymous users cannot list unclaimed tickets."""
513
response = client.get(_api_url(sample_project.slug, "api/tickets/unclaimed"))
514
assert response.status_code == 401
515
516
517
# ===== SSE Tests =====
518
519
520
@pytest.mark.django_db
521
class TestSSEEvents:
522
def test_events_endpoint_returns_sse(self, admin_client, sample_project, fossil_repo_obj):
523
"""SSE endpoint returns text/event-stream content type."""
524
with patch("fossil.api_views.FossilReader") as mock_reader_cls:
525
instance = mock_reader_cls.return_value
526
instance.get_checkin_count.return_value = 0
527
528
response = admin_client.get(
529
_api_url(sample_project.slug, "api/events"),
530
)
531
532
assert response.status_code == 200
533
assert response["Content-Type"] == "text/event-stream"
534
assert response["Cache-Control"] == "no-cache"
535
assert response["X-Accel-Buffering"] == "no"
536
# It's a StreamingHttpResponse
537
assert response.streaming is True
538
539
def test_events_wrong_method(self, admin_client, sample_project, fossil_repo_obj):
540
"""POST to events endpoint returns 405."""
541
response = admin_client.post(
542
_api_url(sample_project.slug, "api/events"),
543
content_type="application/json",
544
)
545
assert response.status_code == 405
546
547
def test_events_denied_for_anon(self, client, sample_project, fossil_repo_obj):
548
"""Anonymous users cannot subscribe to events."""
549
response = client.get(_api_url(sample_project.slug, "api/events"))
550
assert response.status_code == 401
551
552
553
# ===== Code Review Tests =====
554
555
556
@pytest.mark.django_db
557
class TestCodeReviewCreate:
558
def test_create_review(self, admin_client, sample_project, fossil_repo_obj):
559
"""Creating a review returns 201 with review data."""
560
response = admin_client.post(
561
_api_url(sample_project.slug, "api/reviews/create"),
562
data=json.dumps(
563
{
564
"title": "Fix null pointer in auth",
565
"description": "The auth check was failing when user is None",
566
"diff": "--- a/src/auth.py\n+++ b/src/auth.py\n@@ -1,3 +1,4 @@\n+# fix",
567
"files_changed": ["src/auth.py"],
568
"agent_id": "claude-abc",
569
}
570
),
571
content_type="application/json",
572
)
573
574
assert response.status_code == 201
575
data = response.json()
576
assert data["title"] == "Fix null pointer in auth"
577
assert data["status"] == "pending"
578
assert data["agent_id"] == "claude-abc"
579
580
# Verify DB
581
review = CodeReview.objects.get(pk=data["id"])
582
assert review.title == "Fix null pointer in auth"
583
assert review.diff.startswith("--- a/src/auth.py")
584
585
def test_create_review_with_workspace(self, admin_client, sample_project, fossil_repo_obj, workspace):
586
"""Creating a review linked to a workspace."""
587
response = admin_client.post(
588
_api_url(sample_project.slug, "api/reviews/create"),
589
data=json.dumps(
590
{
591
"title": "Fix from workspace",
592
"diff": "--- a/foo.py\n+++ b/foo.py\n",
593
"workspace": "agent-fix-42",
594
"agent_id": "claude-test",
595
}
596
),
597
content_type="application/json",
598
)
599
600
assert response.status_code == 201
601
review = CodeReview.objects.get(pk=response.json()["id"])
602
assert review.workspace == workspace
603
604
def test_create_review_missing_title(self, admin_client, sample_project, fossil_repo_obj):
605
"""Creating a review without title returns 400."""
606
response = admin_client.post(
607
_api_url(sample_project.slug, "api/reviews/create"),
608
data=json.dumps({"diff": "some diff"}),
609
content_type="application/json",
610
)
611
assert response.status_code == 400
612
assert "title" in response.json()["error"].lower()
613
614
def test_create_review_missing_diff(self, admin_client, sample_project, fossil_repo_obj):
615
"""Creating a review without diff returns 400."""
616
response = admin_client.post(
617
_api_url(sample_project.slug, "api/reviews/create"),
618
data=json.dumps({"title": "Some review"}),
619
content_type="application/json",
620
)
621
assert response.status_code == 400
622
assert "diff" in response.json()["error"].lower()
623
624
def test_create_review_denied_for_reader(self, reader_client, sample_project, fossil_repo_obj):
625
"""Read-only users cannot create reviews."""
626
response = reader_client.post(
627
_api_url(sample_project.slug, "api/reviews/create"),
628
data=json.dumps({"title": "Fix", "diff": "---"}),
629
content_type="application/json",
630
)
631
assert response.status_code == 403
632
633
def test_create_review_denied_for_anon(self, client, sample_project, fossil_repo_obj):
634
"""Anonymous users cannot create reviews."""
635
response = client.post(
636
_api_url(sample_project.slug, "api/reviews/create"),
637
data=json.dumps({"title": "Fix", "diff": "---"}),
638
content_type="application/json",
639
)
640
assert response.status_code == 401
641
642
643
@pytest.mark.django_db
644
class TestCodeReviewList:
645
def test_list_reviews(self, admin_client, sample_project, fossil_repo_obj, admin_user):
646
"""List reviews returns all reviews for the repo."""
647
CodeReview.objects.create(repository=fossil_repo_obj, title="Review A", diff="diff A", agent_id="a1", created_by=admin_user)
648
CodeReview.objects.create(repository=fossil_repo_obj, title="Review B", diff="diff B", agent_id="a2", created_by=admin_user)
649
650
response = admin_client.get(_api_url(sample_project.slug, "api/reviews"))
651
assert response.status_code == 200
652
data = response.json()
653
assert data["total"] == 2
654
655
def test_list_reviews_filter_status(self, admin_client, sample_project, fossil_repo_obj, admin_user):
656
"""Filtering by status returns only matching reviews."""
657
CodeReview.objects.create(repository=fossil_repo_obj, title="Pending", diff="d", status="pending", created_by=admin_user)
658
CodeReview.objects.create(repository=fossil_repo_obj, title="Approved", diff="d", status="approved", created_by=admin_user)
659
660
response = admin_client.get(_api_url(sample_project.slug, "api/reviews") + "?status=approved")
661
assert response.status_code == 200
662
data = response.json()
663
assert data["total"] == 1
664
assert data["reviews"][0]["status"] == "approved"
665
666
def test_list_reviews_denied_for_anon(self, client, sample_project, fossil_repo_obj):
667
"""Anonymous users cannot list reviews."""
668
response = client.get(_api_url(sample_project.slug, "api/reviews"))
669
assert response.status_code == 401
670
671
672
@pytest.mark.django_db
673
class TestCodeReviewDetail:
674
def test_get_review_detail(self, admin_client, sample_project, fossil_repo_obj, admin_user):
675
"""Getting review detail returns the review with comments."""
676
review = CodeReview.objects.create(
677
repository=fossil_repo_obj, title="Fix Auth", diff="--- diff ---", agent_id="claude", created_by=admin_user
678
)
679
ReviewComment.objects.create(review=review, body="LGTM", author="reviewer", created_by=admin_user)
680
681
response = admin_client.get(_api_url(sample_project.slug, f"api/reviews/{review.pk}"))
682
assert response.status_code == 200
683
data = response.json()
684
assert data["title"] == "Fix Auth"
685
assert data["diff"] == "--- diff ---"
686
assert len(data["comments"]) == 1
687
assert data["comments"][0]["body"] == "LGTM"
688
689
def test_get_review_not_found(self, admin_client, sample_project, fossil_repo_obj):
690
"""Getting a non-existent review returns 404."""
691
response = admin_client.get(_api_url(sample_project.slug, "api/reviews/99999"))
692
assert response.status_code == 404
693
694
695
@pytest.mark.django_db
696
class TestCodeReviewComment:
697
def test_add_comment(self, admin_client, sample_project, fossil_repo_obj, admin_user):
698
"""Adding a comment to a review returns 201. Author is derived from auth, not body."""
699
review = CodeReview.objects.create(repository=fossil_repo_obj, title="Fix", diff="d", created_by=admin_user)
700
701
response = admin_client.post(
702
_api_url(sample_project.slug, f"api/reviews/{review.pk}/comment"),
703
data=json.dumps(
704
{
705
"body": "Consider using a guard clause here",
706
"file_path": "src/auth.py",
707
"line_number": 42,
708
"author": "human-reviewer", # ignored — author comes from auth context
709
}
710
),
711
content_type="application/json",
712
)
713
714
assert response.status_code == 201
715
data = response.json()
716
assert data["body"] == "Consider using a guard clause here"
717
assert data["file_path"] == "src/auth.py"
718
assert data["line_number"] == 42
719
assert data["author"] == "admin" # session user's username, not caller-supplied
720
721
# Verify DB
722
assert ReviewComment.objects.filter(review=review).count() == 1
723
724
def test_add_comment_infers_author_from_user(self, admin_client, sample_project, fossil_repo_obj, admin_user):
725
"""When author is omitted, the logged-in username is used."""
726
review = CodeReview.objects.create(repository=fossil_repo_obj, title="Fix", diff="d", created_by=admin_user)
727
728
response = admin_client.post(
729
_api_url(sample_project.slug, f"api/reviews/{review.pk}/comment"),
730
data=json.dumps({"body": "Nice fix"}),
731
content_type="application/json",
732
)
733
734
assert response.status_code == 201
735
assert response.json()["author"] == "admin"
736
737
def test_add_comment_missing_body(self, admin_client, sample_project, fossil_repo_obj, admin_user):
738
"""Adding a comment without body returns 400."""
739
review = CodeReview.objects.create(repository=fossil_repo_obj, title="Fix", diff="d", created_by=admin_user)
740
741
response = admin_client.post(
742
_api_url(sample_project.slug, f"api/reviews/{review.pk}/comment"),
743
data=json.dumps({"author": "someone"}),
744
content_type="application/json",
745
)
746
assert response.status_code == 400
747
748
def test_add_comment_review_not_found(self, admin_client, sample_project, fossil_repo_obj):
749
"""Adding a comment to non-existent review returns 404."""
750
response = admin_client.post(
751
_api_url(sample_project.slug, "api/reviews/99999/comment"),
752
data=json.dumps({"body": "comment", "author": "me"}),
753
content_type="application/json",
754
)
755
assert response.status_code == 404
756
757
758
@pytest.mark.django_db
759
class TestCodeReviewApprove:
760
def test_approve_review(self, admin_client, sample_project, fossil_repo_obj, admin_user):
761
"""Approving a pending review changes status to approved."""
762
review = CodeReview.objects.create(repository=fossil_repo_obj, title="Fix", diff="d", status="pending", created_by=admin_user)
763
764
response = admin_client.post(
765
_api_url(sample_project.slug, f"api/reviews/{review.pk}/approve"),
766
content_type="application/json",
767
)
768
769
assert response.status_code == 200
770
assert response.json()["status"] == "approved"
771
review.refresh_from_db()
772
assert review.status == "approved"
773
774
def test_approve_merged_review_fails(self, admin_client, sample_project, fossil_repo_obj, admin_user):
775
"""Cannot approve an already-merged review."""
776
review = CodeReview.objects.create(repository=fossil_repo_obj, title="Fix", diff="d", status="merged", created_by=admin_user)
777
778
response = admin_client.post(
779
_api_url(sample_project.slug, f"api/reviews/{review.pk}/approve"),
780
content_type="application/json",
781
)
782
assert response.status_code == 409
783
784
def test_approve_denied_for_reader(self, reader_client, sample_project, fossil_repo_obj, admin_user):
785
"""Read-only users cannot approve reviews."""
786
review = CodeReview.objects.create(repository=fossil_repo_obj, title="Fix", diff="d", created_by=admin_user)
787
788
response = reader_client.post(
789
_api_url(sample_project.slug, f"api/reviews/{review.pk}/approve"),
790
content_type="application/json",
791
)
792
assert response.status_code == 403
793
794
795
@pytest.mark.django_db
796
class TestCodeReviewRequestChanges:
797
def test_request_changes(self, admin_client, sample_project, fossil_repo_obj, admin_user):
798
"""Requesting changes updates status and optionally adds a comment."""
799
review = CodeReview.objects.create(repository=fossil_repo_obj, title="Fix", diff="d", status="pending", created_by=admin_user)
800
801
response = admin_client.post(
802
_api_url(sample_project.slug, f"api/reviews/{review.pk}/request-changes"),
803
data=json.dumps({"comment": "Please fix the error handling"}),
804
content_type="application/json",
805
)
806
807
assert response.status_code == 200
808
assert response.json()["status"] == "changes_requested"
809
810
review.refresh_from_db()
811
assert review.status == "changes_requested"
812
# Comment should be added
813
assert review.comments.count() == 1
814
assert review.comments.first().body == "Please fix the error handling"
815
816
def test_request_changes_without_comment(self, admin_client, sample_project, fossil_repo_obj, admin_user):
817
"""Requesting changes without a comment still updates status."""
818
review = CodeReview.objects.create(repository=fossil_repo_obj, title="Fix", diff="d", status="pending", created_by=admin_user)
819
820
response = admin_client.post(
821
_api_url(sample_project.slug, f"api/reviews/{review.pk}/request-changes"),
822
content_type="application/json",
823
)
824
825
assert response.status_code == 200
826
review.refresh_from_db()
827
assert review.status == "changes_requested"
828
assert review.comments.count() == 0
829
830
def test_request_changes_on_merged(self, admin_client, sample_project, fossil_repo_obj, admin_user):
831
"""Cannot request changes on a merged review."""
832
review = CodeReview.objects.create(repository=fossil_repo_obj, title="Fix", diff="d", status="merged", created_by=admin_user)
833
834
response = admin_client.post(
835
_api_url(sample_project.slug, f"api/reviews/{review.pk}/request-changes"),
836
content_type="application/json",
837
)
838
assert response.status_code == 409
839
840
841
@pytest.mark.django_db
842
class TestCodeReviewMerge:
843
def test_merge_approved_review(self, admin_client, sample_project, fossil_repo_obj, admin_user):
844
"""Merging an approved review changes status to merged."""
845
review = CodeReview.objects.create(repository=fossil_repo_obj, title="Fix", diff="d", status="approved", created_by=admin_user)
846
847
response = admin_client.post(
848
_api_url(sample_project.slug, f"api/reviews/{review.pk}/merge"),
849
content_type="application/json",
850
)
851
852
assert response.status_code == 200
853
assert response.json()["status"] == "merged"
854
review.refresh_from_db()
855
assert review.status == "merged"
856
857
def test_merge_unapproved_review_fails(self, admin_client, sample_project, fossil_repo_obj, admin_user):
858
"""Cannot merge a review that isn't approved."""
859
review = CodeReview.objects.create(repository=fossil_repo_obj, title="Fix", diff="d", status="pending", created_by=admin_user)
860
861
response = admin_client.post(
862
_api_url(sample_project.slug, f"api/reviews/{review.pk}/merge"),
863
content_type="application/json",
864
)
865
assert response.status_code == 409
866
assert "approved" in response.json()["error"].lower()
867
868
def test_merge_already_merged_review_fails(self, admin_client, sample_project, fossil_repo_obj, admin_user):
869
"""Cannot merge a review twice."""
870
review = CodeReview.objects.create(repository=fossil_repo_obj, title="Fix", diff="d", status="merged", created_by=admin_user)
871
872
response = admin_client.post(
873
_api_url(sample_project.slug, f"api/reviews/{review.pk}/merge"),
874
content_type="application/json",
875
)
876
assert response.status_code == 409
877
878
def test_merge_updates_linked_ticket_claim(self, admin_client, sample_project, fossil_repo_obj, admin_user):
879
"""Merging a review linked to a ticket updates the claim status."""
880
claim = TicketClaim.objects.create(
881
repository=fossil_repo_obj,
882
ticket_uuid="ticket-999",
883
agent_id="claude-abc",
884
status="submitted",
885
created_by=admin_user,
886
)
887
review = CodeReview.objects.create(
888
repository=fossil_repo_obj,
889
title="Fix for ticket-999",
890
diff="d",
891
status="approved",
892
ticket_uuid="ticket-999",
893
created_by=admin_user,
894
)
895
896
response = admin_client.post(
897
_api_url(sample_project.slug, f"api/reviews/{review.pk}/merge"),
898
content_type="application/json",
899
)
900
901
assert response.status_code == 200
902
claim.refresh_from_db()
903
assert claim.status == "merged"
904
905
def test_merge_denied_for_reader(self, reader_client, sample_project, fossil_repo_obj, admin_user):
906
"""Read-only users cannot merge reviews."""
907
review = CodeReview.objects.create(repository=fossil_repo_obj, title="Fix", diff="d", status="approved", created_by=admin_user)
908
909
response = reader_client.post(
910
_api_url(sample_project.slug, f"api/reviews/{review.pk}/merge"),
911
content_type="application/json",
912
)
913
assert response.status_code == 403
914
915
916
# ===== Model Tests =====
917
918
919
@pytest.mark.django_db
920
class TestTicketClaimModel:
921
def test_str(self, fossil_repo_obj, admin_user):
922
claim = TicketClaim.objects.create(repository=fossil_repo_obj, ticket_uuid="abc123def456", agent_id="claude", created_by=admin_user)
923
s = str(claim)
924
assert "abc123def456" in s
925
assert "claude" in s
926
927
def test_soft_delete(self, fossil_repo_obj, admin_user):
928
claim = TicketClaim.objects.create(repository=fossil_repo_obj, ticket_uuid="abc123def456", agent_id="claude", created_by=admin_user)
929
claim.soft_delete(user=admin_user)
930
assert TicketClaim.objects.filter(pk=claim.pk).count() == 0
931
assert TicketClaim.all_objects.filter(pk=claim.pk).count() == 1
932
933
def test_multiple_claims_allowed_after_soft_delete(self, fossil_repo_obj, admin_user):
934
"""After soft-deleting a claim, a new claim for the same ticket can be created."""
935
claim1 = TicketClaim.objects.create(
936
repository=fossil_repo_obj, ticket_uuid="abc123def456", agent_id="agent-1", created_by=admin_user
937
)
938
claim1.soft_delete(user=admin_user)
939
940
# New claim should succeed since original is soft-deleted
941
claim2 = TicketClaim.objects.create(
942
repository=fossil_repo_obj, ticket_uuid="abc123def456", agent_id="agent-2", created_by=admin_user
943
)
944
assert claim2.agent_id == "agent-2"
945
# Both exist in all_objects
946
assert TicketClaim.all_objects.filter(repository=fossil_repo_obj, ticket_uuid="abc123def456").count() == 2
947
# Only the active one in default manager
948
assert TicketClaim.objects.filter(repository=fossil_repo_obj, ticket_uuid="abc123def456").count() == 1
949
950
951
@pytest.mark.django_db
952
class TestCodeReviewModel:
953
def test_str(self, fossil_repo_obj, admin_user):
954
review = CodeReview.objects.create(repository=fossil_repo_obj, title="Fix Auth", diff="d", created_by=admin_user)
955
s = str(review)
956
assert "Fix Auth" in s
957
assert "pending" in s
958
959
def test_soft_delete(self, fossil_repo_obj, admin_user):
960
review = CodeReview.objects.create(repository=fossil_repo_obj, title="Fix", diff="d", created_by=admin_user)
961
review.soft_delete(user=admin_user)
962
assert CodeReview.objects.filter(pk=review.pk).count() == 0
963
assert CodeReview.all_objects.filter(pk=review.pk).count() == 1
964
965
966
@pytest.mark.django_db
967
class TestReviewCommentModel:
968
def test_str_with_file(self, fossil_repo_obj, admin_user):
969
review = CodeReview.objects.create(repository=fossil_repo_obj, title="Fix", diff="d", created_by=admin_user)
970
comment = ReviewComment.objects.create(
971
review=review, body="fix this", author="reviewer", file_path="src/auth.py", line_number=42, created_by=admin_user
972
)
973
s = str(comment)
974
assert "src/auth.py:42" in s
975
976
def test_str_without_file(self, fossil_repo_obj, admin_user):
977
review = CodeReview.objects.create(repository=fossil_repo_obj, title="Fix", diff="d", created_by=admin_user)
978
comment = ReviewComment.objects.create(review=review, body="looks good", author="reviewer", created_by=admin_user)
979
s = str(comment)
980
assert "general" in s
981

Keyboard Shortcuts

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