lucius-mcp 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. lucius_mcp-0.4.0.dist-info/METADATA +124 -0
  2. {lucius_mcp-0.3.0.dist-info → lucius_mcp-0.4.0.dist-info}/RECORD +38 -29
  3. src/client/__init__.py +6 -0
  4. src/client/client.py +271 -7
  5. src/client/exceptions.py +23 -0
  6. src/client/generated/README.md +18 -0
  7. src/client/generated/__init__.py +2 -0
  8. src/client/generated/api/__init__.py +1 -0
  9. src/client/generated/api/integration_controller_api.py +5285 -0
  10. src/client/generated/docs/IntegrationControllerApi.md +1224 -0
  11. src/services/__init__.py +9 -1
  12. src/services/custom_field_value_service.py +301 -0
  13. src/services/integration_service.py +205 -0
  14. src/services/launch_service.py +29 -1
  15. src/services/shared_step_service.py +34 -17
  16. src/services/test_case_service.py +269 -37
  17. src/services/test_layer_service.py +11 -0
  18. src/tools/__init__.py +19 -1
  19. src/tools/create_custom_field_value.py +38 -0
  20. src/tools/create_test_case.py +32 -2
  21. src/tools/delete_custom_field_value.py +57 -0
  22. src/tools/delete_test_case.py +5 -4
  23. src/tools/delete_test_layer.py +17 -4
  24. src/tools/delete_test_layer_schema.py +17 -4
  25. src/tools/launches.py +86 -0
  26. src/tools/link_shared_step.py +18 -12
  27. src/tools/list_custom_field_values.py +72 -0
  28. src/tools/list_integrations.py +77 -0
  29. src/tools/search.py +3 -3
  30. src/tools/shared_steps.py +23 -8
  31. src/tools/unlink_shared_step.py +19 -5
  32. src/tools/update_custom_field_value.py +55 -0
  33. src/tools/update_test_case.py +67 -2
  34. src/tools/update_test_layer.py +15 -4
  35. src/tools/update_test_layer_schema.py +16 -5
  36. lucius_mcp-0.3.0.dist-info/METADATA +0 -297
  37. {lucius_mcp-0.3.0.dist-info → lucius_mcp-0.4.0.dist-info}/WHEEL +0 -0
  38. {lucius_mcp-0.3.0.dist-info → lucius_mcp-0.4.0.dist-info}/entry_points.txt +0 -0
  39. {lucius_mcp-0.3.0.dist-info → lucius_mcp-0.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,6 @@
1
1
  import base64
2
2
  import logging
3
- from typing import Any
3
+ from typing import cast
4
4
 
5
5
  from pydantic import ValidationError as PydanticValidationError
6
6
 
@@ -8,10 +8,11 @@ from src.client import (
8
8
  AllureClient,
9
9
  PageSharedStepDto,
10
10
  ScenarioStepCreateDto,
11
+ ScenarioStepPatchDto,
11
12
  SharedStepAttachmentRowDto,
12
13
  SharedStepDto,
13
14
  )
14
- from src.client.exceptions import AllureAPIError, AllureValidationError
15
+ from src.client.exceptions import AllureAPIError, AllureNotFoundError, AllureValidationError
15
16
  from src.services.attachment_service import AttachmentService
16
17
  from src.utils.schema_hint import generate_schema_hint
17
18
 
@@ -37,7 +38,7 @@ class SharedStepService:
37
38
  async def create_shared_step(
38
39
  self,
39
40
  name: str,
40
- steps: list[dict[str, Any]] | None = None,
41
+ steps: list[dict[str, object]] | None = None,
41
42
  ) -> SharedStepDto:
42
43
  """Create a new shared step with optional steps.
43
44
 
@@ -125,7 +126,12 @@ class SharedStepService:
125
126
  AllureNotFoundError: If shared step doesn't exist.
126
127
  AllureAPIError: If the server returns an error.
127
128
  """
128
- return await self._client.get_shared_step(step_id)
129
+ try:
130
+ return await self._client.get_shared_step(step_id)
131
+ except (AllureNotFoundError, AllureValidationError, AllureAPIError):
132
+ raise
133
+ except Exception as e:
134
+ raise AllureAPIError(f"Failed to get shared step {step_id}: {e}") from e
129
135
 
130
136
  async def update_shared_step(
131
137
  self,
@@ -176,27 +182,31 @@ class SharedStepService:
176
182
  updated = await self._client.update_shared_step(step_id, patch_data)
177
183
  return updated, True
178
184
 
179
- async def delete_shared_step(self, step_id: int) -> None:
185
+ async def delete_shared_step(self, step_id: int) -> bool:
180
186
  """Delete (archive) a shared step with idempotent behavior.
181
187
 
182
188
  Args:
183
189
  step_id: The shared step ID to delete.
184
190
 
191
+ Returns:
192
+ True if deleted, False if already deleted/not found.
193
+
185
194
  Raises:
186
195
  AllureAPIError: If the API request fails (other than 404).
187
196
 
188
197
  Note:
189
198
  This operation is idempotent following Story 1.5 pattern.
190
- If already deleted (404), returns gracefully.
199
+ If already deleted (404), returns False.
191
200
  """
192
- from src.client.exceptions import AllureNotFoundError
193
-
194
201
  try:
202
+ # Check existence first for accurate feedback
203
+ await self.get_shared_step(step_id)
195
204
  await self._client.delete_shared_step(step_id)
205
+ return True
196
206
  except AllureNotFoundError:
197
207
  # Idempotent: if already deleted, this is fine
198
208
  logger.debug(f"Shared step {step_id} already deleted or not found")
199
- pass
209
+ return False
200
210
 
201
211
  # ==========================================
202
212
  # Helper Methods
@@ -205,7 +215,7 @@ class SharedStepService:
205
215
  async def _add_steps(
206
216
  self,
207
217
  shared_step_id: int,
208
- steps: list[dict[str, Any]],
218
+ steps: list[dict[str, object]],
209
219
  parent_step_id: int | None = None,
210
220
  ) -> int | None:
211
221
  """Recursively add steps to a shared step."""
@@ -214,7 +224,7 @@ class SharedStepService:
214
224
  for s in steps:
215
225
  action = str(s.get("action", ""))
216
226
  expected = str(s.get("expected", ""))
217
- step_attachments: list[dict[str, str]] = s.get("attachments", [])
227
+ step_attachments = cast(list[dict[str, str]], s.get("attachments", []))
218
228
 
219
229
  current_parent_id: int | None = parent_step_id
220
230
 
@@ -226,12 +236,19 @@ class SharedStepService:
226
236
  response = await self._client.create_shared_step_scenario_step(step_dto)
227
237
  current_parent_id = response.created_step_id
228
238
 
229
- # 2. Expected Result (child of action)
230
- if expected:
231
- expected_dto = self._build_scenario_step_dto(
232
- shared_step_id=shared_step_id, body=expected, parent_id=current_parent_id
239
+ # 2. Expected Result (patch action step)
240
+ if expected and current_parent_id is not None:
241
+ await self._client.patch_shared_step_scenario_step(
242
+ step_id=current_parent_id,
243
+ patch=ScenarioStepPatchDto(body=action, expected_result=expected),
244
+ )
245
+ await self._client.create_shared_step_scenario_step(
246
+ self._build_scenario_step_dto(
247
+ shared_step_id=shared_step_id,
248
+ body=expected,
249
+ parent_id=current_parent_id,
250
+ )
233
251
  )
234
- await self._client.create_shared_step_scenario_step(expected_dto)
235
252
 
236
253
  # 3. Attachments (children of action)
237
254
  if step_attachments:
@@ -317,7 +334,7 @@ class SharedStepService:
317
334
  if len(name) > MAX_NAME_LENGTH:
318
335
  raise AllureValidationError(f"Name too long (max {MAX_NAME_LENGTH})")
319
336
 
320
- def _validate_steps(self, steps: list[dict[str, Any]] | None) -> None:
337
+ def _validate_steps(self, steps: list[dict[str, object]] | None) -> None:
321
338
  if steps is None:
322
339
  return
323
340
  if not isinstance(steps, list):
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ import re
2
3
  from dataclasses import dataclass
3
4
  from typing import TypedDict, cast
4
5
 
@@ -7,7 +8,9 @@ from pydantic import ValidationError as PydanticValidationError
7
8
  from src.client import (
8
9
  AllureClient,
9
10
  AttachmentStepDtoWithName,
10
- BodyStepDtoWithSteps,
11
+ ScenarioStepPatchDto,
12
+ StepWithExpected,
13
+ TestCaseDtoWithCF,
11
14
  )
12
15
  from src.client.exceptions import AllureAPIError, AllureNotFoundError, AllureValidationError
13
16
  from src.client.generated.models import (
@@ -15,11 +18,14 @@ from src.client.generated.models import (
15
18
  CustomFieldValueDto,
16
19
  CustomFieldValueWithCfDto,
17
20
  ExternalLinkDto,
21
+ IssueDto,
18
22
  ScenarioStepCreateDto,
19
23
  SharedStepScenarioDtoStepsInner,
24
+ TestCaseBulkIssueDto,
20
25
  TestCaseCreateV2Dto,
21
26
  TestCaseDto,
22
27
  TestCaseOverviewDto,
28
+ TestCaseTreeSelectionDto,
23
29
  TestLayerDto,
24
30
  TestTagDto,
25
31
  )
@@ -72,8 +78,14 @@ class TestCaseUpdate:
72
78
  expected_result: str | None = None
73
79
  status_id: int | None = None
74
80
  test_layer_id: int | None = None
81
+ test_layer_name: str | None = None
75
82
  workflow_id: int | None = None
76
83
  links: list[dict[str, str]] | None = None
84
+ issues: list[str] | None = None
85
+ remove_issues: list[str] | None = None
86
+ clear_issues: bool | None = None
87
+ integration_id: int | None = None
88
+ integration_name: str | None = None
77
89
 
78
90
 
79
91
  @dataclass
@@ -112,6 +124,9 @@ class TestCaseService:
112
124
  custom_fields: dict[str, str | list[str]] | None = None,
113
125
  test_layer_id: int | None = None,
114
126
  test_layer_name: str | None = None,
127
+ issues: list[str] | None = None,
128
+ integration_id: int | None = None,
129
+ integration_name: str | None = None,
115
130
  ) -> TestCaseOverviewDto:
116
131
  """Create a new test case.
117
132
 
@@ -124,6 +139,9 @@ class TestCaseService:
124
139
  custom_fields: Optional dictionary of custom fields (Name -> Value).
125
140
  test_layer_id: Optional test layer ID to assign to the test case.
126
141
  test_layer_name: Optional test layer name to assign to the test case.
142
+ issues: Optional list of issue keys to link (e.g., 'PROJ-123').
143
+ integration_id: Optional integration ID for issue linking (required when multiple exist).
144
+ integration_name: Optional integration name for issue linking (mutually exclusive with integration_id).
127
145
 
128
146
  Returns:
129
147
  The created test case overview.
@@ -232,6 +250,15 @@ class TestCaseService:
232
250
 
233
251
  # Add global attachments (appended at end of steps)
234
252
  await self._add_global_attachments(test_case_id, attachments, last_step_id)
253
+
254
+ # Add issue links
255
+ if issues:
256
+ await self.add_issues_to_test_case(
257
+ test_case_id,
258
+ issues,
259
+ integration_id=integration_id,
260
+ integration_name=integration_name,
261
+ )
235
262
  except Exception as e:
236
263
  # Rollback: delete the partially created test case
237
264
  try:
@@ -248,7 +275,7 @@ class TestCaseService:
248
275
 
249
276
  return created_test_case
250
277
 
251
- async def get_test_case(self, test_case_id: int) -> TestCaseDto:
278
+ async def get_test_case(self, test_case_id: int) -> TestCaseDtoWithCF:
252
279
  """Retrieve a test case by ID.
253
280
 
254
281
  Args:
@@ -345,6 +372,9 @@ class TestCaseService:
345
372
  data.test_layer_id is not None,
346
373
  data.workflow_id is not None,
347
374
  data.links is not None,
375
+ data.issues is not None,
376
+ data.remove_issues is not None,
377
+ data.clear_issues is not None,
348
378
  ]
349
379
  )
350
380
  has_scenario_changes = data.steps is not None or data.attachments is not None
@@ -358,16 +388,54 @@ class TestCaseService:
358
388
  updated_case = await self.get_test_case(test_case_id)
359
389
  return updated_case if updated_case is not None else current_case
360
390
 
361
- if data.test_layer_id is not None:
362
- self._validate_test_layer_id(data.test_layer_id)
363
- current_test_layer_id = current_case.test_layer.id if current_case.test_layer else None
364
- if data.test_layer_id != current_test_layer_id:
365
- await self._validate_test_layer_exists(data.test_layer_id, project_id)
391
+ if data.clear_issues:
392
+ # Use current_case fetched at step 1 instead of re-fetching
393
+ if current_case and current_case.issues:
394
+ existing_keys = [i.name for i in current_case.issues if i.name]
395
+ if existing_keys:
396
+ await self.remove_issues_from_test_case(test_case_id, existing_keys)
397
+
398
+ if data.remove_issues:
399
+ await self.remove_issues_from_test_case(test_case_id, data.remove_issues)
400
+
401
+ if data.issues:
402
+ await self.add_issues_to_test_case(
403
+ test_case_id,
404
+ data.issues,
405
+ integration_id=data.integration_id,
406
+ integration_name=data.integration_name,
407
+ )
366
408
 
367
- # 3. Handle Metadata Patches
409
+ # 4. Handle Metadata Patches
368
410
  patch_kwargs, has_changes = await self._prepare_field_updates(current_case, data)
369
411
 
370
- # 4. Handle Custom Fields in mixed update (replacement)
412
+ if data.test_layer_id is not None or data.test_layer_name is not None:
413
+ current_test_layer_id = current_case.test_layer.id if current_case.test_layer else None
414
+ resolved_layer_id = None
415
+ skip_validation = False
416
+
417
+ if data.test_layer_id is not None and data.test_layer_name is None:
418
+ if data.test_layer_id == current_test_layer_id:
419
+ skip_validation = True
420
+ resolved_layer_id = data.test_layer_id
421
+
422
+ if not skip_validation:
423
+ # Resolve the ID using the validation helper
424
+ # Note: _validate_test_layer handles mutual exclusivity check
425
+ resolved_layer_id = await self._validate_test_layer(
426
+ test_layer_id=data.test_layer_id,
427
+ test_layer_name=data.test_layer_name,
428
+ )
429
+
430
+ current_test_layer_id = current_case.test_layer.id if current_case.test_layer else None
431
+
432
+ # Only update if the resolved ID is different (and not None)
433
+ if resolved_layer_id is not None and resolved_layer_id != current_test_layer_id:
434
+ # Add to patch_kwargs which is now initialized
435
+ patch_kwargs["test_layer_id"] = resolved_layer_id
436
+ has_changes = True
437
+
438
+ # 5. Handle Custom Fields in mixed update (replacement)
371
439
  if data.custom_fields is not None:
372
440
  # For mixed updates via patch13, we need to provide the FULL list of custom fields
373
441
  # since the API replaces them. We merge current + updates.
@@ -422,7 +490,7 @@ class TestCaseService:
422
490
  patch_kwargs["customFields"] = final_cfs
423
491
  has_changes = True
424
492
 
425
- # 5. Handle Scenario
493
+ # 6. Handle Scenario
426
494
  scenario_dto_v2 = await self._prepare_scenario_update(test_case_id, data)
427
495
  if scenario_dto_v2:
428
496
  has_changes = True
@@ -430,7 +498,7 @@ class TestCaseService:
430
498
  if not has_changes:
431
499
  return current_case
432
500
 
433
- # 6. Apply Update
501
+ # 7. Apply Update
434
502
  try:
435
503
  patch_data = TestCasePatchV2Dto(**patch_kwargs)
436
504
  await self._client.update_test_case(test_case_id, patch_data)
@@ -438,7 +506,7 @@ class TestCaseService:
438
506
  hint = generate_schema_hint(TestCasePatchV2Dto)
439
507
  raise AllureValidationError(f"Invalid update data: {e}", suggestions=[hint]) from e
440
508
 
441
- # 7. Apply Scenario Re-creation
509
+ # 8. Apply Scenario Re-creation
442
510
  if scenario_dto_v2 and scenario_dto_v2.steps is not None:
443
511
  await self._recreate_scenario(test_case_id, scenario_dto_v2.steps)
444
512
 
@@ -572,6 +640,139 @@ class TestCaseService:
572
640
 
573
641
  return await self.get_test_case(test_case_id)
574
642
 
643
+ async def add_issues_to_test_case(
644
+ self,
645
+ test_case_id: int,
646
+ issues: list[str],
647
+ integration_id: int | None = None,
648
+ integration_name: str | None = None,
649
+ ) -> None:
650
+ """Add issue links to a test case.
651
+
652
+ Args:
653
+ test_case_id: The test case ID.
654
+ issues: List of issue keys (e.g. "PROJ-123").
655
+ integration_id: Optional integration ID (required when multiple exist).
656
+ integration_name: Optional integration name (mutually exclusive with integration_id).
657
+ """
658
+ if not issues:
659
+ return
660
+
661
+ # 1. Resolve integrations and build IssueDtos
662
+ issue_dtos = await self._build_issue_dtos(
663
+ issues,
664
+ integration_id=integration_id,
665
+ integration_name=integration_name,
666
+ )
667
+
668
+ # Idempotency Filter: Only link issues not already present
669
+ current_case = await self.get_test_case(test_case_id)
670
+ existing_names = {i.name.upper() for i in (current_case.issues or []) if i.name}
671
+ issue_dtos = [io for io in issue_dtos if io.name not in existing_names]
672
+
673
+ if not issue_dtos:
674
+ return
675
+
676
+ # 2. Build selection
677
+ selection = TestCaseTreeSelectionDto(project_id=self._project_id, leafs_include=[test_case_id])
678
+
679
+ # 3. Call Bulk API
680
+ from src.client.generated.api.test_case_bulk_controller_api import TestCaseBulkControllerApi
681
+
682
+ bulk_api = TestCaseBulkControllerApi(self._client.api_client)
683
+
684
+ bulk_dto = TestCaseBulkIssueDto(issues=issue_dtos, selection=selection)
685
+ await bulk_api.issue_add1(bulk_dto)
686
+
687
+ async def remove_issues_from_test_case(self, test_case_id: int, issues: list[str]) -> None:
688
+ """Remove issue links from a test case.
689
+
690
+ Args:
691
+ test_case_id: The test case ID.
692
+ issues: List of issue keys to remove.
693
+ """
694
+ if not issues:
695
+ return
696
+
697
+ # 1. Fetch current issues to get their IDs
698
+ current_case = await self.get_test_case(test_case_id)
699
+ if not current_case.issues:
700
+ return
701
+
702
+ # 2. Find IDs of issues to remove
703
+ issues_to_remove_ids = []
704
+ issues_set = set(issues)
705
+
706
+ for issue in current_case.issues:
707
+ if issue.name and issue.name in issues_set and issue.id:
708
+ issues_to_remove_ids.append(issue.id)
709
+
710
+ if not issues_to_remove_ids:
711
+ return
712
+
713
+ # 3. Call Bulk API with IDs
714
+ selection = TestCaseTreeSelectionDto(project_id=self._project_id, leafs_include=[test_case_id])
715
+
716
+ from src.client.generated.api.test_case_bulk_controller_api import TestCaseBulkControllerApi
717
+ from src.client.generated.models.test_case_bulk_entity_ids_dto import TestCaseBulkEntityIdsDto
718
+
719
+ bulk_api = TestCaseBulkControllerApi(self._client.api_client)
720
+
721
+ bulk_dto = TestCaseBulkEntityIdsDto(ids=issues_to_remove_ids, selection=selection)
722
+ await bulk_api.issue_remove1(bulk_dto)
723
+
724
+ MAX_ISSUE_KEY_LENGTH = 50
725
+ ISSUE_KEY_PATTERN = re.compile(r"^[A-Z0-9]+-\d+$", re.IGNORECASE)
726
+
727
+ async def _build_issue_dtos(
728
+ self,
729
+ issues: list[str],
730
+ integration_id: int | None = None,
731
+ integration_name: str | None = None,
732
+ ) -> list[IssueDto]:
733
+ """Convert issue keys to IssueDto objects with resolved integration IDs.
734
+
735
+ Validation:
736
+ - Checks issue key format (e.g., PROJ-123).
737
+ - Resolves integration ID using IntegrationService.
738
+ - AC#6: Auto-selects when only one integration exists.
739
+ - AC#7: Errors when multiple integrations exist and none specified.
740
+ """
741
+ if not issues:
742
+ return []
743
+
744
+ # 1. Validate Issue Keys
745
+ invalid_keys = []
746
+ for key in issues:
747
+ if not key or len(key) > self.MAX_ISSUE_KEY_LENGTH:
748
+ invalid_keys.append(f"{key} (too long)")
749
+ elif not self.ISSUE_KEY_PATTERN.match(key):
750
+ invalid_keys.append(f"{key} (invalid format, expected PROJECT-123)")
751
+
752
+ if invalid_keys:
753
+ raise AllureValidationError(
754
+ "Invalid issue keys provided:\n"
755
+ + "\n".join(f"- {k}" for k in invalid_keys)
756
+ + "\n\nHint: Issue keys must follow the format 'PROJECT-123' (alphanumeric prefix, hyphen, number)."
757
+ )
758
+
759
+ # 2. Resolve Integration using IntegrationService
760
+ from src.services.integration_service import IntegrationService
761
+
762
+ integration_service = IntegrationService(self._client)
763
+ target_integration_id = await integration_service.resolve_integration_for_issues(
764
+ integration_id=integration_id,
765
+ integration_name=integration_name,
766
+ )
767
+
768
+ # 3. Build DTOs
769
+ dtos = []
770
+ for key in issues:
771
+ dto = IssueDto(name=key.upper(), integration_id=target_integration_id) # Normalize to uppercase
772
+ dtos.append(dto)
773
+
774
+ return dtos
775
+
575
776
  async def _prepare_field_updates( # noqa: C901
576
777
  self, current_case: TestCaseDto, data: TestCaseUpdate
577
778
  ) -> tuple[dict[str, object], bool]:
@@ -605,11 +806,8 @@ class TestCaseService:
605
806
  patch_kwargs["status_id"] = data.status_id
606
807
  has_changes = True
607
808
 
608
- if data.test_layer_id is not None:
609
- current_test_layer_id = current_case.test_layer.id if current_case.test_layer else None
610
- if data.test_layer_id != current_test_layer_id:
611
- patch_kwargs["test_layer_id"] = data.test_layer_id
612
- has_changes = True
809
+ # test_layer_id is handled centrally in update_test_case logic due to name resolution complexity
810
+ # and validation requirements. We skip it here to avoid duplication.
613
811
 
614
812
  if data.workflow_id is not None:
615
813
  current_workflow_id = current_case.workflow.id if current_case.workflow else None
@@ -721,13 +919,6 @@ class TestCaseService:
721
919
 
722
920
  children: list[SharedStepScenarioDtoStepsInner] = []
723
921
 
724
- if expected:
725
- children.append(
726
- SharedStepScenarioDtoStepsInner(
727
- actual_instance=ExpectedBodyStepDto(type="ExpectedBodyStepDto", body=expected)
728
- )
729
- )
730
-
731
922
  if step_attachments and isinstance(step_attachments, list):
732
923
  for sa in step_attachments:
733
924
  if isinstance(sa, dict):
@@ -748,7 +939,12 @@ class TestCaseService:
748
939
 
749
940
  dtos.append(
750
941
  SharedStepScenarioDtoStepsInner(
751
- actual_instance=BodyStepDtoWithSteps(type="BodyStepDto", body=action, steps=children)
942
+ actual_instance=StepWithExpected(
943
+ type="BodyStepDto",
944
+ body=action,
945
+ expected_result=expected or None,
946
+ steps=children,
947
+ )
752
948
  )
753
949
  )
754
950
  return dtos
@@ -892,8 +1088,8 @@ class TestCaseService:
892
1088
  """Validate test layer ID format."""
893
1089
  if test_layer_id is None:
894
1090
  return
895
- if not isinstance(test_layer_id, int) or test_layer_id <= 0:
896
- raise AllureValidationError("Test layer ID must be a positive integer")
1091
+ if not isinstance(test_layer_id, int) or test_layer_id < 0:
1092
+ raise AllureValidationError("Test layer ID must be a positive integer or 0 (to unset)")
897
1093
 
898
1094
  def _format_available_layers(self, layers: list[TestLayerDto]) -> str:
899
1095
  display_lines: list[str] = []
@@ -940,6 +1136,9 @@ class TestCaseService:
940
1136
 
941
1137
  if test_layer_id is not None:
942
1138
  self._validate_test_layer_id(test_layer_id)
1139
+ if test_layer_id == 0:
1140
+ return 0
1141
+
943
1142
  try:
944
1143
  await self._test_layer_service.get_test_layer(test_layer_id)
945
1144
  except AllureNotFoundError as e:
@@ -1019,7 +1218,15 @@ class TestCaseService:
1019
1218
  """Get or fetch custom field name-to-info mapping for a project."""
1020
1219
  if project_id in self._cf_cache:
1021
1220
  return self._cf_cache[project_id]
1221
+ return await self._fetch_resolved_custom_fields(project_id)
1022
1222
 
1223
+ async def refresh_resolved_custom_fields(self, project_id: int) -> dict[str, ResolvedCustomFieldInfo]:
1224
+ """Refresh cached custom field name-to-info mapping for a project."""
1225
+ if project_id in self._cf_cache:
1226
+ del self._cf_cache[project_id]
1227
+ return await self._fetch_resolved_custom_fields(project_id)
1228
+
1229
+ async def _fetch_resolved_custom_fields(self, project_id: int) -> dict[str, ResolvedCustomFieldInfo]:
1023
1230
  # Use the client wrapper method for consistent error handling and response processing
1024
1231
  cfs = await self._client.get_custom_fields_with_values(project_id)
1025
1232
  logger.debug("Fetched %d custom fields for project %d", len(cfs), project_id)
@@ -1277,19 +1484,21 @@ class TestCaseService:
1277
1484
  current_parent_id = response.created_step_id
1278
1485
  last_step_id = current_parent_id
1279
1486
 
1280
- # If there's an expected result, create it as a child step under the action
1281
- if expected:
1282
- expected_step_dto = self._build_scenario_step_dto(
1283
- test_case_id=test_case_id,
1284
- body=expected,
1285
- parent_id=current_parent_id,
1487
+ # If there's an expected result, patch the action step
1488
+ if expected and current_parent_id is not None:
1489
+ await self._client.patch_test_case_scenario_step(
1490
+ step_id=current_parent_id,
1491
+ patch=ScenarioStepPatchDto(body=action, expected_result=expected),
1286
1492
  )
1287
- expected_response = await self._client.create_scenario_step(
1493
+ await self._client.create_scenario_step(
1288
1494
  test_case_id=test_case_id,
1289
- step=expected_step_dto,
1290
- after_id=None, # First child
1495
+ step=self._build_scenario_step_dto(
1496
+ test_case_id=test_case_id,
1497
+ body=expected,
1498
+ parent_id=current_parent_id,
1499
+ ),
1500
+ after_id=None,
1291
1501
  )
1292
- last_child_id = expected_response.created_step_id
1293
1502
 
1294
1503
  # Step Attachments (added as children of the action step)
1295
1504
  if step_attachments and isinstance(step_attachments, list):
@@ -1376,6 +1585,22 @@ class TestCaseService:
1376
1585
  resp = await self._client.create_scenario_step(test_case_id=test_case_id, step=step_dto, after_id=after_id)
1377
1586
  created_id = resp.created_step_id
1378
1587
 
1588
+ expected_result = getattr(instance, "expected_result", None)
1589
+ if expected_result and created_id is not None:
1590
+ await self._client.patch_test_case_scenario_step(
1591
+ step_id=created_id,
1592
+ patch=ScenarioStepPatchDto(body=instance.body, expected_result=expected_result),
1593
+ )
1594
+ await self._client.create_scenario_step(
1595
+ test_case_id=test_case_id,
1596
+ step=self._build_scenario_step_dto(
1597
+ test_case_id=test_case_id,
1598
+ body=expected_result,
1599
+ parent_id=created_id,
1600
+ ),
1601
+ after_id=None,
1602
+ )
1603
+
1379
1604
  # Add children (steps may not be a defined field in generated DTO,
1380
1605
  # but we set it via model_construct)
1381
1606
  child_steps = getattr(instance, "steps", None)
@@ -1398,6 +1623,13 @@ class TestCaseService:
1398
1623
  resp = await self._client.create_scenario_step(test_case_id=test_case_id, step=step_dto, after_id=after_id)
1399
1624
  created_id = resp.created_step_id
1400
1625
 
1626
+ elif isinstance(instance, SharedStepStepDto):
1627
+ step_dto = self._build_scenario_step_dto(
1628
+ test_case_id=test_case_id, shared_step_id=instance.shared_step_id, parent_id=parent_id
1629
+ )
1630
+ resp = await self._client.create_scenario_step(test_case_id=test_case_id, step=step_dto, after_id=after_id)
1631
+ created_id = resp.created_step_id
1632
+
1401
1633
  return created_id if created_id else after_id
1402
1634
 
1403
1635
  async def _add_global_attachments(
@@ -123,6 +123,8 @@ class TestLayerService:
123
123
  except ApiException as e:
124
124
  self._client._handle_api_exception(e)
125
125
  raise
126
+ except (AllureNotFoundError, AllureValidationError, AllureAPIError):
127
+ raise
126
128
  except Exception as e:
127
129
  raise AllureAPIError(f"Failed to get test layer {layer_id}: {e}") from e
128
130
 
@@ -192,6 +194,8 @@ class TestLayerService:
192
194
  raise AllureAPIError("Test Layer API is not initialized")
193
195
 
194
196
  try:
197
+ # Check existence first for accurate feedback
198
+ await self.get_test_layer(layer_id)
195
199
  await self._client._test_layer_api.delete9(id=layer_id)
196
200
  return True
197
201
  except AllureNotFoundError:
@@ -300,6 +304,11 @@ class TestLayerService:
300
304
 
301
305
  try:
302
306
  return await self._client._test_layer_schema_api.find_one7(id=schema_id)
307
+ except ApiException as e:
308
+ self._client._handle_api_exception(e)
309
+ raise
310
+ except (AllureNotFoundError, AllureValidationError, AllureAPIError):
311
+ raise
303
312
  except Exception as e:
304
313
  raise AllureAPIError(f"Failed to get test layer schema {schema_id}: {e}") from e
305
314
 
@@ -385,6 +394,8 @@ class TestLayerService:
385
394
  raise AllureAPIError("Test Layer Schema API is not initialized")
386
395
 
387
396
  try:
397
+ # Check existence first for accurate feedback
398
+ await self.get_test_layer_schema(schema_id)
388
399
  await self._client._test_layer_schema_api.delete8(id=schema_id)
389
400
  return True
390
401
  except AllureNotFoundError: