Skip to content

Commit bb9f4fa

Browse files
feat: add comprehensive tests for Cycle API endpoints
* Introduced a new test suite for Cycle API endpoints, covering creation, retrieval, updating, and deletion of cycles. * Implemented tests for various scenarios including successful operations, invalid data handling, and conflict resolution with external IDs. * Enhanced test coverage for listing cycles with different view filters and verifying cycle metrics annotations.
1 parent 8638d42 commit bb9f4fa

1 file changed

Lines changed: 382 additions & 0 deletions

File tree

Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
1+
import pytest
2+
from rest_framework import status
3+
from django.db import IntegrityError
4+
from django.utils import timezone
5+
from datetime import datetime, timedelta
6+
from uuid import uuid4
7+
8+
from plane.db.models import Cycle, Project, ProjectMember
9+
10+
11+
@pytest.fixture
12+
def project(db, workspace, create_user):
13+
"""Create a test project with the user as a member"""
14+
project = Project.objects.create(
15+
name="Test Project",
16+
identifier="TP",
17+
workspace=workspace,
18+
created_by=create_user,
19+
)
20+
ProjectMember.objects.create(
21+
project=project,
22+
member=create_user,
23+
role=20, # Admin role
24+
is_active=True,
25+
)
26+
return project
27+
28+
29+
@pytest.fixture
30+
def cycle_data():
31+
"""Sample cycle data for tests"""
32+
return {
33+
"name": "Test Cycle",
34+
"description": "A test cycle for unit tests",
35+
}
36+
37+
38+
@pytest.fixture
39+
def draft_cycle_data():
40+
"""Sample draft cycle data (no dates)"""
41+
return {
42+
"name": "Draft Cycle",
43+
"description": "A draft cycle without dates",
44+
}
45+
46+
47+
@pytest.fixture
48+
def create_cycle(db, project, create_user):
49+
"""Create a test cycle"""
50+
return Cycle.objects.create(
51+
name="Existing Cycle",
52+
description="An existing cycle",
53+
start_date=timezone.now() + timedelta(days=1),
54+
end_date=timezone.now() + timedelta(days=7),
55+
project=project,
56+
workspace=project.workspace,
57+
owned_by=create_user,
58+
)
59+
60+
61+
62+
63+
@pytest.mark.contract
64+
class TestCycleListCreateAPIEndpoint:
65+
"""Test Cycle List and Create API Endpoint"""
66+
67+
def get_cycle_url(self, workspace_slug, project_id):
68+
"""Helper to get cycle endpoint URL"""
69+
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/cycles/"
70+
71+
@pytest.mark.django_db
72+
def test_create_cycle_success(self, api_key_client, workspace, project, cycle_data):
73+
"""Test successful cycle creation"""
74+
url = self.get_cycle_url(workspace.slug, project.id)
75+
76+
response = api_key_client.post(url, cycle_data, format="json")
77+
78+
response.status_code == status.HTTP_201_CREATED
79+
80+
assert Cycle.objects.count() == 1
81+
82+
created_cycle = Cycle.objects.first()
83+
assert created_cycle.name == cycle_data["name"]
84+
assert created_cycle.description == cycle_data["description"]
85+
assert created_cycle.project == project
86+
assert created_cycle.owned_by_id is not None
87+
88+
89+
@pytest.mark.django_db
90+
def test_create_cycle_invalid_data(self, api_key_client, workspace, project):
91+
"""Test cycle creation with invalid data"""
92+
url = self.get_cycle_url(workspace.slug, project.id)
93+
94+
# Test with empty data
95+
response = api_key_client.post(url, {}, format="json")
96+
assert response.status_code == status.HTTP_400_BAD_REQUEST
97+
98+
# Test with missing name
99+
response = api_key_client.post(url, {"description": "Test cycle"}, format="json")
100+
assert response.status_code == status.HTTP_400_BAD_REQUEST
101+
102+
@pytest.mark.django_db
103+
def test_create_cycle_invalid_date_combination(self, api_key_client, workspace, project):
104+
"""Test cycle creation with invalid date combination (only start_date)"""
105+
url = self.get_cycle_url(workspace.slug, project.id)
106+
107+
invalid_data = {
108+
"name": "Invalid Cycle",
109+
"start_date": (timezone.now() + timedelta(days=1)).isoformat(),
110+
# Missing end_date
111+
}
112+
113+
response = api_key_client.post(url, invalid_data, format="json")
114+
assert response.status_code == status.HTTP_400_BAD_REQUEST
115+
assert "Both start date and end date are either required or are to be null" in response.data["error"]
116+
117+
@pytest.mark.django_db
118+
def test_create_cycle_with_external_id(self, api_key_client, workspace, project):
119+
"""Test creating cycle with external ID"""
120+
url = self.get_cycle_url(workspace.slug, project.id)
121+
122+
cycle_data = {
123+
"name": "External Cycle",
124+
"description": "A cycle with external ID",
125+
"external_id": "ext-123",
126+
"external_source": "github",
127+
}
128+
129+
response = api_key_client.post(url, cycle_data, format="json")
130+
131+
assert response.status_code == status.HTTP_201_CREATED
132+
created_cycle = Cycle.objects.first()
133+
assert created_cycle.external_id == "ext-123"
134+
assert created_cycle.external_source == "github"
135+
136+
@pytest.mark.django_db
137+
def test_create_cycle_duplicate_external_id(self, api_key_client, workspace, project, create_user):
138+
"""Test creating cycle with duplicate external ID"""
139+
url = self.get_cycle_url(workspace.slug, project.id)
140+
141+
# Create first cycle
142+
Cycle.objects.create(
143+
name="First Cycle",
144+
project=project,
145+
workspace=workspace,
146+
external_id="ext-123",
147+
external_source="github",
148+
owned_by=create_user,
149+
)
150+
151+
# Try to create second cycle with same external ID
152+
cycle_data = {
153+
"name": "Second Cycle",
154+
"external_id": "ext-123",
155+
"external_source": "github",
156+
"owned_by": create_user.id,
157+
}
158+
159+
response = api_key_client.post(url, cycle_data, format="json")
160+
161+
assert response.status_code == status.HTTP_409_CONFLICT
162+
assert "same external id" in response.data["error"]
163+
164+
@pytest.mark.django_db
165+
def test_list_cycles_success(self, api_key_client, workspace, project, create_cycle, create_user):
166+
"""Test successful cycle listing"""
167+
url = self.get_cycle_url(workspace.slug, project.id)
168+
169+
# Create additional cycles
170+
Cycle.objects.create(
171+
name="Cycle 2",
172+
project=project,
173+
workspace=workspace,
174+
start_date=timezone.now() + timedelta(days=10),
175+
end_date=timezone.now() + timedelta(days=17),
176+
owned_by=create_user,
177+
)
178+
Cycle.objects.create(
179+
name="Cycle 3",
180+
project=project,
181+
workspace=workspace,
182+
start_date=timezone.now() + timedelta(days=20),
183+
end_date=timezone.now() + timedelta(days=27),
184+
owned_by=create_user,
185+
)
186+
187+
response = api_key_client.get(url)
188+
189+
assert response.status_code == status.HTTP_200_OK
190+
assert "results" in response.data
191+
assert len(response.data["results"]) == 3 # Including create_cycle fixture
192+
193+
@pytest.mark.django_db
194+
def test_list_cycles_with_view_filter(self, api_key_client, workspace, project, create_user):
195+
"""Test cycle listing with different view filters"""
196+
url = self.get_cycle_url(workspace.slug, project.id)
197+
198+
# Create cycles in different states
199+
now = timezone.now()
200+
201+
# Current cycle (started but not ended)
202+
Cycle.objects.create(
203+
name="Current Cycle",
204+
project=project,
205+
workspace=workspace,
206+
start_date=now - timedelta(days=1),
207+
end_date=now + timedelta(days=6),
208+
owned_by=create_user,
209+
)
210+
211+
# Upcoming cycle
212+
Cycle.objects.create(
213+
name="Upcoming Cycle",
214+
project=project,
215+
workspace=workspace,
216+
start_date=now + timedelta(days=1),
217+
end_date=now + timedelta(days=8),
218+
owned_by=create_user,
219+
)
220+
221+
# Completed cycle
222+
Cycle.objects.create(
223+
name="Completed Cycle",
224+
project=project,
225+
workspace=workspace,
226+
start_date=now - timedelta(days=10),
227+
end_date=now - timedelta(days=3),
228+
owned_by=create_user,
229+
)
230+
231+
# Draft cycle
232+
Cycle.objects.create(
233+
name="Draft Cycle",
234+
project=project,
235+
workspace=workspace,
236+
owned_by=create_user,
237+
)
238+
239+
# Test current cycles
240+
response = api_key_client.get(url, {"cycle_view": "current"})
241+
assert response.status_code == status.HTTP_200_OK
242+
assert len(response.data) == 1
243+
assert response.data[0]["name"] == "Current Cycle"
244+
245+
# Test upcoming cycles
246+
response = api_key_client.get(url, {"cycle_view": "upcoming"})
247+
assert response.status_code == status.HTTP_200_OK
248+
assert len(response.data["results"]) == 1
249+
assert response.data["results"][0]["name"] == "Upcoming Cycle"
250+
251+
# Test completed cycles
252+
response = api_key_client.get(url, {"cycle_view": "completed"})
253+
assert response.status_code == status.HTTP_200_OK
254+
assert len(response.data["results"]) == 1
255+
assert response.data["results"][0]["name"] == "Completed Cycle"
256+
257+
# Test draft cycles
258+
response = api_key_client.get(url, {"cycle_view": "draft"})
259+
assert response.status_code == status.HTTP_200_OK
260+
assert len(response.data["results"]) == 1
261+
assert response.data["results"][0]["name"] == "Draft Cycle"
262+
263+
264+
@pytest.mark.contract
265+
class TestCycleDetailAPIEndpoint:
266+
"""Test Cycle Detail API Endpoint"""
267+
268+
def get_cycle_detail_url(self, workspace_slug, project_id, cycle_id):
269+
"""Helper to get cycle detail endpoint URL"""
270+
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/"
271+
272+
@pytest.mark.django_db
273+
def test_get_cycle_success(self, api_key_client, workspace, project, create_cycle):
274+
"""Test successful cycle retrieval"""
275+
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
276+
277+
response = api_key_client.get(url)
278+
279+
assert response.status_code == status.HTTP_200_OK
280+
assert str(response.data["id"]) == str(create_cycle.id)
281+
assert response.data["name"] == create_cycle.name
282+
assert response.data["description"] == create_cycle.description
283+
284+
@pytest.mark.django_db
285+
def test_get_cycle_not_found(self, api_key_client, workspace, project):
286+
"""Test getting non-existent cycle"""
287+
fake_id = uuid4()
288+
url = self.get_cycle_detail_url(workspace.slug, project.id, fake_id)
289+
290+
response = api_key_client.get(url)
291+
assert response.status_code == status.HTTP_404_NOT_FOUND
292+
293+
@pytest.mark.django_db
294+
def test_update_cycle_success(self, api_key_client, workspace, project, create_cycle):
295+
"""Test successful cycle update"""
296+
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
297+
298+
update_data = {
299+
"name": f"Updated Cycle {uuid4()}",
300+
"description": "Updated description",
301+
}
302+
303+
response = api_key_client.patch(url, update_data, format="json")
304+
305+
assert response.status_code == status.HTTP_200_OK
306+
307+
create_cycle.refresh_from_db()
308+
assert create_cycle.name == update_data["name"]
309+
assert create_cycle.description == update_data["description"]
310+
311+
@pytest.mark.django_db
312+
def test_update_cycle_invalid_data(self, api_key_client, workspace, project, create_cycle):
313+
"""Test cycle update with invalid data"""
314+
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
315+
316+
update_data = {"name": ""}
317+
response = api_key_client.patch(url, update_data, format="json")
318+
319+
# This might be 400 if name is required, or 200 if empty names are allowed
320+
assert response.status_code in [status.HTTP_400_BAD_REQUEST, status.HTTP_200_OK]
321+
322+
@pytest.mark.django_db
323+
def test_update_cycle_with_external_id_conflict(self, api_key_client, workspace, project, create_cycle, create_user ):
324+
"""Test cycle update with conflicting external ID"""
325+
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
326+
327+
# Create another cycle with external ID
328+
Cycle.objects.create(
329+
name="Another Cycle",
330+
project=project,
331+
workspace=workspace,
332+
external_id="ext-456",
333+
external_source="github",
334+
owned_by=create_user,
335+
)
336+
337+
# Try to update cycle with same external ID
338+
update_data = {
339+
"external_id": "ext-456",
340+
"external_source": "github",
341+
}
342+
343+
response = api_key_client.patch(url, update_data, format="json")
344+
345+
assert response.status_code == status.HTTP_409_CONFLICT
346+
assert "same external id" in response.data["error"]
347+
348+
@pytest.mark.django_db
349+
def test_delete_cycle_success(self, api_key_client, workspace, project, create_cycle):
350+
"""Test successful cycle deletion"""
351+
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
352+
353+
response = api_key_client.delete(url)
354+
355+
assert response.status_code == status.HTTP_204_NO_CONTENT
356+
assert not Cycle.objects.filter(id=create_cycle.id).exists()
357+
358+
@pytest.mark.django_db
359+
def test_cycle_metrics_annotation(self, api_key_client, workspace, project, create_cycle):
360+
"""Test that cycle includes issue metrics annotations"""
361+
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
362+
363+
response = api_key_client.get(url)
364+
365+
assert response.status_code == status.HTTP_200_OK
366+
367+
# Check that metrics are included in response
368+
cycle_data = response.data
369+
assert "total_issues" in cycle_data
370+
assert "completed_issues" in cycle_data
371+
assert "cancelled_issues" in cycle_data
372+
assert "started_issues" in cycle_data
373+
assert "unstarted_issues" in cycle_data
374+
assert "backlog_issues" in cycle_data
375+
376+
# All should be 0 for a new cycle
377+
assert cycle_data["total_issues"] == 0
378+
assert cycle_data["completed_issues"] == 0
379+
assert cycle_data["cancelled_issues"] == 0
380+
assert cycle_data["started_issues"] == 0
381+
assert cycle_data["unstarted_issues"] == 0
382+
assert cycle_data["backlog_issues"] == 0

0 commit comments

Comments
 (0)