lucius-mcp 0.2.2__py3-none-any.whl → 0.3.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 (38) hide show
  1. {lucius_mcp-0.2.2.dist-info → lucius_mcp-0.3.0.dist-info}/METADATA +8 -1
  2. {lucius_mcp-0.2.2.dist-info → lucius_mcp-0.3.0.dist-info}/RECORD +38 -19
  3. src/client/__init__.py +10 -0
  4. src/client/client.py +289 -8
  5. src/client/generated/README.md +11 -0
  6. src/client/generated/__init__.py +4 -0
  7. src/client/generated/api/__init__.py +2 -0
  8. src/client/generated/api/test_layer_controller_api.py +1746 -0
  9. src/client/generated/api/test_layer_schema_controller_api.py +1415 -0
  10. src/client/generated/docs/TestLayerControllerApi.md +407 -0
  11. src/client/generated/docs/TestLayerSchemaControllerApi.md +350 -0
  12. src/client/overridden/test_case_custom_fields_v2.py +254 -0
  13. src/services/__init__.py +8 -0
  14. src/services/launch_service.py +278 -0
  15. src/services/search_service.py +1 -1
  16. src/services/test_case_service.py +512 -92
  17. src/services/test_layer_service.py +416 -0
  18. src/tools/__init__.py +35 -0
  19. src/tools/create_test_case.py +38 -19
  20. src/tools/create_test_layer.py +33 -0
  21. src/tools/create_test_layer_schema.py +39 -0
  22. src/tools/delete_test_layer.py +31 -0
  23. src/tools/delete_test_layer_schema.py +31 -0
  24. src/tools/get_custom_fields.py +2 -1
  25. src/tools/get_test_case_custom_fields.py +34 -0
  26. src/tools/launches.py +112 -0
  27. src/tools/list_test_layer_schemas.py +43 -0
  28. src/tools/list_test_layers.py +38 -0
  29. src/tools/search.py +6 -3
  30. src/tools/test_layers.py +21 -0
  31. src/tools/update_test_case.py +48 -23
  32. src/tools/update_test_layer.py +33 -0
  33. src/tools/update_test_layer_schema.py +40 -0
  34. src/utils/__init__.py +4 -0
  35. src/utils/links.py +13 -0
  36. {lucius_mcp-0.2.2.dist-info → lucius_mcp-0.3.0.dist-info}/WHEEL +0 -0
  37. {lucius_mcp-0.2.2.dist-info → lucius_mcp-0.3.0.dist-info}/entry_points.txt +0 -0
  38. {lucius_mcp-0.2.2.dist-info → lucius_mcp-0.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,6 @@
1
1
  import logging
2
2
  from dataclasses import dataclass
3
- from typing import Any
3
+ from typing import TypedDict, cast
4
4
 
5
5
  from pydantic import ValidationError as PydanticValidationError
6
6
 
@@ -12,6 +12,7 @@ from src.client import (
12
12
  from src.client.exceptions import AllureAPIError, AllureNotFoundError, AllureValidationError
13
13
  from src.client.generated.models import (
14
14
  CustomFieldDto,
15
+ CustomFieldValueDto,
15
16
  CustomFieldValueWithCfDto,
16
17
  ExternalLinkDto,
17
18
  ScenarioStepCreateDto,
@@ -19,6 +20,7 @@ from src.client.generated.models import (
19
20
  TestCaseCreateV2Dto,
20
21
  TestCaseDto,
21
22
  TestCaseOverviewDto,
23
+ TestLayerDto,
22
24
  TestTagDto,
23
25
  )
24
26
  from src.client.generated.models.attachment_step_dto import AttachmentStepDto
@@ -28,6 +30,7 @@ from src.client.generated.models.shared_step_step_dto import SharedStepStepDto
28
30
  from src.client.generated.models.test_case_patch_v2_dto import TestCasePatchV2Dto
29
31
  from src.client.generated.models.test_case_scenario_v2_dto import TestCaseScenarioV2Dto
30
32
  from src.services.attachment_service import AttachmentService
33
+ from src.services.test_layer_service import TestLayerService
31
34
  from src.utils.schema_hint import generate_schema_hint
32
35
 
33
36
  # Maximum lengths based on API constraints
@@ -39,6 +42,21 @@ MAX_BODY_LENGTH = 10000 # Step body limit
39
42
  logger = logging.getLogger(__name__)
40
43
 
41
44
 
45
+ class ResolvedCustomFieldInfo(TypedDict):
46
+ id: int
47
+ project_cf_id: int
48
+ required: bool
49
+ single_select: bool | None
50
+ values: list[str]
51
+ values_map: dict[str, int | None]
52
+
53
+
54
+ class CustomFieldDisplay(TypedDict):
55
+ name: str
56
+ required: bool
57
+ values: list[str]
58
+
59
+
42
60
  @dataclass
43
61
  class TestCaseUpdate:
44
62
  """Data object for updating a test case."""
@@ -46,10 +64,10 @@ class TestCaseUpdate:
46
64
  name: str | None = None
47
65
  description: str | None = None
48
66
  precondition: str | None = None
49
- steps: list[dict[str, Any]] | None = None
67
+ steps: list[dict[str, object]] | None = None
50
68
  tags: list[str] | None = None
51
69
  attachments: list[dict[str, str]] | None = None
52
- custom_fields: dict[str, str] | None = None
70
+ custom_fields: dict[str, str | list[str]] | None = None
53
71
  automated: bool | None = None
54
72
  expected_result: str | None = None
55
73
  status_id: int | None = None
@@ -75,21 +93,25 @@ class TestCaseService:
75
93
  self,
76
94
  client: AllureClient,
77
95
  attachment_service: AttachmentService | None = None,
96
+ test_layer_service: TestLayerService | None = None,
78
97
  ) -> None:
79
98
  self._client = client
80
99
  self._project_id = client.get_project()
81
100
  self._attachment_service = attachment_service or AttachmentService(self._client)
101
+ self._test_layer_service = test_layer_service or TestLayerService(self._client)
82
102
  # {project_id: {name: {"id": int, "values": list[str]}}}
83
- self._cf_cache: dict[int, dict[str, dict[str, Any]]] = {}
103
+ self._cf_cache: dict[int, dict[str, ResolvedCustomFieldInfo]] = {}
84
104
 
85
105
  async def create_test_case( # noqa: C901
86
106
  self,
87
107
  name: str,
88
108
  description: str | None = None,
89
- steps: list[dict[str, Any]] | None = None,
109
+ steps: list[dict[str, object]] | None = None,
90
110
  tags: list[str] | None = None,
91
111
  attachments: list[dict[str, str]] | None = None,
92
- custom_fields: dict[str, str] | None = None,
112
+ custom_fields: dict[str, str | list[str]] | None = None,
113
+ test_layer_id: int | None = None,
114
+ test_layer_name: str | None = None,
93
115
  ) -> TestCaseOverviewDto:
94
116
  """Create a new test case.
95
117
 
@@ -100,6 +122,8 @@ class TestCaseService:
100
122
  tags: Optional list of tags.
101
123
  attachments: Optional list of test-case level attachments.
102
124
  custom_fields: Optional dictionary of custom fields (Name -> Value).
125
+ test_layer_id: Optional test layer ID to assign to the test case.
126
+ test_layer_name: Optional test layer name to assign to the test case.
103
127
 
104
128
  Returns:
105
129
  The created test case overview.
@@ -116,28 +140,46 @@ class TestCaseService:
116
140
  self._validate_attachments(attachments)
117
141
  self._validate_custom_fields(custom_fields)
118
142
 
119
- # 2. Resolve custom fields if provided
120
- resolved_custom_fields = []
143
+ # 2. Resolve test layer if provided
144
+ resolved_test_layer_id = await self._validate_test_layer(
145
+ test_layer_id=test_layer_id,
146
+ test_layer_name=test_layer_name,
147
+ )
148
+
149
+ # 3. Resolve custom fields if provided
150
+ resolved_custom_fields: list[CustomFieldValueWithCfDto] = []
121
151
  if custom_fields:
122
152
  project_cfs = await self._get_resolved_custom_fields(self._project_id)
123
- missing_fields = []
124
- invalid_values = []
153
+ missing_fields: list[str] = []
154
+ invalid_values: list[str] = []
125
155
 
126
156
  for key, value in custom_fields.items():
127
157
  cf_info = project_cfs.get(key)
128
158
  if cf_info is None:
129
159
  missing_fields.append(key)
160
+ continue
161
+
162
+ cf_id = cf_info["id"]
163
+ allowed_values = cf_info["values"]
164
+ values_map = cf_info["values_map"]
165
+
166
+ if allowed_values:
167
+ invalid_for_field = False
168
+ input_values = [value] if isinstance(value, str) else value
169
+ for item in input_values:
170
+ if item not in allowed_values:
171
+ invalid_values.append(f"'{key}': '{item}' (Allowed: {', '.join(allowed_values)})")
172
+ invalid_for_field = True
173
+ if invalid_for_field:
174
+ continue
130
175
  else:
131
- cf_id = cf_info["id"]
132
- allowed_values = cf_info["values"]
176
+ input_values = [value] if isinstance(value, str) else value
133
177
 
134
- # Validate value if allowed_values are present
135
- if allowed_values and value not in allowed_values:
136
- invalid_values.append(f"'{key}': '{value}' (Allowed: {', '.join(allowed_values)})")
137
- else:
138
- resolved_custom_fields.append(
139
- CustomFieldValueWithCfDto(custom_field=CustomFieldDto(id=cf_id, name=key), name=value)
140
- )
178
+ for item in input_values:
179
+ val_id = values_map.get(item)
180
+ resolved_custom_fields.append(
181
+ CustomFieldValueWithCfDto(custom_field=CustomFieldDto(id=cf_id, name=key), id=val_id, name=item)
182
+ )
141
183
 
142
184
  error_messages = []
143
185
 
@@ -160,7 +202,7 @@ class TestCaseService:
160
202
  )
161
203
  raise AllureValidationError(full_error_msg)
162
204
 
163
- # 3. Create TestCaseCreateV2Dto with validation
205
+ # 4. Create TestCaseCreateV2Dto with validation
164
206
  tag_dtos = self._build_tag_dtos(tags)
165
207
  try:
166
208
  data = TestCaseCreateV2Dto(
@@ -169,19 +211,20 @@ class TestCaseService:
169
211
  description=description,
170
212
  tags=tag_dtos,
171
213
  custom_fields=resolved_custom_fields,
214
+ test_layer_id=resolved_test_layer_id,
172
215
  )
173
216
  except PydanticValidationError as e:
174
217
  hint = generate_schema_hint(TestCaseCreateV2Dto)
175
218
  raise AllureValidationError(f"Invalid test case data: {e}", suggestions=[hint]) from e
176
219
 
177
- # 4. Create the test case
220
+ # 5. Create the test case
178
221
  created_test_case = await self._client.create_test_case(data)
179
222
  test_case_id = created_test_case.id
180
223
 
181
224
  if test_case_id is None:
182
225
  raise AllureValidationError("Failed to get test case ID from created test case")
183
226
 
184
- # 5. Add steps and attachments with rollback on failure
227
+ # 6. Add steps and attachments with rollback on failure
185
228
  try:
186
229
  # Add steps one by one via separate API calls
187
230
  last_step_id: int | None = None
@@ -216,7 +259,7 @@ class TestCaseService:
216
259
  """
217
260
  return await self._client.get_test_case(test_case_id)
218
261
 
219
- async def get_custom_fields(self, name: str | None = None) -> list[dict[str, Any]]:
262
+ async def get_custom_fields(self, name: str | None = None) -> list[CustomFieldDisplay]:
220
263
  """Get custom fields for the project with optional name filtering.
221
264
 
222
265
  This method uses the internal cache to avoid duplicate API calls when
@@ -225,7 +268,7 @@ class TestCaseService:
225
268
  # Use cached resolution method to get field mapping
226
269
  cf_mapping = await self._get_resolved_custom_fields(self._project_id)
227
270
 
228
- result = []
271
+ result: list[CustomFieldDisplay] = []
229
272
  filter_name = name.lower() if name else None
230
273
 
231
274
  # Convert the cached mapping back to the display format
@@ -237,7 +280,44 @@ class TestCaseService:
237
280
 
238
281
  return result
239
282
 
240
- async def update_test_case(self, test_case_id: int, data: TestCaseUpdate) -> TestCaseDto:
283
+ async def get_test_case_custom_fields_values(self, test_case_id: int) -> dict[str, str | list[str]]:
284
+ """Fetch custom field values for a test case.
285
+
286
+ Returns:
287
+ Dictionary of {custom_field_name: value(s)}.
288
+ Single value return as scalar, multiple/empty as list.
289
+ """
290
+ cfs = await self._client.get_test_case_custom_fields(test_case_id, self._project_id)
291
+ result: dict[str, str | list[str]] = {}
292
+ for cf in cfs:
293
+ if cf.custom_field and cf.custom_field.custom_field and cf.custom_field.custom_field.name:
294
+ name = cf.custom_field.custom_field.name
295
+ values = cf.values or []
296
+ # Normalize: Single value -> Scalar, else List
297
+ if len(values) == 1:
298
+ single_name = values[0].name
299
+ result[name] = single_name if single_name is not None else []
300
+ else:
301
+ result[name] = [v.name for v in values if v.name is not None]
302
+ return result
303
+
304
+ async def update_test_case_custom_fields(
305
+ self, test_case_id: int, custom_fields: dict[str, str | list[str]]
306
+ ) -> list[CustomFieldValueWithCfDto]:
307
+ """Update only custom fields via dedicated endpoint.
308
+
309
+ Args:
310
+ test_case_id: ID of the test case.
311
+ custom_fields: Mapping of Names -> Value(s).
312
+
313
+ Returns:
314
+ List of updated CF DTOs.
315
+ """
316
+ resolved_dtos = await self._build_custom_field_dtos(self._project_id, custom_fields)
317
+ await self._client.update_test_case_custom_fields(test_case_id, resolved_dtos)
318
+ return resolved_dtos
319
+
320
+ async def update_test_case(self, test_case_id: int, data: TestCaseUpdate) -> TestCaseDto: # noqa: C901
241
321
  """Update an existing test case.
242
322
 
243
323
  Args:
@@ -249,38 +329,121 @@ class TestCaseService:
249
329
  """
250
330
  # 1. Fetch current state for idempotency and partial updates
251
331
  current_case = await self.get_test_case(test_case_id)
332
+ project_id = current_case.project_id or self._project_id
333
+
334
+ # 2. Check if we can use the dedicated CF endpoint
335
+ # If ONLY custom_fields are provided, use the dedicated endpoint
336
+ has_metadata_changes = any(
337
+ [
338
+ data.name is not None,
339
+ data.description is not None,
340
+ data.precondition is not None,
341
+ data.tags is not None,
342
+ data.automated is not None,
343
+ data.expected_result is not None,
344
+ data.status_id is not None,
345
+ data.test_layer_id is not None,
346
+ data.workflow_id is not None,
347
+ data.links is not None,
348
+ ]
349
+ )
350
+ has_scenario_changes = data.steps is not None or data.attachments is not None
351
+ if data.custom_fields is not None and not (has_metadata_changes or has_scenario_changes):
352
+ current_cf_values = await self.get_test_case_custom_fields_values(test_case_id)
353
+ current_normalized = self._normalize_custom_field_values_map(current_cf_values, drop_empty=False)
354
+ desired_normalized = self._normalize_custom_field_values_map(data.custom_fields, drop_empty=False)
355
+ if current_normalized == desired_normalized:
356
+ return current_case
357
+ await self.update_test_case_custom_fields(test_case_id, data.custom_fields)
358
+ updated_case = await self.get_test_case(test_case_id)
359
+ return updated_case if updated_case is not None else current_case
252
360
 
253
- # 2. Prepare patches for simple fields and tags
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)
366
+
367
+ # 3. Handle Metadata Patches
254
368
  patch_kwargs, has_changes = await self._prepare_field_updates(current_case, data)
255
369
 
256
- # 3. Handle Scenario (Steps and Attachments)
370
+ # 4. Handle Custom Fields in mixed update (replacement)
371
+ if data.custom_fields is not None:
372
+ # For mixed updates via patch13, we need to provide the FULL list of custom fields
373
+ # since the API replaces them. We merge current + updates.
374
+ current_cf_dtos = await self._client.get_test_case_custom_fields(test_case_id, project_id)
375
+
376
+ # Build map of {field_name: CustomFieldValueWithCfDto[]}
377
+ # We use list of DTOs to support multi-value fields in the map
378
+ cf_map: dict[str, list[CustomFieldValueWithCfDto]] = {}
379
+ for cf in current_cf_dtos:
380
+ if cf.custom_field and cf.custom_field.custom_field and cf.custom_field.custom_field.name:
381
+ fname = cf.custom_field.custom_field.name
382
+ fid = cf.custom_field.custom_field.id
383
+ cf_map[fname] = [
384
+ CustomFieldValueWithCfDto(
385
+ custom_field=CustomFieldDto(id=fid, name=fname),
386
+ id=v.id,
387
+ name=v.name,
388
+ )
389
+ for v in (cf.values or [])
390
+ ]
391
+
392
+ # Resolve New Field DTOs
393
+ new_cf_dtos = await self._build_custom_field_dtos_legacy(project_id, data.custom_fields)
394
+
395
+ # Merge into map
396
+ for cf_dto in new_cf_dtos:
397
+ custom_field = cf_dto.custom_field
398
+ if custom_field is None or custom_field.name is None:
399
+ continue
400
+ fname = custom_field.name
401
+ if cf_dto.id is None and cf_dto.name is None: # Clear indicator in building logic
402
+ cf_map.pop(fname, None)
403
+ else:
404
+ # _build_custom_field_dtos_legacy returns flattened list, so we group them
405
+ if fname not in cf_map or data.custom_fields.get(fname) is not None:
406
+ # If explicitly mentioned in updates, replace entire field value list
407
+ cf_map[fname] = [cf_dto]
408
+ else:
409
+ # Append? No, the legacy builder for patch13 is tricky.
410
+ # Actually, if it's in data.custom_fields, we should have processed it already.
411
+ pass
412
+
413
+ # Flatten map back to list
414
+ final_cfs: list[CustomFieldValueWithCfDto] = []
415
+ for item_list in cf_map.values():
416
+ final_cfs.extend(item_list)
417
+
418
+ current_cf_values = await self.get_test_case_custom_fields_values(test_case_id)
419
+ current_normalized = self._normalize_custom_field_values_map(current_cf_values, drop_empty=True)
420
+ desired_normalized = self._normalize_custom_field_values_map(data.custom_fields, drop_empty=True)
421
+ if current_normalized != desired_normalized:
422
+ patch_kwargs["customFields"] = final_cfs
423
+ has_changes = True
424
+
425
+ # 5. Handle Scenario
257
426
  scenario_dto_v2 = await self._prepare_scenario_update(test_case_id, data)
258
- # We do NOT add scenario to patch_kwargs because patch13 endpoint has issues with BodyStepDto serialization.
259
- # Instead, we will recreate the scenario step-by-step if needed.
260
427
  if scenario_dto_v2:
261
428
  has_changes = True
262
429
 
263
- # 4. Idempotency Check
264
430
  if not has_changes:
265
431
  return current_case
266
432
 
267
- # 5. Apply Update
268
- updated_case = current_case
269
- if patch_kwargs:
270
- try:
271
- patch_data = TestCasePatchV2Dto(**patch_kwargs)
272
- updated_case = await self._client.update_test_case(test_case_id, patch_data)
273
- except PydanticValidationError as e:
274
- hint = generate_schema_hint(TestCasePatchV2Dto)
275
- raise AllureValidationError(f"Invalid update data: {e}", suggestions=[hint]) from e
433
+ # 6. Apply Update
434
+ try:
435
+ patch_data = TestCasePatchV2Dto(**patch_kwargs)
436
+ await self._client.update_test_case(test_case_id, patch_data)
437
+ except PydanticValidationError as e:
438
+ hint = generate_schema_hint(TestCasePatchV2Dto)
439
+ raise AllureValidationError(f"Invalid update data: {e}", suggestions=[hint]) from e
276
440
 
277
- # 6. Apply Scenario Re-creation
441
+ # 7. Apply Scenario Re-creation
278
442
  if scenario_dto_v2 and scenario_dto_v2.steps is not None:
279
443
  await self._recreate_scenario(test_case_id, scenario_dto_v2.steps)
280
- # Refetch to get consistent state
281
- updated_case = await self.get_test_case(test_case_id)
282
444
 
283
- return updated_case
445
+ updated_case = await self.get_test_case(test_case_id)
446
+ return updated_case if updated_case is not None else current_case
284
447
 
285
448
  async def delete_test_case(self, test_case_id: int) -> DeleteResult:
286
449
  """Archive/soft-delete a test case."""
@@ -411,9 +574,9 @@ class TestCaseService:
411
574
 
412
575
  async def _prepare_field_updates( # noqa: C901
413
576
  self, current_case: TestCaseDto, data: TestCaseUpdate
414
- ) -> tuple[dict[str, Any], bool]:
577
+ ) -> tuple[dict[str, object], bool]:
415
578
  """Prepare patch arguments for simple fields, tags, and custom fields."""
416
- patch_kwargs: dict[str, Any] = {}
579
+ patch_kwargs: dict[str, object] = {}
417
580
  has_changes = False
418
581
 
419
582
  if data.name is not None and data.name != current_case.name:
@@ -468,28 +631,11 @@ class TestCaseService:
468
631
  patch_kwargs["tags"] = self._build_tag_dtos(data.tags)
469
632
  has_changes = True
470
633
 
471
- # Custom Fields
472
- if data.custom_fields:
473
- project_id = current_case.project_id
474
- if project_id:
475
- resolved_cfs = []
476
- project_cfs = await self._get_resolved_custom_fields(project_id)
477
- for key, value in data.custom_fields.items():
478
- cf_info = project_cfs.get(key)
479
- if cf_info:
480
- # For updates, we blindly trust if it exists, or should we validate?
481
- # The plan implies validating create, let's also validate update to be safe,
482
- # but typically update is just "prepare kwargs".
483
- # If we want validation here, we should add it.
484
- # For now, adapting to the new dict structure is required.
485
- resolved_cfs.append(
486
- CustomFieldValueWithCfDto(
487
- custom_field=CustomFieldDto(id=cf_info["id"], name=key), name=value
488
- )
489
- )
490
- patch_kwargs["custom_fields"] = resolved_cfs
491
634
  has_changes = True
492
635
 
636
+ # Custom Fields are handled via dedicated endpoint in update_test_case
637
+ # so we DO NOT add them to patch_kwargs here.
638
+
493
639
  return patch_kwargs, has_changes
494
640
 
495
641
  async def _prepare_scenario_update(self, test_case_id: int, data: TestCaseUpdate) -> TestCaseScenarioV2Dto | None:
@@ -564,7 +710,7 @@ class TestCaseService:
564
710
  return final_steps
565
711
 
566
712
  async def _build_steps_dtos_from_list(
567
- self, test_case_id: int, steps: list[dict[str, Any]]
713
+ self, test_case_id: int, steps: list[dict[str, object]]
568
714
  ) -> list[SharedStepScenarioDtoStepsInner]:
569
715
  """Convert list of step dicts to DTOs for PATCH."""
570
716
  dtos = []
@@ -627,7 +773,7 @@ class TestCaseService:
627
773
  if len(name) > MAX_NAME_LENGTH:
628
774
  raise AllureValidationError(f"Test case name must be {MAX_NAME_LENGTH} characters or less.")
629
775
 
630
- def _validate_steps(self, steps: list[dict[str, Any]] | None) -> None: # noqa: C901
776
+ def _validate_steps(self, steps: list[dict[str, object]] | None) -> None: # noqa: C901
631
777
  """Validate steps list structure and content."""
632
778
  if steps is None:
633
779
  return
@@ -715,8 +861,8 @@ class TestCaseService:
715
861
  if "content" in att and "name" not in att:
716
862
  raise AllureValidationError(f"Attachment at index {i} with 'content' must also have 'name'")
717
863
 
718
- def _validate_custom_fields(self, custom_fields: dict[str, str] | None) -> None:
719
- """Validate custom fields dictionary."""
864
+ def _validate_custom_fields(self, custom_fields: dict[str, str | list[str]] | None) -> None:
865
+ """Validate custom fields dictionary structure."""
720
866
  if custom_fields is None:
721
867
  return
722
868
 
@@ -729,13 +875,128 @@ class TestCaseService:
729
875
  for key, value in custom_fields.items():
730
876
  if not isinstance(key, str):
731
877
  raise AllureValidationError(f"Custom field key must be a string, got {type(key).__name__}")
732
- if not isinstance(value, str):
878
+ if not isinstance(value, (str, list)):
733
879
  raise AllureValidationError(
734
- f"Custom field value for '{key}' must be a string, got {type(value).__name__}"
880
+ f"Custom field value for '{key}' must be a string or list of strings, got {type(value).__name__}"
735
881
  )
882
+ if isinstance(value, list):
883
+ for i, v in enumerate(value):
884
+ if not isinstance(v, str):
885
+ raise AllureValidationError(
886
+ f"Custom field '{key}' item {i} must be a string, got {type(v).__name__}"
887
+ )
736
888
  if not key.strip():
737
889
  raise AllureValidationError("Custom field key cannot be empty")
738
890
 
891
+ def _validate_test_layer_id(self, test_layer_id: int | None) -> None:
892
+ """Validate test layer ID format."""
893
+ if test_layer_id is None:
894
+ 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")
897
+
898
+ def _format_available_layers(self, layers: list[TestLayerDto]) -> str:
899
+ display_lines: list[str] = []
900
+ for layer in layers[:10]:
901
+ display_lines.append(f"ID: {layer.id}, Name: {layer.name}")
902
+ if not display_lines:
903
+ display_lines.append("(none)")
904
+ return "\n".join(display_lines)
905
+
906
+ async def _validate_test_layer_exists(self, test_layer_id: int, project_id: int) -> None:
907
+ try:
908
+ await self._test_layer_service.get_test_layer(test_layer_id)
909
+ except AllureNotFoundError as e:
910
+ layers = await self._test_layer_service.list_test_layers(page=0, size=100)
911
+ available_layers = self._format_available_layers(layers)
912
+ raise AllureValidationError(
913
+ "\n".join(
914
+ [
915
+ f"Warning: Test layer ID {test_layer_id} does not exist in project {project_id}.",
916
+ "Test case update was not performed.",
917
+ "Available test layers (first 10):",
918
+ available_layers,
919
+ "Use list_test_layers(page=..., size=...) to see more.",
920
+ "To proceed without a test layer, omit test_layer_id.",
921
+ ]
922
+ )
923
+ ) from e
924
+ except AllureAPIError as e:
925
+ raise AllureValidationError(
926
+ f"Unable to validate test layer ID {test_layer_id} for project {project_id}: {e}",
927
+ suggestions=["Use list_test_layers to find valid IDs", "Check API connectivity and permissions"],
928
+ ) from e
929
+
930
+ async def _validate_test_layer(
931
+ self,
932
+ *,
933
+ test_layer_id: int | None,
934
+ test_layer_name: str | None,
935
+ ) -> int | None:
936
+ if test_layer_id is not None and test_layer_name is not None:
937
+ raise AllureValidationError(
938
+ "Provide only one of test_layer_id or test_layer_name (they are mutually exclusive)."
939
+ )
940
+
941
+ if test_layer_id is not None:
942
+ self._validate_test_layer_id(test_layer_id)
943
+ try:
944
+ await self._test_layer_service.get_test_layer(test_layer_id)
945
+ except AllureNotFoundError as e:
946
+ layers = await self._test_layer_service.list_test_layers(page=0, size=100)
947
+ available_layers = self._format_available_layers(layers)
948
+ raise AllureValidationError(
949
+ "\n".join(
950
+ [
951
+ f"Warning: Test layer ID {test_layer_id} does not exist in project {self._project_id}.",
952
+ "Test case creation was not performed.",
953
+ "Available test layers (first 10):",
954
+ available_layers,
955
+ "Use list_test_layers(page=..., size=...) to see more.",
956
+ "To proceed without a test layer, omit test_layer_id/test_layer_name.",
957
+ ]
958
+ )
959
+ ) from e
960
+ except AllureAPIError as e:
961
+ raise AllureValidationError(
962
+ f"Unable to validate test layer ID {test_layer_id} for project {self._project_id}: {e}",
963
+ suggestions=["Use list_test_layers to find valid IDs", "Check API connectivity and permissions"],
964
+ ) from e
965
+ return test_layer_id
966
+
967
+ if test_layer_name is not None:
968
+ layers = await self._test_layer_service.list_test_layers(page=0, size=100)
969
+ matches = [layer for layer in layers if layer.name == test_layer_name]
970
+ if len(matches) == 0:
971
+ available_layers = self._format_available_layers(layers)
972
+ raise AllureValidationError(
973
+ "\n".join(
974
+ [
975
+ f"Warning: Test layer name '{test_layer_name}' not found.",
976
+ "Test case creation was not performed.",
977
+ "Available test layers (first 10):",
978
+ available_layers,
979
+ "Use list_test_layers(page=..., size=...) to see more.",
980
+ "To proceed without a test layer, omit test_layer_id/test_layer_name.",
981
+ ]
982
+ )
983
+ )
984
+ if len(matches) > 1:
985
+ match_lines = "\n".join([f"ID: {layer.id}, Name: {layer.name}" for layer in matches])
986
+ raise AllureValidationError(
987
+ "\n".join(
988
+ [
989
+ f"Warning: Multiple test layers match name '{test_layer_name}'.",
990
+ "Test case creation was not performed.",
991
+ match_lines,
992
+ "Use test_layer_id to disambiguate.",
993
+ ]
994
+ )
995
+ )
996
+ return matches[0].id
997
+
998
+ return None
999
+
739
1000
  # ==========================================
740
1001
  # DTO Building Methods
741
1002
  # ==========================================
@@ -754,7 +1015,7 @@ class TestCaseService:
754
1015
  raise AllureValidationError(f"Invalid tag '{t}': {e}", suggestions=[hint]) from e
755
1016
  return tag_dtos
756
1017
 
757
- async def _get_resolved_custom_fields(self, project_id: int) -> dict[str, dict[str, Any]]:
1018
+ async def _get_resolved_custom_fields(self, project_id: int) -> dict[str, ResolvedCustomFieldInfo]:
758
1019
  """Get or fetch custom field name-to-info mapping for a project."""
759
1020
  if project_id in self._cf_cache:
760
1021
  return self._cf_cache[project_id]
@@ -762,37 +1023,196 @@ class TestCaseService:
762
1023
  # Use the client wrapper method for consistent error handling and response processing
763
1024
  cfs = await self._client.get_custom_fields_with_values(project_id)
764
1025
  logger.debug("Fetched %d custom fields for project %d", len(cfs), project_id)
765
- mapping = {}
1026
+ mapping: dict[str, ResolvedCustomFieldInfo] = {}
766
1027
  for cf_with_values in cfs:
767
1028
  if cf_with_values.custom_field and cf_with_values.custom_field.custom_field:
768
1029
  inner_cf = cf_with_values.custom_field.custom_field
769
1030
  if inner_cf.name and inner_cf.id:
770
- values = []
1031
+ values_list: list[str] = []
1032
+ values_map: dict[str, int | None] = {}
771
1033
  if cf_with_values.values:
772
- values = [v.name for v in cf_with_values.values if v.name]
773
-
1034
+ for v in cf_with_values.values:
1035
+ if v.name:
1036
+ values_list.append(v.name)
1037
+ values_map[v.name] = v.id
1038
+
1039
+ project_cf_id = (
1040
+ cf_with_values.custom_field.id
1041
+ if cf_with_values.custom_field and cf_with_values.custom_field.id is not None
1042
+ else inner_cf.id
1043
+ )
1044
+ if project_cf_id is None:
1045
+ continue
774
1046
  mapping[inner_cf.name] = {
775
1047
  "id": inner_cf.id,
776
- "required": bool(cf_with_values.custom_field.required),
777
- "values": values,
1048
+ "project_cf_id": project_cf_id,
1049
+ "required": bool(cf_with_values.custom_field.required)
1050
+ if cf_with_values.custom_field
1051
+ else False,
1052
+ "single_select": bool(cf_with_values.custom_field.single_select)
1053
+ if cf_with_values.custom_field
1054
+ else None,
1055
+ "values": values_list,
1056
+ "values_map": values_map,
778
1057
  }
779
-
780
1058
  self._cf_cache[project_id] = mapping
781
1059
  return mapping
782
1060
 
783
- def _build_custom_field_dtos(self, custom_fields: dict[str, str] | None) -> list[CustomFieldValueWithCfDto]:
784
- """DEPRECATED: Use inline resolution in create_test_case."""
785
- if not custom_fields:
786
- return []
1061
+ def _normalize_custom_field_values_map(
1062
+ self,
1063
+ values: dict[str, str | list[str]],
1064
+ *,
1065
+ drop_empty: bool,
1066
+ ) -> dict[str, list[str]]:
1067
+ normalized: dict[str, list[str]] = {}
1068
+ for key, value in values.items():
1069
+ if value == "" or value == [] or value == "[]":
1070
+ if not drop_empty:
1071
+ normalized[key] = []
1072
+ continue
1073
+
1074
+ if isinstance(value, list):
1075
+ items = list(value)
1076
+ else:
1077
+ items = [value]
1078
+
1079
+ items.sort()
1080
+ normalized[key] = items
1081
+
1082
+ return normalized
1083
+
1084
+ async def _build_custom_field_dtos( # noqa: C901
1085
+ self, project_id: int, custom_fields: dict[str, str | list[str]]
1086
+ ) -> list[CustomFieldValueWithCfDto]:
1087
+ """Build DTOs for dedicated custom field endpoint with aggregated validation."""
1088
+ resolved_dtos: list[CustomFieldValueWithCfDto] = []
1089
+ project_cfs = await self._get_resolved_custom_fields(project_id)
1090
+
1091
+ missing_fields: list[str] = []
1092
+ invalid_values: list[str] = []
1093
+ required_missing: list[str] = []
1094
+ single_select_errors: list[str] = []
1095
+
1096
+ # Check for required fields (on update, we only check if they are being cleared)
1097
+ for cf_name, info in project_cfs.items():
1098
+ if info["required"] and cf_name in custom_fields:
1099
+ val = custom_fields[cf_name]
1100
+ if val == "" or val == [] or val == "[]":
1101
+ required_missing.append(cf_name)
787
1102
 
788
- cf_dtos = []
789
1103
  for key, value in custom_fields.items():
790
- if not key:
791
- raise AllureValidationError("Custom field key cannot be empty.")
792
- if not isinstance(value, str):
793
- raise AllureValidationError(f"Custom field '{key}' value must be a string.")
794
- cf_dtos.append(CustomFieldValueWithCfDto(custom_field=CustomFieldDto(name=key), name=value))
795
- return cf_dtos
1104
+ cf_info = project_cfs.get(key)
1105
+ if cf_info is None:
1106
+ missing_fields.append(key)
1107
+ continue
1108
+
1109
+ cf_id = cf_info["project_cf_id"] # Use project-scoped ID
1110
+ allowed_values = cf_info["values"]
1111
+ values_map = cf_info["values_map"]
1112
+ is_single_select = cf_info["single_select"]
1113
+
1114
+ # Handle clearing logic: "[]" or empty string/list
1115
+ if value == "[]" or value == "" or value == []:
1116
+ continue
1117
+
1118
+ # Normalize value to list
1119
+ input_values = [value] if isinstance(value, str) else value
1120
+
1121
+ if is_single_select is True and len(input_values) > 1:
1122
+ single_select_errors.append(f"'{key}': multiple values provided for single-select field")
1123
+ continue
1124
+
1125
+ final_values: list[CustomFieldValueDto] = []
1126
+
1127
+ if allowed_values:
1128
+ invalid_for_field = False
1129
+ for v in input_values:
1130
+ if v not in allowed_values:
1131
+ invalid_values.append(f"'{key}': '{v}' (Allowed: {', '.join(allowed_values)})")
1132
+ invalid_for_field = True
1133
+ continue
1134
+
1135
+ vid = values_map.get(v)
1136
+ if vid:
1137
+ final_values.append(CustomFieldValueDto(id=vid))
1138
+ else:
1139
+ final_values.append(CustomFieldValueDto(name=v))
1140
+ if invalid_for_field:
1141
+ continue
1142
+ else:
1143
+ for v in input_values:
1144
+ vid = values_map.get(v)
1145
+ if vid:
1146
+ final_values.append(CustomFieldValueDto(id=vid))
1147
+ else:
1148
+ final_values.append(CustomFieldValueDto(name=v))
1149
+
1150
+ for _ in final_values:
1151
+ resolved_dtos.append(
1152
+ CustomFieldValueWithCfDto(custom_field=CustomFieldDto(id=cf_id), id=_.id, name=_.name)
1153
+ )
1154
+
1155
+ logger.debug("Resolved CF DTOs for update: %s", [d.to_dict() for d in resolved_dtos])
1156
+
1157
+ # Build aggregated error message
1158
+ errors: list[str] = []
1159
+ if missing_fields:
1160
+ errors.append(f"Fields not found: {', '.join(missing_fields)}")
1161
+ if invalid_values:
1162
+ errors.append(f"Invalid values: {'; '.join(invalid_values)}")
1163
+ if required_missing:
1164
+ suggestions = []
1165
+ for field_name in required_missing:
1166
+ field_info = project_cfs.get(field_name)
1167
+ if field_info is None:
1168
+ continue
1169
+ allowed = field_info["values"]
1170
+ if allowed:
1171
+ suggestions.append(f"- {field_name}: {', '.join(allowed)}")
1172
+ guidance = ["Required fields cannot be cleared: " + ", ".join(required_missing)]
1173
+ guidance.append("Use get_custom_fields to review valid values.")
1174
+ if suggestions:
1175
+ guidance.append("Allowed values:")
1176
+ guidance.extend(suggestions)
1177
+ errors.append("\n".join(guidance))
1178
+ if single_select_errors:
1179
+ errors.append(f"Single-select violations: {'; '.join(single_select_errors)}")
1180
+
1181
+ if errors:
1182
+ raise AllureValidationError(
1183
+ "\n".join(errors) + "\n\nUsage Hint: Use get_custom_fields to see available fields and values."
1184
+ )
1185
+
1186
+ return resolved_dtos
1187
+
1188
+ async def _build_custom_field_dtos_legacy(
1189
+ self, project_id: int, custom_fields: dict[str, str | list[str]]
1190
+ ) -> list[CustomFieldValueWithCfDto]:
1191
+ """Build flat DTOs for mixed update patch endpoint."""
1192
+ dtos: list[CustomFieldValueWithCfDto] = []
1193
+ structured = await self._build_custom_field_dtos(project_id, custom_fields)
1194
+
1195
+ project_cfs = await self._get_resolved_custom_fields(project_id)
1196
+
1197
+ for s_dto in structured:
1198
+ custom_field = s_dto.custom_field
1199
+ if custom_field is None or custom_field.id is None:
1200
+ continue
1201
+ fname = next((k for k, v in project_cfs.items() if v["project_cf_id"] == custom_field.id), None)
1202
+ fid = project_cfs[fname]["id"] if fname else custom_field.id
1203
+
1204
+ # Clear indicator for merging logic in update_test_case
1205
+ if s_dto.id is None and s_dto.name is None:
1206
+ dtos.append(
1207
+ CustomFieldValueWithCfDto(custom_field=CustomFieldDto(id=fid, name=fname), id=None, name=None)
1208
+ )
1209
+ else:
1210
+ dtos.append(
1211
+ CustomFieldValueWithCfDto(
1212
+ custom_field=CustomFieldDto(id=fid, name=fname), id=s_dto.id, name=s_dto.name
1213
+ )
1214
+ )
1215
+ return dtos
796
1216
 
797
1217
  def _build_scenario_step_dto(
798
1218
  self,
@@ -822,7 +1242,7 @@ class TestCaseService:
822
1242
  async def _add_steps(
823
1243
  self,
824
1244
  test_case_id: int,
825
- steps: list[dict[str, Any]] | None,
1245
+ steps: list[dict[str, object]] | None,
826
1246
  last_step_id: int | None,
827
1247
  ) -> int | None:
828
1248
  """Add steps to a test case using separate API calls.
@@ -841,7 +1261,7 @@ class TestCaseService:
841
1261
  for s in steps:
842
1262
  action = str(s.get("action", ""))
843
1263
  expected = str(s.get("expected", ""))
844
- step_attachments: list[dict[str, str]] = s.get("attachments", [])
1264
+ step_attachments = cast(list[dict[str, str]], s.get("attachments", []))
845
1265
 
846
1266
  current_parent_id: int | None = None
847
1267
  last_child_id: int | None = None