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