clqms-be/app/Controllers/Test/TestMapController.php
mahdahar 7fd3dfddd8 fix: add testmap search filters
Allow the test map list endpoint to filter by host and client, and include container labels in detail responses. Update the API contract and feature coverage to match.
2026-04-16 12:53:46 +07:00

571 lines
20 KiB
PHP
Executable File

<?php
namespace App\Controllers\Test;
use App\Traits\PatchValidationTrait;
use App\Traits\ResponseTrait;
use App\Controllers\BaseController;
use App\Libraries\ValueSet;
use App\Models\Test\TestMapModel;
use App\Models\Test\TestMapDetailModel;
class TestMapController extends BaseController {
use ResponseTrait;
use PatchValidationTrait;
protected $db;
protected $rules;
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();
$this->model = new TestMapModel;
$this->modelDetail = new TestMapDetailModel;
$this->rules = [
'HostID' => 'required|integer',
'ClientID' => 'required|integer',
];
$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() {
$rows = $this->model->getUniqueGroupings();
$rows = $this->applyIndexFilters($rows);
if (empty($rows)) { return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); }
$rows = ValueSet::transformLabels($rows, [
'HostType' => 'entity_type',
'ClientType' => 'entity_type',
]);
$rows = array_map([$this, 'sanitizeTopLevelPayload'], $rows);
return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $rows ], 200);
}
public function show($id = null) {
$row = $this->model->getByIdWithNames($id);
if (empty($row)) { return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null ], 200); }
$row = ValueSet::transformLabels([$row], [
'HostType' => 'entity_type',
'ClientType' => 'entity_type',
])[0];
$row = $this->sanitizeTopLevelPayload($row);
// Include testmapdetail records
$row['details'] = $this->modelDetail->getDetailsByTestMap($id);
return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $row ], 200);
}
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['created'])) {
if (!$this->insertDetailRows($id, $detailsPayload['created'])) {
$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));
if ($input === null) {
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;
}
$existing = $this->model->where('TestMapID', $id)->where('EndDate', null)->first();
if (!$existing) {
return $this->respond([ 'status' => 'failed', 'message' => 'Test map not found', 'data' => [] ], 404);
}
if (isset($input['TestMapID']) && (string) $input['TestMapID'] !== (string) $id) {
return $this->failValidationErrors('TestMapID in URL does not match body.');
}
$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 {
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 showByTestCode($testCode = null) {
if (!$testCode) { return $this->failValidationErrors('TestCode is required.'); }
$rows = $this->model->getMappingsByTestCode($testCode);
if (empty($rows)) { return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); }
$rows = ValueSet::transformLabels($rows, [
'HostType' => 'entity_type',
'ClientType' => 'entity_type',
]);
$rows = array_map([$this, 'sanitizeTopLevelPayload'], $rows);
return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $rows ], 200);
}
private function sanitizeTopLevelPayload(array $row): array
{
unset($row['TestCode'], $row['testcode']);
return $row;
}
private function applyIndexFilters(array $rows): array
{
$hostFilter = trim((string) $this->request->getGet('host'));
$clientFilter = trim((string) $this->request->getGet('client'));
if ($hostFilter === '' && $clientFilter === '') {
return $rows;
}
return array_values(array_filter($rows, function (array $row) use ($hostFilter, $clientFilter): bool {
if ($hostFilter !== '' && !$this->matchesSearch($row, 'Host', $hostFilter)) {
return false;
}
if ($clientFilter !== '' && !$this->matchesSearch($row, 'Client', $clientFilter)) {
return false;
}
return true;
}));
}
private function matchesSearch(array $row, string $prefix, string $filter): bool
{
$haystacks = [
(string) ($row[$prefix . 'Name'] ?? ''),
(string) ($row[$prefix . 'ID'] ?? ''),
(string) ($row[$prefix . 'Type'] ?? ''),
];
$needle = strtolower($filter);
foreach ($haystacks as $value) {
if ($value !== '' && str_contains(strtolower($value), $needle)) {
return true;
}
}
return false;
}
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)) {
$createdItems = $this->normalizeDetailList($detailsPayload['created'] ?? [], 'details.created');
if ($createdItems === null) { return null; }
$editedItems = $this->normalizeDetailList($detailsPayload['edited'] ?? [], 'details.edited');
if ($editedItems === null) { return null; }
$deletedIds = $this->normalizeDetailIds($detailsPayload['deleted'] ?? []);
if ($deletedIds === null) { return null; }
return ['created' => $createdItems, 'edited' => $editedItems, 'deleted' => $deletedIds];
}
if ($this->isListPayload($detailsPayload)) {
$items = $this->normalizeDetailList($detailsPayload, 'details');
if ($items === null) { return null; }
return ['created' => $items, 'edited' => [], 'deleted' => []];
}
if ($this->isAssocArray($detailsPayload)) {
$items = $this->normalizeDetailList([$detailsPayload], 'details');
if ($items === null) { return null; }
return ['created' => $items, 'edited' => [], 'deleted' => []];
}
$this->failValidationErrors('details must be an array of objects or contain created/edited/deleted arrays.');
return null;
}
private function applyDetailOperations(int $testMapID, array $operations): bool
{
if (!empty($operations['edited']) && !$this->updateDetails($testMapID, $operations['edited'])) {
return false;
}
if (!empty($operations['deleted']) && !$this->softDeleteDetails($testMapID, $operations['deleted'])) {
return false;
}
if (!empty($operations['created']) && !$this->insertDetailRows($testMapID, $operations['created'])) {
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.created' => $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.edited[{$index}].TestMapDetailID is required and must be an integer.");
return false;
}
if (array_key_exists('TestMapID', $detail) && (int) $detail['TestMapID'] !== $testMapID) {
$this->failValidationErrors("details.edited[{$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), ['created', 'edited', '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, string $fieldPath): ?array
{
if ($value === null) {
return [];
}
if (!is_array($value)) {
$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("{$fieldPath}[{$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);
if (!is_array($items)) { return $this->failValidationErrors('Expected array of items'); }
$results = ['success' => [], 'failed' => []];
$this->db->transStart();
foreach ($items as $index => $item) {
if (!$this->validateData($item, $this->rules)) {
$results['failed'][] = ['index' => $index, 'errors' => $this->validator->getErrors()];
continue;
}
try {
$id = $this->model->insert($item);
$results['success'][] = ['index' => $index, 'TestMapID' => $id];
} catch (\Exception $e) {
$results['failed'][] = ['index' => $index, 'error' => $e->getMessage()];
}
}
$this->db->transComplete();
return $this->respond([
'status' => empty($results['failed']) ? 'success' : 'partial',
'message' => 'Batch create completed',
'data' => $results
], 200);
}
public function batchUpdate() {
$items = $this->request->getJSON(true);
if (!is_array($items)) { return $this->failValidationErrors('Expected array of items'); }
$results = ['success' => [], 'failed' => []];
$this->db->transStart();
foreach ($items as $index => $item) {
$id = $item['TestMapID'] ?? null;
if (!$id) {
$results['failed'][] = ['index' => $index, 'error' => 'TestMapID required'];
continue;
}
if (!$this->validateData($item, $this->rules)) {
$results['failed'][] = ['index' => $index, 'errors' => $this->validator->getErrors()];
continue;
}
try {
$this->model->update($id, $item);
$results['success'][] = ['index' => $index, 'TestMapID' => $id];
} catch (\Exception $e) {
$results['failed'][] = ['index' => $index, 'error' => $e->getMessage()];
}
}
$this->db->transComplete();
return $this->respond([
'status' => empty($results['failed']) ? 'success' : 'partial',
'message' => 'Batch update completed',
'data' => $results
], 200);
}
public function batchDelete() {
$ids = $this->request->getJSON(true);
if (!is_array($ids)) { return $this->failValidationErrors('Expected array of TestMapIDs'); }
$results = ['success' => [], 'failed' => []];
$this->db->transStart();
foreach ($ids as $id) {
try {
$row = $this->model->where('TestMapID', $id)->where('EndDate', null)->first();
if (empty($row)) {
$results['failed'][] = ['TestMapID' => $id, 'error' => 'Not found or already deleted'];
continue;
}
$this->model->update($id, ['EndDate' => date('Y-m-d H:i:s')]);
$results['success'][] = $id;
} catch (\Exception $e) {
$results['failed'][] = ['TestMapID' => $id, 'error' => $e->getMessage()];
}
}
$this->db->transComplete();
return $this->respond([
'status' => empty($results['failed']) ? 'success' : 'partial',
'message' => 'Batch delete completed',
'data' => $results
], 200);
}
}