FossilRepo

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

Keyboard Shortcuts

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