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

View File

@ -235,9 +235,9 @@ class TestMapController extends BaseController {
}
if ($this->isDetailOpsPayload($detailsPayload)) {
$newItems = $this->normalizeDetailList($detailsPayload['new'] ?? []);
$newItems = $this->normalizeDetailList($detailsPayload['new'] ?? [], 'details.new');
if ($newItems === null) { return null; }
$editItems = $this->normalizeDetailList($detailsPayload['edit'] ?? []);
$editItems = $this->normalizeDetailList($detailsPayload['edit'] ?? [], 'details.edit');
if ($editItems === null) { return null; }
$deletedIds = $this->normalizeDetailIds($detailsPayload['deleted'] ?? []);
if ($deletedIds === null) { return null; }
@ -246,13 +246,13 @@ class TestMapController extends BaseController {
}
if ($this->isListPayload($detailsPayload)) {
$items = $this->normalizeDetailList($detailsPayload);
$items = $this->normalizeDetailList($detailsPayload, 'details');
if ($items === null) { return null; }
return ['new' => $items, 'edit' => [], 'deleted' => []];
}
if ($this->isAssocArray($detailsPayload)) {
$items = $this->normalizeDetailList([$detailsPayload]);
$items = $this->normalizeDetailList([$detailsPayload], 'details');
if ($items === null) { return null; }
return ['new' => $items, 'edit' => [], 'deleted' => []];
}
@ -386,21 +386,25 @@ class TestMapController extends BaseController {
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) {
return [];
}
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;
}
if ($value !== [] && $this->isAssocArray($value)) {
$value = [$value];
}
$results = [];
foreach ($value as $index => $item) {
if (!is_array($item)) {
$this->failValidationErrors("details[{$index}] must be an object.");
$this->failValidationErrors("{$fieldPath}[{$index}] must be an object.");
return null;
}
$results[] = $item;

View File

@ -4199,21 +4199,6 @@ paths:
type: integer
description: Test Map ID
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
content:
application/json:
@ -4229,12 +4214,53 @@ paths:
ClientID:
type: string
details:
type: object
description: Apply detail-level changes together with the header update
properties:
new:
type: array
description: New detail records to insert
description: |
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:
new:
type: array
description: New detail records to insert
items:
type: object
properties:
HostTestCode:
type: string
HostTestName:
type: string
ConDefID:
type: integer
ClientTestCode:
type: string
ClientTestName:
type: string
edit:
type: array
description: Existing detail records to update
items:
type: object
properties:
TestMapDetailID:
type: integer
HostTestCode:
type: string
HostTestName:
type: string
ConDefID:
type: integer
ClientTestCode:
type: string
ClientTestName:
type: string
deleted:
type: array
description: TestMapDetailIDs to soft delete
items:
type: integer
- type: array
description: Shortcut format for creating new details only
items:
type: object
properties:
@ -4248,30 +4274,35 @@ paths:
type: string
ClientTestName:
type: string
edit:
type: array
description: Existing detail records to update
items:
type: object
properties:
TestMapDetailID:
type: integer
HostTestCode:
type: string
HostTestName:
type: string
ConDefID:
type: integer
ClientTestCode:
type: string
ClientTestName:
type: string
deleted:
type: array
description: TestMapDetailIDs to soft delete
items:
type: integer
responses: null
- 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}:
get:
tags:

View File

@ -189,12 +189,53 @@
ClientID:
type: string
details:
type: object
description: Apply detail-level changes together with the header update
properties:
new:
type: array
description: New detail records to insert
description: |
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:
new:
type: array
description: New detail records to insert
items:
type: object
properties:
HostTestCode:
type: string
HostTestName:
type: string
ConDefID:
type: integer
ClientTestCode:
type: string
ClientTestName:
type: string
edit:
type: array
description: Existing detail records to update
items:
type: object
properties:
TestMapDetailID:
type: integer
HostTestCode:
type: string
HostTestName:
type: string
ConDefID:
type: integer
ClientTestCode:
type: string
ClientTestName:
type: string
deleted:
type: array
description: TestMapDetailIDs to soft delete
items:
type: integer
- type: array
description: Shortcut format for creating new details only
items:
type: object
properties:
@ -208,30 +249,20 @@
type: string
ClientTestName:
type: string
edit:
type: array
description: Existing detail records to update
items:
type: object
properties:
TestMapDetailID:
type: integer
HostTestCode:
type: string
HostTestName:
type: string
ConDefID:
type: integer
ClientTestCode:
type: string
ClientTestName:
type: string
deleted:
type: array
description: TestMapDetailIDs to soft delete
items:
type: integer
responses:
- 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:

View File

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

View File

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