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.
- {lucius_mcp-0.2.2.dist-info → lucius_mcp-0.3.0.dist-info}/METADATA +8 -1
- {lucius_mcp-0.2.2.dist-info → lucius_mcp-0.3.0.dist-info}/RECORD +38 -19
- src/client/__init__.py +10 -0
- src/client/client.py +289 -8
- src/client/generated/README.md +11 -0
- src/client/generated/__init__.py +4 -0
- src/client/generated/api/__init__.py +2 -0
- src/client/generated/api/test_layer_controller_api.py +1746 -0
- src/client/generated/api/test_layer_schema_controller_api.py +1415 -0
- src/client/generated/docs/TestLayerControllerApi.md +407 -0
- src/client/generated/docs/TestLayerSchemaControllerApi.md +350 -0
- src/client/overridden/test_case_custom_fields_v2.py +254 -0
- src/services/__init__.py +8 -0
- src/services/launch_service.py +278 -0
- src/services/search_service.py +1 -1
- src/services/test_case_service.py +512 -92
- src/services/test_layer_service.py +416 -0
- src/tools/__init__.py +35 -0
- src/tools/create_test_case.py +38 -19
- src/tools/create_test_layer.py +33 -0
- src/tools/create_test_layer_schema.py +39 -0
- src/tools/delete_test_layer.py +31 -0
- src/tools/delete_test_layer_schema.py +31 -0
- src/tools/get_custom_fields.py +2 -1
- src/tools/get_test_case_custom_fields.py +34 -0
- src/tools/launches.py +112 -0
- src/tools/list_test_layer_schemas.py +43 -0
- src/tools/list_test_layers.py +38 -0
- src/tools/search.py +6 -3
- src/tools/test_layers.py +21 -0
- src/tools/update_test_case.py +48 -23
- src/tools/update_test_layer.py +33 -0
- src/tools/update_test_layer_schema.py +40 -0
- src/utils/__init__.py +4 -0
- src/utils/links.py +13 -0
- {lucius_mcp-0.2.2.dist-info → lucius_mcp-0.3.0.dist-info}/WHEEL +0 -0
- {lucius_mcp-0.2.2.dist-info → lucius_mcp-0.3.0.dist-info}/entry_points.txt +0 -0
- {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
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
120
|
-
|
|
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
|
-
|
|
132
|
-
allowed_values = cf_info["values"]
|
|
176
|
+
input_values = [value] if isinstance(value, str) else value
|
|
133
177
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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[
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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,
|
|
577
|
+
) -> tuple[dict[str, object], bool]:
|
|
415
578
|
"""Prepare patch arguments for simple fields, tags, and custom fields."""
|
|
416
|
-
patch_kwargs: dict[str,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
1031
|
+
values_list: list[str] = []
|
|
1032
|
+
values_map: dict[str, int | None] = {}
|
|
771
1033
|
if cf_with_values.values:
|
|
772
|
-
|
|
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
|
-
"
|
|
777
|
-
"
|
|
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
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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,
|
|
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
|
|
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
|