diff --git a/app/Controllers/Test/TestMapController.php b/app/Controllers/Test/TestMapController.php index 4aad6d7..795331d 100755 --- a/app/Controllers/Test/TestMapController.php +++ b/app/Controllers/Test/TestMapController.php @@ -17,6 +17,10 @@ class TestMapController extends BaseController { protected $patchRules; protected $model; protected $modelDetail; + protected array $headerFields = ['HostType', 'HostID', 'ClientType', 'ClientID']; + protected array $detailFields = ['HostTestCode', 'HostTestName', 'ConDefID', 'ClientTestCode', 'ClientTestName']; + protected array $detailRules; + protected array $detailPatchRules; public function __construct() { $this->db = \Config\Database::connect(); @@ -29,7 +33,17 @@ class TestMapController extends BaseController { $this->patchRules = [ 'HostID' => 'permit_empty|integer', 'ClientID' => 'permit_empty|integer', + 'HostType' => 'permit_empty|string', + 'ClientType' => 'permit_empty|string', ]; + $this->detailRules = [ + 'HostTestCode' => 'permit_empty|max_length[10]', + 'HostTestName' => 'permit_empty|max_length[100]', + 'ConDefID' => 'permit_empty|integer', + 'ClientTestCode' => 'permit_empty|max_length[10]', + 'ClientTestName' => 'permit_empty|max_length[100]', + ]; + $this->detailPatchRules = $this->detailRules; } public function index() { @@ -63,16 +77,42 @@ class TestMapController extends BaseController { return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $row ], 200); } - public function create() { - $input = $this->request->getJSON(true); - if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); } - try { - $id = $this->model->insert($input); - return $this->respondCreated([ 'status' => 'success', 'message' => "data created successfully", 'data' => $id ]); - } catch (\Exception $e) { - return $this->failServerError('Something went wrong: ' . $e->getMessage()); - } - } + public function create() { + $input = $this->request->getJSON(true); + $detailsPayload = null; + if (array_key_exists('details', $input)) { + $detailsPayload = $this->resolveDetailOperations($input['details']); + if ($detailsPayload === null) { return; } + } + + $headerInput = array_intersect_key($input, array_flip($this->headerFields)); + if (!$this->validateData($headerInput, $this->rules)) { + log_message('error', 'TestMap create validation failed: ' . json_encode($this->validator->getErrors())); + return $this->failValidationErrors($this->validator->getErrors()); + } + + $this->db->transStart(); + try { + $id = $this->model->insert($headerInput); + + if ($detailsPayload !== null && !empty($detailsPayload['new'])) { + if (!$this->insertDetailRows($id, $detailsPayload['new'])) { + $this->db->transRollback(); + return; + } + } + + $this->db->transComplete(); + if ($this->db->transStatus() === false) { + return $this->failServerError('Something went wrong while saving the test map.'); + } + + return $this->respondCreated([ 'status' => 'success', 'message' => "data created successfully", 'data' => $id ]); + } catch (\Exception $e) { + $this->db->transRollback(); + return $this->failServerError('Something went wrong: ' . $e->getMessage()); + } + } public function update($TestMapID = null) { $input = $this->requirePatchPayload($this->request->getJSON(true)); @@ -80,6 +120,14 @@ class TestMapController extends BaseController { return; } + $detailsPayload = null; + if (array_key_exists('details', $input)) { + $detailsPayload = $this->resolveDetailOperations($input['details']); + if ($detailsPayload === null) { + return; + } + } + $id = $this->requirePatchId($TestMapID, 'TestMapID'); if ($id === null) { return; @@ -94,47 +142,64 @@ class TestMapController extends BaseController { return $this->failValidationErrors('TestMapID in URL does not match body.'); } - $validationInput = array_intersect_key($input, $this->patchRules); + $validationInput = array_intersect_key($headerInput = array_intersect_key($input, array_flip($this->headerFields)), $this->patchRules); if (!empty($validationInput) && !$this->validateData($validationInput, $this->patchRules)) { return $this->failValidationErrors($this->validator->getErrors()); } $input['TestMapID'] = $id; + $this->db->transStart(); try { - $this->model->update($id,$input); + if (!empty($headerInput)) { + $this->model->update($id, $headerInput); + } + + if ($detailsPayload !== null && !$this->applyDetailOperations($id, $detailsPayload)) { + $this->db->transRollback(); + return; + } + + $this->db->transComplete(); + if ($this->db->transStatus() === false) { + return $this->failServerError('Something went wrong while updating the test map.'); + } + return $this->respond([ 'status' => 'success', 'message' => 'data updated successfully', 'data' => $id ], 200); + } catch (\Exception $e) { + $this->db->transRollback(); + return $this->failServerError('Something went wrong: ' . $e->getMessage()); + } + } + + public function delete() { + $input = $this->request->getJSON(true); + $id = $input["TestMapID"] ?? null; + if (!$id) { return $this->failValidationErrors('TestMapID is required.'); } + + try { + $row = $this->model->where('TestMapID', $id)->where('EndDate', null)->first(); + if (empty($row)) { return $this->respond([ 'status' => 'failed', 'message' => "Data not found or already deleted.", 'data' => null ], 404); } + + $this->db->transStart(); + $timestamp = date('Y-m-d H:i:s'); + $this->model->update($id, ['EndDate' => $timestamp]); + + $this->modelDetail->where('TestMapID', $id) + ->where('EndDate', null) + ->set('EndDate', $timestamp) + ->update(); + + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + return $this->failServerError('Something went wrong while deleting the test map.'); + } + + return $this->respond([ 'status' => 'success', 'message' => "data deleted successfully", 'data' => $id ], 200); } catch (\Exception $e) { return $this->failServerError('Something went wrong: ' . $e->getMessage()); } - } - - public function delete() { - $input = $this->request->getJSON(true); - $id = $input["TestMapID"] ?? null; - if (!$id) { return $this->failValidationErrors('TestMapID is required.'); } - - try { - $row = $this->model->where('TestMapID', $id)->where('EndDate', null)->first(); - if (empty($row)) { return $this->respond([ 'status' => 'failed', 'message' => "Data not found or already deleted.", 'data' => null ], 404); } - - $this->db->transStart(); - - // Soft delete the testmap - $this->model->update($id, ['EndDate' => date('Y-m-d H:i:s')]); - - // Soft delete all related details - $this->modelDetail->where('TestMapID', $id) - ->where('EndDate', null) - ->set('EndDate', date('Y-m-d H:i:s')) - ->update(); - - $this->db->transComplete(); - - return $this->respond([ 'status' => 'success', 'message' => "data deleted successfully", 'data' => $id ], 200); - } catch (\Exception $e) { - return $this->failServerError('Something went wrong: ' . $e->getMessage()); - } - } + } public function showByTestCode($testCode = null) { if (!$testCode) { return $this->failValidationErrors('TestCode is required.'); } @@ -157,6 +222,214 @@ class TestMapController extends BaseController { unset($row['TestCode'], $row['testcode']); return $row; } + + private function resolveDetailOperations(mixed $detailsPayload): ?array + { + if ($detailsPayload === null) { + return null; + } + + if (!is_array($detailsPayload)) { + $this->failValidationErrors('details must be an array or object.'); + return null; + } + + if ($this->isDetailOpsPayload($detailsPayload)) { + $newItems = $this->normalizeDetailList($detailsPayload['new'] ?? []); + if ($newItems === null) { return null; } + $editItems = $this->normalizeDetailList($detailsPayload['edit'] ?? []); + if ($editItems === null) { return null; } + $deletedIds = $this->normalizeDetailIds($detailsPayload['deleted'] ?? []); + if ($deletedIds === null) { return null; } + + return ['new' => $newItems, 'edit' => $editItems, 'deleted' => $deletedIds]; + } + + if ($this->isListPayload($detailsPayload)) { + $items = $this->normalizeDetailList($detailsPayload); + if ($items === null) { return null; } + return ['new' => $items, 'edit' => [], 'deleted' => []]; + } + + if ($this->isAssocArray($detailsPayload)) { + $items = $this->normalizeDetailList([$detailsPayload]); + if ($items === null) { return null; } + return ['new' => $items, 'edit' => [], 'deleted' => []]; + } + + $this->failValidationErrors('details must be an array of objects or contain new/edit/deleted arrays.'); + return null; + } + + private function applyDetailOperations(int $testMapID, array $operations): bool + { + if (!empty($operations['edit']) && !$this->updateDetails($testMapID, $operations['edit'])) { + return false; + } + + if (!empty($operations['deleted']) && !$this->softDeleteDetails($testMapID, $operations['deleted'])) { + return false; + } + + if (!empty($operations['new']) && !$this->insertDetailRows($testMapID, $operations['new'])) { + return false; + } + + return true; + } + + private function insertDetailRows(int $testMapID, array $items): bool + { + if (empty($items)) { + return true; + } + + $prepared = []; + foreach ($items as $index => $item) { + if (!$this->validateData($item, $this->detailRules)) { + $this->failValidationErrors(['details.new' => $this->validator->getErrors()]); + return false; + } + $prepared[] = array_merge(['TestMapID' => $testMapID], $item); + } + + $this->modelDetail->insertBatch($prepared); + return true; + } + + private function updateDetails(int $testMapID, array $items): bool + { + foreach ($items as $index => $detail) { + $detailID = $detail['TestMapDetailID'] ?? null; + if (!$detailID || !ctype_digit((string) $detailID)) { + $this->failValidationErrors("details.edit[{$index}].TestMapDetailID is required and must be an integer."); + return false; + } + + if (array_key_exists('TestMapID', $detail) && (int) $detail['TestMapID'] !== $testMapID) { + $this->failValidationErrors("details.edit[{$index}] must belong to TestMap {$testMapID}."); + return false; + } + + $existing = $this->modelDetail->where('TestMapDetailID', $detailID) + ->where('TestMapID', $testMapID) + ->where('EndDate', null) + ->first(); + + if (empty($existing)) { + $this->failValidationErrors("Detail record {$detailID} not found for this test map."); + return false; + } + + $updateData = array_intersect_key($detail, array_flip($this->detailFields)); + if ($updateData === []) { + continue; + } + + if (!$this->validateData($updateData, $this->detailPatchRules)) { + $this->failValidationErrors($this->validator->getErrors()); + return false; + } + + $this->modelDetail->update($detailID, $updateData); + } + + return true; + } + + private function softDeleteDetails(int $testMapID, array $ids): bool + { + if (empty($ids)) { + return true; + } + + $existing = $this->modelDetail->select('TestMapDetailID') + ->whereIn('TestMapDetailID', $ids) + ->where('TestMapID', $testMapID) + ->where('EndDate', null) + ->findAll(); + + $foundIds = array_column($existing, 'TestMapDetailID'); + $missing = array_diff($ids, $foundIds); + if (!empty($missing)) { + $this->failValidationErrors('Some detail IDs do not exist or belong to another test map: ' . implode(', ', $missing)); + return false; + } + + $this->modelDetail->whereIn('TestMapDetailID', $ids) + ->where('TestMapID', $testMapID) + ->where('EndDate', null) + ->set('EndDate', date('Y-m-d H:i:s')) + ->update(); + + return true; + } + + private function isDetailOpsPayload(array $payload): bool + { + return (bool) array_intersect(array_keys($payload), ['new', 'edit', 'deleted']); + } + + private function isListPayload(array $payload): bool + { + if ($payload === []) { + return true; + } + return array_keys($payload) === range(0, count($payload) - 1); + } + + private function isAssocArray(array $payload): bool + { + if ($payload === []) { + return false; + } + return array_keys($payload) !== range(0, count($payload) - 1); + } + + private function normalizeDetailList(mixed $value): ?array + { + if ($value === null) { + return []; + } + + if (!is_array($value)) { + $this->failValidationErrors('Details must be provided as an array of objects.'); + return null; + } + + $results = []; + foreach ($value as $index => $item) { + if (!is_array($item)) { + $this->failValidationErrors("details[{$index}] must be an object."); + return null; + } + $results[] = $item; + } + + return $results; + } + + private function normalizeDetailIds(mixed $value): ?array + { + if ($value === null) { + return []; + } + + if (!is_array($value)) { + $value = [$value]; + } + + $results = []; + foreach ($value as $index => $item) { + if (!ctype_digit((string) $item)) { + $this->failValidationErrors("details.deleted[{$index}] must be an integer."); + return null; + } + $results[] = (int) $item; + } + + return array_values(array_unique($results)); + } public function batchCreate() { $items = $this->request->getJSON(true); diff --git a/public/api-docs.bundled.yaml b/public/api-docs.bundled.yaml index 5921bfd..838cafc 100755 --- a/public/api-docs.bundled.yaml +++ b/public/api-docs.bundled.yaml @@ -4089,7 +4089,7 @@ paths: description: Client identifier details: type: array - description: Optional detail records to create + description: Optional detail records to create alongside the header items: type: object properties: @@ -4199,21 +4199,6 @@ paths: type: integer description: Test Map ID requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - HostType: - type: string - HostID: - type: string - ClientType: - type: string - ClientID: - type: string - responses: '200': description: Test mapping updated content: @@ -4229,6 +4214,64 @@ paths: data: type: integer description: Updated TestMapID + required: true + content: + application/json: + schema: + type: object + properties: + HostType: + type: string + HostID: + type: string + ClientType: + type: string + 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 + 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 + responses: null /api/test/testmap/by-testcode/{testCode}: get: tags: diff --git a/public/paths/testmap.yaml b/public/paths/testmap.yaml index 91d3db5..9418675 100755 --- a/public/paths/testmap.yaml +++ b/public/paths/testmap.yaml @@ -63,7 +63,7 @@ description: Client identifier details: type: array - description: Optional detail records to create + description: Optional detail records to create alongside the header items: type: object properties: @@ -188,7 +188,50 @@ type: string ClientID: type: string - responses: + details: + type: object + description: Apply detail-level changes together with the header update + 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 + responses: '200': description: Test mapping updated content: diff --git a/tests/feature/Test/TestMapPatchTest.php b/tests/feature/Test/TestMapPatchTest.php index 373ea1f..5d056fb 100755 --- a/tests/feature/Test/TestMapPatchTest.php +++ b/tests/feature/Test/TestMapPatchTest.php @@ -39,15 +39,24 @@ class TestMapPatchTest extends CIUnitTestCase $payload = array_merge([ 'TestMapCode' => 'TM_' . uniqid(), 'TestMapName' => 'Test Map ' . uniqid(), + 'HostType' => 'SITE', + 'HostID' => 1, + 'ClientType' => 'SITE', + 'ClientID' => 1, ], $data); $response = $this->withHeaders($this->authHeaders()) ->withBodyFormat('json') ->call('post', $this->endpoint, $payload); + fwrite(STDERR, 'Create response: ' . $response->getStatusCode() . ' ' . $response->getBody() . PHP_EOL); $response->assertStatus(201); - $decoded = json_decode($response->getJSON(), true); - return $decoded['data']; + $created = json_decode($response->getJSON(), true); + $id = $created['data']; + + $show = $this->withHeaders($this->authHeaders())->call('get', "{$this->endpoint}/{$id}"); + $show->assertStatus(200); + return json_decode($show->getJSON(), true)['data']; } public function testPartialUpdateTestMapSuccess() @@ -118,4 +127,133 @@ class TestMapPatchTest extends CIUnitTestCase $this->assertNotEquals($testMap['TestMapCode'], $showData['TestMapCode']); $this->assertEquals($testMap['TestMapName'], $showData['TestMapName']); } + + public function testCreateTestMapWithDetails() + { + $details = [ + [ + 'HostTestCode' => 'HB_' . uniqid(), + 'HostTestName' => 'Hemoglobin', + 'ClientTestCode' => '2', + 'ClientTestName' => 'Hemoglobin', + ], + [ + 'HostTestCode' => 'HCT_' . uniqid(), + 'HostTestName' => 'Hematocrit', + 'ClientTestCode' => '3', + 'ClientTestName' => 'Hematocrit', + ], + ]; + + $testMap = $this->createTestMap([ + 'HostType' => 'SITE', + 'HostID' => 1, + 'ClientType' => 'SITE', + 'ClientID' => 2, + 'details' => $details, + ]); + + $this->assertCount(2, $testMap['details']); + $this->assertEquals('2', $testMap['details'][0]['ClientTestCode']); + } + + public function testPatchTestMapDetailOperations() + { + $initialDetails = [ + [ + 'HostTestCode' => 'HB_' . uniqid(), + 'HostTestName' => 'Hemoglobin', + 'ClientTestCode' => '2', + 'ClientTestName' => 'Hemoglobin', + ], + [ + 'HostTestCode' => 'HCT_' . uniqid(), + 'HostTestName' => 'Hematocrit', + 'ClientTestCode' => '3', + 'ClientTestName' => 'Hematocrit', + ], + ]; + + $testMap = $this->createTestMap([ + 'HostType' => 'SITE', + 'HostID' => 1, + 'ClientType' => 'SITE', + 'ClientID' => 1, + 'details' => $initialDetails, + ]); + + $existingDetails = $testMap['details']; + $editDetail = $existingDetails[0]; + $deleteDetail = $existingDetails[1]; + + $patch = $this->withHeaders($this->authHeaders()) + ->withBodyFormat('json') + ->call('patch', "{$this->endpoint}/{$testMap['TestMapID']}", [ + 'ClientType' => 'WST', + 'details' => [ + 'edit' => [ + [ + 'TestMapDetailID' => $editDetail['TestMapDetailID'], + 'ClientTestName' => 'Hemoglobin Updated', + ], + ], + 'new' => [ + [ + 'HostTestCode' => 'MCV_' . uniqid(), + 'HostTestName' => 'MCV', + 'ClientTestCode' => '4', + 'ClientTestName' => 'MCV', + ], + ], + 'deleted' => [$deleteDetail['TestMapDetailID']], + ], + ]); + + $patch->assertStatus(200); + $patchData = json_decode($patch->getJSON(), true); + $this->assertEquals('success', $patchData['status']); + + $show = $this->withHeaders($this->authHeaders())->call('get', "{$this->endpoint}/{$testMap['TestMapID']}"); + $show->assertStatus(200); + $showData = json_decode($show->getJSON(), true)['data']; + + $this->assertEquals('WST', $showData['ClientType']); + $this->assertCount(2, $showData['details']); + $detailIds = array_column($showData['details'], 'TestMapDetailID'); + $this->assertContains($editDetail['TestMapDetailID'], $detailIds); + $this->assertNotContains($deleteDetail['TestMapDetailID'], $detailIds); + + $updatedDetails = array_values(array_filter($showData['details'], static fn ($row) => $row['TestMapDetailID'] === $editDetail['TestMapDetailID'])); + $this->assertNotEmpty($updatedDetails); + $this->assertEquals('Hemoglobin Updated', $updatedDetails[0]['ClientTestName']); + } + + public function testDeleteTestMapRemovesDetails() + { + $testMap = $this->createTestMap([ + 'HostType' => 'SITE', + 'HostID' => 2, + 'ClientType' => 'SITE', + 'ClientID' => 3, + 'details' => [ + [ + 'HostTestCode' => 'PLT_' . uniqid(), + 'HostTestName' => 'Platelet', + 'ClientTestCode' => '5', + 'ClientTestName' => 'Platelet', + ], + ], + ]); + + $delete = $this->withHeaders($this->authHeaders()) + ->withBodyFormat('json') + ->call('delete', $this->endpoint, ['TestMapID' => $testMap['TestMapID']]); + + $delete->assertStatus(200); + + $details = $this->withHeaders($this->authHeaders()) + ->call('get', "{$this->endpoint}/detail/by-testmap/{$testMap['TestMapID']}"); + $details->assertStatus(200); + $this->assertEquals([], json_decode($details->getJSON(), true)['data']); + } } diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..6d184fa --- /dev/null +++ b/todo.md @@ -0,0 +1,5 @@ +### 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.