fix(testmap): support flexible detail patch payloads and align patch route coverage

This commit is contained in:
mahdahar 2026-04-09 09:02:50 +07:00
parent 84cfff2201
commit 99d5117bd9
8 changed files with 203 additions and 132 deletions

View File

@ -1,6 +0,0 @@
## Remaining Work
1. `PatVisitController::updateADT` needs to accept or infer `InternalPVID` so the ADT patch tests no longer error out.
2. Implement or expose `POST /api/result` and align `ResultController` responses with what `ResultPatchTest` expects (success + 400/404 handling).
3. For each patch test controller (Contact, Location, Organization modules, Specimen masters, Test/TestMap variants, Rule, User, etc.), ensure the update action validates payloads, rejects empty bodies with 400, returns 404 for absent IDs, and responds with 200/201 on success.
4. Once controllers are fixed, rerun `./vendor/bin/phpunit` to confirm the patch suite passes end-to-end.

View File

@ -315,12 +315,12 @@ $routes->group('api', function ($routes) {
$routes->get('/', 'Test\TestsController::index'); $routes->get('/', 'Test\TestsController::index');
$routes->get('(:num)', 'Test\TestsController::show/$1'); $routes->get('(:num)', 'Test\TestsController::show/$1');
$routes->post('/', 'Test\TestsController::create'); $routes->post('/', 'Test\TestsController::create');
$routes->patch('(:any)', 'Test\TestsController::update/$1'); $routes->patch('(:segment)', 'Test\TestsController::update/$1');
$routes->group('testmap', function ($routes) { $routes->group('testmap', function ($routes) {
$routes->get('/', 'Test\TestMapController::index'); $routes->get('/', 'Test\TestMapController::index');
$routes->get('(:num)', 'Test\TestMapController::show/$1'); $routes->get('(:num)', 'Test\TestMapController::show/$1');
$routes->post('/', 'Test\TestMapController::create'); $routes->post('/', 'Test\TestMapController::create');
$routes->patch('(:any)', 'Test\TestMapController::update/$1'); $routes->patch('(:segment)', 'Test\TestMapController::update/$1');
$routes->delete('/', 'Test\TestMapController::delete'); $routes->delete('/', 'Test\TestMapController::delete');
// Filter routes // Filter routes
@ -331,7 +331,7 @@ $routes->group('api', function ($routes) {
$routes->get('/', 'Test\TestMapDetailController::index'); $routes->get('/', 'Test\TestMapDetailController::index');
$routes->get('(:num)', 'Test\TestMapDetailController::show/$1'); $routes->get('(:num)', 'Test\TestMapDetailController::show/$1');
$routes->post('/', 'Test\TestMapDetailController::create'); $routes->post('/', 'Test\TestMapDetailController::create');
$routes->patch('(:any)', 'Test\TestMapDetailController::update/$1'); $routes->patch('(:segment)', 'Test\TestMapDetailController::update/$1');
$routes->delete('/', 'Test\TestMapDetailController::delete'); $routes->delete('/', 'Test\TestMapDetailController::delete');
$routes->get('by-testmap/(:num)', 'Test\TestMapDetailController::showByTestMap/$1'); $routes->get('by-testmap/(:num)', 'Test\TestMapDetailController::showByTestMap/$1');
$routes->post('batch', 'Test\TestMapDetailController::batchCreate'); $routes->post('batch', 'Test\TestMapDetailController::batchCreate');

View File

@ -235,9 +235,9 @@ class TestMapController extends BaseController {
} }
if ($this->isDetailOpsPayload($detailsPayload)) { if ($this->isDetailOpsPayload($detailsPayload)) {
$newItems = $this->normalizeDetailList($detailsPayload['new'] ?? []); $newItems = $this->normalizeDetailList($detailsPayload['new'] ?? [], 'details.new');
if ($newItems === null) { return null; } if ($newItems === null) { return null; }
$editItems = $this->normalizeDetailList($detailsPayload['edit'] ?? []); $editItems = $this->normalizeDetailList($detailsPayload['edit'] ?? [], 'details.edit');
if ($editItems === null) { return null; } if ($editItems === null) { return null; }
$deletedIds = $this->normalizeDetailIds($detailsPayload['deleted'] ?? []); $deletedIds = $this->normalizeDetailIds($detailsPayload['deleted'] ?? []);
if ($deletedIds === null) { return null; } if ($deletedIds === null) { return null; }
@ -246,13 +246,13 @@ class TestMapController extends BaseController {
} }
if ($this->isListPayload($detailsPayload)) { if ($this->isListPayload($detailsPayload)) {
$items = $this->normalizeDetailList($detailsPayload); $items = $this->normalizeDetailList($detailsPayload, 'details');
if ($items === null) { return null; } if ($items === null) { return null; }
return ['new' => $items, 'edit' => [], 'deleted' => []]; return ['new' => $items, 'edit' => [], 'deleted' => []];
} }
if ($this->isAssocArray($detailsPayload)) { if ($this->isAssocArray($detailsPayload)) {
$items = $this->normalizeDetailList([$detailsPayload]); $items = $this->normalizeDetailList([$detailsPayload], 'details');
if ($items === null) { return null; } if ($items === null) { return null; }
return ['new' => $items, 'edit' => [], 'deleted' => []]; return ['new' => $items, 'edit' => [], 'deleted' => []];
} }
@ -386,21 +386,25 @@ class TestMapController extends BaseController {
return array_keys($payload) !== range(0, count($payload) - 1); return array_keys($payload) !== range(0, count($payload) - 1);
} }
private function normalizeDetailList(mixed $value): ?array private function normalizeDetailList(mixed $value, string $fieldPath): ?array
{ {
if ($value === null) { if ($value === null) {
return []; return [];
} }
if (!is_array($value)) { if (!is_array($value)) {
$this->failValidationErrors('Details must be provided as an array of objects.'); $this->failValidationErrors("{$fieldPath} must be an array of objects.");
return null; return null;
} }
if ($value !== [] && $this->isAssocArray($value)) {
$value = [$value];
}
$results = []; $results = [];
foreach ($value as $index => $item) { foreach ($value as $index => $item) {
if (!is_array($item)) { if (!is_array($item)) {
$this->failValidationErrors("details[{$index}] must be an object."); $this->failValidationErrors("{$fieldPath}[{$index}] must be an object.");
return null; return null;
} }
$results[] = $item; $results[] = $item;

View File

@ -4199,21 +4199,6 @@ paths:
type: integer type: integer
description: Test Map ID description: Test Map ID
requestBody: requestBody:
'200':
description: Test mapping updated
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
message:
type: string
data:
type: integer
description: Updated TestMapID
required: true required: true
content: content:
application/json: application/json:
@ -4229,8 +4214,11 @@ paths:
ClientID: ClientID:
type: string type: string
details: details:
type: object description: |
description: Apply detail-level changes together with the header update Detail payload supports either a flat array/object (treated as new rows)
or an operations object with `new`, `edit`, and `deleted` arrays.
oneOf:
- type: object
properties: properties:
new: new:
type: array type: array
@ -4271,7 +4259,50 @@ paths:
description: TestMapDetailIDs to soft delete description: TestMapDetailIDs to soft delete
items: items:
type: integer type: integer
responses: null - type: array
description: Shortcut format for creating new details only
items:
type: object
properties:
HostTestCode:
type: string
HostTestName:
type: string
ConDefID:
type: integer
ClientTestCode:
type: string
ClientTestName:
type: string
- type: object
description: Shortcut format for creating a single new detail
properties:
HostTestCode:
type: string
HostTestName:
type: string
ConDefID:
type: integer
ClientTestCode:
type: string
ClientTestName:
type: string
responses:
'200':
description: Test mapping updated
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
message:
type: string
data:
type: integer
description: Updated TestMapID
/api/test/testmap/by-testcode/{testCode}: /api/test/testmap/by-testcode/{testCode}:
get: get:
tags: tags:

View File

@ -189,8 +189,11 @@
ClientID: ClientID:
type: string type: string
details: details:
type: object description: |
description: Apply detail-level changes together with the header update Detail payload supports either a flat array/object (treated as new rows)
or an operations object with `new`, `edit`, and `deleted` arrays.
oneOf:
- type: object
properties: properties:
new: new:
type: array type: array
@ -231,6 +234,34 @@
description: TestMapDetailIDs to soft delete description: TestMapDetailIDs to soft delete
items: items:
type: integer type: integer
- type: array
description: Shortcut format for creating new details only
items:
type: object
properties:
HostTestCode:
type: string
HostTestName:
type: string
ConDefID:
type: integer
ClientTestCode:
type: string
ClientTestName:
type: string
- type: object
description: Shortcut format for creating a single new detail
properties:
HostTestCode:
type: string
HostTestName:
type: string
ConDefID:
type: integer
ClientTestCode:
type: string
ClientTestName:
type: string
responses: responses:
'200': '200':
description: Test mapping updated description: Test mapping updated

View File

@ -12,6 +12,7 @@ class TestMapDetailPatchTest extends CIUnitTestCase
protected string $token; protected string $token;
protected string $endpoint = 'api/test/testmap/detail'; protected string $endpoint = 'api/test/testmap/detail';
protected string $mapEndpoint = 'api/test/testmap';
protected function setUp(): void protected function setUp(): void
{ {
@ -36,9 +37,23 @@ class TestMapDetailPatchTest extends CIUnitTestCase
private function createTestMapDetail(array $data = []): array private function createTestMapDetail(array $data = []): array
{ {
$mapResponse = $this->withHeaders($this->authHeaders())
->withBodyFormat('json')
->call('post', $this->mapEndpoint, [
'HostType' => 'SITE',
'HostID' => 1,
'ClientType' => 'SITE',
'ClientID' => 1,
]);
$mapResponse->assertStatus(201);
$mapID = json_decode($mapResponse->getJSON(), true)['data'];
$payload = array_merge([ $payload = array_merge([
'TestMapDetailCode' => 'TMD_' . uniqid(), 'TestMapID' => $mapID,
'TestMapDetailName' => 'Test Map Detail ' . uniqid(), 'HostTestCode' => 'HB',
'HostTestName' => 'Hemoglobin',
'ClientTestCode' => '2',
'ClientTestName' => 'Hemoglobin',
], $data); ], $data);
$response = $this->withHeaders($this->authHeaders()) $response = $this->withHeaders($this->authHeaders())
@ -47,7 +62,11 @@ class TestMapDetailPatchTest extends CIUnitTestCase
$response->assertStatus(201); $response->assertStatus(201);
$decoded = json_decode($response->getJSON(), true); $decoded = json_decode($response->getJSON(), true);
return $decoded['data']; $detailID = $decoded['data'];
$show = $this->withHeaders($this->authHeaders())->call('get', "{$this->endpoint}/{$detailID}");
$show->assertStatus(200);
return json_decode($show->getJSON(), true)['data'];
} }
public function testPartialUpdateTestMapDetailSuccess() public function testPartialUpdateTestMapDetailSuccess()
@ -57,7 +76,7 @@ class TestMapDetailPatchTest extends CIUnitTestCase
$patch = $this->withHeaders($this->authHeaders()) $patch = $this->withHeaders($this->authHeaders())
->withBodyFormat('json') ->withBodyFormat('json')
->call('patch', "{$this->endpoint}/{$id}", ['TestMapDetailName' => 'Updated Detail']); ->call('patch', "{$this->endpoint}/{$id}", ['ClientTestName' => 'Updated Detail']);
$patch->assertStatus(200); $patch->assertStatus(200);
$patchData = json_decode($patch->getJSON(), true); $patchData = json_decode($patch->getJSON(), true);
@ -67,15 +86,15 @@ class TestMapDetailPatchTest extends CIUnitTestCase
$show->assertStatus(200); $show->assertStatus(200);
$showData = json_decode($show->getJSON(), true)['data']; $showData = json_decode($show->getJSON(), true)['data'];
$this->assertEquals('Updated Detail', $showData['TestMapDetailName']); $this->assertEquals('Updated Detail', $showData['ClientTestName']);
$this->assertEquals($detail['TestMapDetailCode'], $showData['TestMapDetailCode']); $this->assertEquals($detail['HostTestCode'], $showData['HostTestCode']);
} }
public function testPartialUpdateTestMapDetailNotFound() public function testPartialUpdateTestMapDetailNotFound()
{ {
$patch = $this->withHeaders($this->authHeaders()) $patch = $this->withHeaders($this->authHeaders())
->withBodyFormat('json') ->withBodyFormat('json')
->call('patch', "{$this->endpoint}/999999", ['TestMapDetailName' => 'Updated']); ->call('patch', "{$this->endpoint}/999999", ['ClientTestName' => 'Updated']);
$patch->assertStatus(404); $patch->assertStatus(404);
} }
@ -84,7 +103,7 @@ class TestMapDetailPatchTest extends CIUnitTestCase
{ {
$patch = $this->withHeaders($this->authHeaders()) $patch = $this->withHeaders($this->authHeaders())
->withBodyFormat('json') ->withBodyFormat('json')
->call('patch', "{$this->endpoint}/invalid", ['TestMapDetailName' => 'Updated']); ->call('patch', "{$this->endpoint}/invalid", ['ClientTestName' => 'Updated']);
$patch->assertStatus(400); $patch->assertStatus(400);
} }
@ -108,14 +127,14 @@ class TestMapDetailPatchTest extends CIUnitTestCase
$patch = $this->withHeaders($this->authHeaders()) $patch = $this->withHeaders($this->authHeaders())
->withBodyFormat('json') ->withBodyFormat('json')
->call('patch', "{$this->endpoint}/{$id}", ['TestMapDetailCode' => 'NEW_' . uniqid()]); ->call('patch', "{$this->endpoint}/{$id}", ['HostTestCode' => 'HBA1C']);
$patch->assertStatus(200); $patch->assertStatus(200);
$showData = json_decode($this->withHeaders($this->authHeaders()) $showData = json_decode($this->withHeaders($this->authHeaders())
->call('get', "{$this->endpoint}/{$id}") ->call('get', "{$this->endpoint}/{$id}")
->getJSON(), true)['data']; ->getJSON(), true)['data'];
$this->assertNotEquals($detail['TestMapDetailCode'], $showData['TestMapDetailCode']); $this->assertNotEquals($detail['HostTestCode'], $showData['HostTestCode']);
$this->assertEquals($detail['TestMapDetailName'], $showData['TestMapDetailName']); $this->assertEquals($detail['ClientTestName'], $showData['ClientTestName']);
} }
} }

View File

@ -37,8 +37,6 @@ class TestMapPatchTest extends CIUnitTestCase
private function createTestMap(array $data = []): array private function createTestMap(array $data = []): array
{ {
$payload = array_merge([ $payload = array_merge([
'TestMapCode' => 'TM_' . uniqid(),
'TestMapName' => 'Test Map ' . uniqid(),
'HostType' => 'SITE', 'HostType' => 'SITE',
'HostID' => 1, 'HostID' => 1,
'ClientType' => 'SITE', 'ClientType' => 'SITE',
@ -49,7 +47,6 @@ class TestMapPatchTest extends CIUnitTestCase
->withBodyFormat('json') ->withBodyFormat('json')
->call('post', $this->endpoint, $payload); ->call('post', $this->endpoint, $payload);
fwrite(STDERR, 'Create response: ' . $response->getStatusCode() . ' ' . $response->getBody() . PHP_EOL);
$response->assertStatus(201); $response->assertStatus(201);
$created = json_decode($response->getJSON(), true); $created = json_decode($response->getJSON(), true);
$id = $created['data']; $id = $created['data'];
@ -66,7 +63,7 @@ class TestMapPatchTest extends CIUnitTestCase
$patch = $this->withHeaders($this->authHeaders()) $patch = $this->withHeaders($this->authHeaders())
->withBodyFormat('json') ->withBodyFormat('json')
->call('patch', "{$this->endpoint}/{$id}", ['TestMapName' => 'Updated TestMap']); ->call('patch', "{$this->endpoint}/{$id}", ['ClientType' => 'WST']);
$patch->assertStatus(200); $patch->assertStatus(200);
$patchData = json_decode($patch->getJSON(), true); $patchData = json_decode($patch->getJSON(), true);
@ -76,15 +73,15 @@ class TestMapPatchTest extends CIUnitTestCase
$show->assertStatus(200); $show->assertStatus(200);
$showData = json_decode($show->getJSON(), true)['data']; $showData = json_decode($show->getJSON(), true)['data'];
$this->assertEquals('Updated TestMap', $showData['TestMapName']); $this->assertEquals('WST', $showData['ClientType']);
$this->assertEquals($testMap['TestMapCode'], $showData['TestMapCode']); $this->assertEquals((string) $testMap['HostID'], (string) $showData['HostID']);
} }
public function testPartialUpdateTestMapNotFound() public function testPartialUpdateTestMapNotFound()
{ {
$patch = $this->withHeaders($this->authHeaders()) $patch = $this->withHeaders($this->authHeaders())
->withBodyFormat('json') ->withBodyFormat('json')
->call('patch', "{$this->endpoint}/999999", ['TestMapName' => 'Updated']); ->call('patch', "{$this->endpoint}/999999", ['ClientType' => 'WST']);
$patch->assertStatus(404); $patch->assertStatus(404);
} }
@ -93,7 +90,7 @@ class TestMapPatchTest extends CIUnitTestCase
{ {
$patch = $this->withHeaders($this->authHeaders()) $patch = $this->withHeaders($this->authHeaders())
->withBodyFormat('json') ->withBodyFormat('json')
->call('patch', "{$this->endpoint}/invalid", ['TestMapName' => 'Updated']); ->call('patch', "{$this->endpoint}/invalid", ['ClientType' => 'WST']);
$patch->assertStatus(400); $patch->assertStatus(400);
} }
@ -117,28 +114,28 @@ class TestMapPatchTest extends CIUnitTestCase
$patch = $this->withHeaders($this->authHeaders()) $patch = $this->withHeaders($this->authHeaders())
->withBodyFormat('json') ->withBodyFormat('json')
->call('patch', "{$this->endpoint}/{$id}", ['TestMapCode' => 'NEW_' . uniqid()]); ->call('patch', "{$this->endpoint}/{$id}", ['HostID' => 2]);
$patch->assertStatus(200); $patch->assertStatus(200);
$showData = json_decode($this->withHeaders($this->authHeaders()) $showData = json_decode($this->withHeaders($this->authHeaders())
->call('get', "{$this->endpoint}/{$id}") ->call('get', "{$this->endpoint}/{$id}")
->getJSON(), true)['data']; ->getJSON(), true)['data'];
$this->assertNotEquals($testMap['TestMapCode'], $showData['TestMapCode']); $this->assertEquals('2', (string) $showData['HostID']);
$this->assertEquals($testMap['TestMapName'], $showData['TestMapName']); $this->assertEquals((string) $testMap['ClientID'], (string) $showData['ClientID']);
} }
public function testCreateTestMapWithDetails() public function testCreateTestMapWithDetails()
{ {
$details = [ $details = [
[ [
'HostTestCode' => 'HB_' . uniqid(), 'HostTestCode' => 'HB',
'HostTestName' => 'Hemoglobin', 'HostTestName' => 'Hemoglobin',
'ClientTestCode' => '2', 'ClientTestCode' => '2',
'ClientTestName' => 'Hemoglobin', 'ClientTestName' => 'Hemoglobin',
], ],
[ [
'HostTestCode' => 'HCT_' . uniqid(), 'HostTestCode' => 'HCT',
'HostTestName' => 'Hematocrit', 'HostTestName' => 'Hematocrit',
'ClientTestCode' => '3', 'ClientTestCode' => '3',
'ClientTestName' => 'Hematocrit', 'ClientTestName' => 'Hematocrit',
@ -161,13 +158,13 @@ class TestMapPatchTest extends CIUnitTestCase
{ {
$initialDetails = [ $initialDetails = [
[ [
'HostTestCode' => 'HB_' . uniqid(), 'HostTestCode' => 'HB',
'HostTestName' => 'Hemoglobin', 'HostTestName' => 'Hemoglobin',
'ClientTestCode' => '2', 'ClientTestCode' => '2',
'ClientTestName' => 'Hemoglobin', 'ClientTestName' => 'Hemoglobin',
], ],
[ [
'HostTestCode' => 'HCT_' . uniqid(), 'HostTestCode' => 'HCT',
'HostTestName' => 'Hematocrit', 'HostTestName' => 'Hematocrit',
'ClientTestCode' => '3', 'ClientTestCode' => '3',
'ClientTestName' => 'Hematocrit', 'ClientTestName' => 'Hematocrit',
@ -199,7 +196,7 @@ class TestMapPatchTest extends CIUnitTestCase
], ],
'new' => [ 'new' => [
[ [
'HostTestCode' => 'MCV_' . uniqid(), 'HostTestCode' => 'MCV',
'HostTestName' => 'MCV', 'HostTestName' => 'MCV',
'ClientTestCode' => '4', 'ClientTestCode' => '4',
'ClientTestName' => 'MCV', 'ClientTestName' => 'MCV',
@ -237,7 +234,7 @@ class TestMapPatchTest extends CIUnitTestCase
'ClientID' => 3, 'ClientID' => 3,
'details' => [ 'details' => [
[ [
'HostTestCode' => 'PLT_' . uniqid(), 'HostTestCode' => 'PLT',
'HostTestName' => 'Platelet', 'HostTestName' => 'Platelet',
'ClientTestCode' => '5', 'ClientTestCode' => '5',
'ClientTestName' => 'Platelet', 'ClientTestName' => 'Platelet',

View File

@ -1,5 +0,0 @@
### TestMap detail sync fix
- Investigate why `TestMapController::create` and `patch` still reject payloads (400) despite passing required fields; log output hints validation errors.
- Complete detail operation helpers (new/edit/deleted) so frontend payload works end-to-end and rerun feature tests.
- Update tests once endpoints behave (remove stderr logging) and verify `phpunit tests/feature/Test/TestMapPatchTest.php` passes.
- Confirm OpenAPI docs reflect final behavior and bundle output already up-to-date.