feat: require id params for update endpoints

This commit is contained in:
mahdahar 2026-03-16 15:58:56 +07:00
parent 2bcdf09b55
commit aaadd593dd
92 changed files with 4439 additions and 5260 deletions

406
AGENTS.md
View File

@ -1,316 +1,152 @@
# AGENTS.md - Code Guidelines for CLQMS # AGENTS.md - Code Guidelines for CLQMS
> **CLQMS (Clinical Laboratory Quality Management System)** - A headless REST API backend for clinical laboratory workflows built with CodeIgniter 4. > **CLQMS (Clinical Laboratory Quality Management System)** headless REST API backend built on CodeIgniter 4 with a focus on laboratory workflows, JWT authentication, and synchronized OpenAPI documentation.
--- ---
## Build, Test & Lint Commands ## Repository Snapshot
- `app/` holds controllers, models, filters, and traits wired through PSR-4 `App\` namespace.
- `tests/` relies on CodeIgniter's testing helpers plus Faker for deterministic fixtures.
- Shared response helpers and ValueSet lookups live under `app/Libraries` and `app/Traits` and should be reused before introducing new helpers.
- Environment values, secrets, and database credentials live in `.env` but are never committed; treat the file as a reference for defaults.
---
## Build, Lint & Test
All commands run from the repository root.
```bash ```bash
# Run all tests # Run the entire PHPUnit suite
./vendor/bin/phpunit ./vendor/bin/phpunit
# Run a specific test file # Target a single test file (fast verification)
./vendor/bin/phpunit tests/feature/Patients/PatientCreateTest.php ./vendor/bin/phpunit tests/feature/Patients/PatientCreateTest.php
# Run a specific test method # Run one test case by method
./vendor/bin/phpunit --filter testCreatePatientSuccess tests/feature/Patients/PatientCreateTest.php ./vendor/bin/phpunit --filter testCreatePatientSuccess tests/feature/Patients/PatientCreateTest.php
# Run tests with coverage # Generate scaffolding (model, controller, migration)
./vendor/bin/phpunit --coverage-html build/logs/html php spark make:model <Name>
php spark make:controller <Name>
# Run tests by suite
./vendor/bin/phpunit --testsuite App
# Generate scaffolding
php spark make:migration <name> php spark make:migration <name>
php spark make:model <name>
php spark make:controller <name>
# Database migrations # Database migrations
php spark migrate php spark migrate
php spark migrate:rollback php spark migrate:rollback
```
--- # After OpenAPI edits
## Code Style Guidelines
### PHP Standards
- **PHP Version**: 8.1+
- **PSR-4 Autoloading**: `App\` maps to `app/`, `Config\` maps to `app/Config/`
- **PSR-12 Coding Style** (follow where applicable)
### Naming Conventions
| Element | Convention | Example |
|---------|-----------|---------|
| Classes | PascalCase | `PatientController` |
| Methods | camelCase | `createPatient()` |
| Properties | snake_case (legacy) / camelCase (new) | `$patient_id` / `$patientId` |
| Constants | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` |
| Tables | snake_case | `patient_visits` |
| Columns | PascalCase (legacy) | `PatientID`, `NameFirst` |
| JSON fields | PascalCase | `"PatientID": "123"` |
### Imports & Namespaces
- Fully qualified namespaces at the top
- Group imports: Framework first, then App, then external
- Alphabetical order within groups
```php
<?php
namespace App\Controllers;
use CodeIgniter\Controller;
use CodeIgniter\HTTP\ResponseInterface;
use App\Traits\ResponseTrait;
use Firebase\JWT\JWT;
```
### Controller Structure
Controllers handle HTTP requests and delegate business logic to Models. They should NOT contain database queries.
```php
<?php
namespace App\Controllers;
use App\Traits\ResponseTrait;
class ExampleController extends Controller
{
use ResponseTrait;
protected $model;
public function __construct()
{
$this->model = new \App\Models\ExampleModel();
}
public function index()
{
$data = $this->model->findAll();
return $this->respond(['status' => 'success', 'data' => $data], 200);
}
public function create()
{
$data = $this->request->getJSON(true);
$result = $this->model->createWithRelations($data);
return $this->respond(['status' => 'success', 'data' => $result], 201);
}
}
```
### Response Format
All API responses use standardized format:
```php
// Success
return $this->respond([
'status' => 'success',
'message' => 'Operation completed',
'data' => $data
], 200);
// Error
return $this->respond([
'status' => 'failed',
'message' => 'Error description',
'data' => []
], 400);
```
**Note**: Custom `ResponseTrait` automatically converts empty strings to `null`.
### Error Handling
- Use try-catch for JWT and external calls
- Log errors: `log_message('error', $message)`
- Return structured error responses with appropriate HTTP status codes
```php
try {
$decoded = JWT::decode($token, new Key($key, 'HS256'));
} catch (\Firebase\JWT\ExpiredException $e) {
return $this->respond(['status' => 'failed', 'message' => 'Token expired'], 401);
} catch (\Exception $e) {
return $this->respond(['status' => 'failed', 'message' => 'Invalid token'], 401);
}
```
### Database Operations
- Use CodeIgniter Query Builder or Model methods
- Use `helper('utc')` for UTC date conversion
- Wrap multi-table operations in transactions
```php
$this->db->transStart();
// ... database operations
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return $this->respond(['status' => 'error', 'message' => 'Transaction failed'], 500);
}
```
### Model Patterns
- Extend `BaseModel` for automatic UTC date handling
- Use `checkDbError()` for database error detection
```php
<?php
namespace App\Models;
class PatientModel extends BaseModel
{
protected $table = 'patients';
protected $primaryKey = 'PatientID';
protected $allowedFields = ['NameFirst', 'NameLast', ...];
private function checkDbError($db, string $context) {
$error = $db->error();
if (!empty($error['code'])) {
throw new \Exception("{$context} failed: {$error['code']} - {$error['message']}");
}
}
}
```
### Testing Guidelines
```php
<?php
namespace Tests\Feature\Patients;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
use Faker\Factory;
class PatientCreateTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api/patient';
public function testCreatePatientSuccess()
{
$faker = Factory::create('id_ID');
$payload = [...];
$result = $this->withBodyFormat('json')->post($this->endpoint, $payload);
$result->assertStatus(201);
}
}
```
**Test Naming**: `test<Action><Scenario><ExpectedResult>` (e.g., `testCreatePatientValidationFail`)
**Test Status Codes**: 200 (GET/PATCH), 201 (POST), 400 (Validation), 401 (Unauthorized), 404 (Not Found), 500 (Server Error)
### API Design
- **Base URL**: `/api/`
- **Authentication**: JWT token via HttpOnly cookie
- **Content-Type**: `application/json`
- **Methods**: GET (read), POST (create), PATCH (partial update), DELETE (delete)
### Routes Pattern
```php
$routes->group('api/patient', function ($routes) {
$routes->get('/', 'Patient\PatientController::index');
$routes->post('/', 'Patient\PatientController::create');
$routes->get('(:num)', 'Patient\PatientController::show/$1');
$routes->patch('/', 'Patient\PatientController::update');
$routes->delete('/', 'Patient\PatientController::delete');
});
```
### Security
- Use `auth` filter for protected routes
- Sanitize user inputs
- Use parameterized queries
- Store secrets in `.env`, never commit
---
## Project-Specific Conventions
### API Documentation Sync
**CRITICAL**: When updating any controller, you MUST also update the corresponding OpenAPI YAML documentation:
- **Paths**: `public/paths/<resource>.yaml` (e.g., `patients.yaml`, `orders.yaml`)
- **Schemas**: `public/components/schemas/<resource>.yaml`
- **Main file**: `public/api-docs.yaml` (for tags and schema references)
**After updating YAML files**, regenerate the bundled documentation:
```bash
node public/bundle-api-docs.js node public/bundle-api-docs.js
``` ```
This produces `public/api-docs.bundled.yaml` which is used by Swagger UI/Redoc. Use `php spark test --filter <Class>::<method>` when filtering more than one test file is cumbersome.
### Controller-to-YAML Mapping ---
| Controller | YAML Path File | YAML Schema File |
|-----------|----------------|------------------|
| `PatientController` | `paths/patients.yaml` | `components/schemas/patient.yaml` |
| `PatVisitController` | `paths/patient-visits.yaml` | `components/schemas/patient-visit.yaml` |
| `OrderTestController` | `paths/orders.yaml` | `components/schemas/orders.yaml` |
| `SpecimenController` | `paths/specimen.yaml` | `components/schemas/specimen.yaml` |
| `TestsController` | `paths/tests.yaml` | `components/schemas/tests.yaml` |
| `AuthController` | `paths/authentication.yaml` | `components/schemas/authentication.yaml` |
| `ResultController` | `paths/results.yaml` | `components/schemas/*.yaml` |
| `EdgeController` | `paths/edge-api.yaml` | `components/schemas/edge-api.yaml` |
| `LocationController` | `paths/locations.yaml` | `components/schemas/master-data.yaml` |
| `ValueSetController` | `paths/valuesets.yaml` | `components/schemas/valuesets.yaml` |
| `ContactController` | `paths/contact.yaml` | (inline schemas) |
### Legacy Field Naming ## Agent Rules Scan
Database uses PascalCase columns: `PatientID`, `NameFirst`, `Birthdate`, `CreatedAt`, `UpdatedAt` - No `.cursor/rules/*` or `.cursorrules` directory detected; continue without Cursor-specific constraints.
- No `.github/copilot-instructions.md` present; Copilot behaviors revert to general GitHub defaults.
### ValueSet System ---
```php
use App\Libraries\Lookups;
$genders = Lookups::get('gender'); ## Coding Standards
$label = Lookups::getLabel('gender', '1'); // Returns 'Female'
$options = Lookups::getOptions('gender'); ### Language & Formatting
$labeled = Lookups::transformLabels($data, ['Sex' => 'gender']); - PHP 8.1+ is the baseline; enable `declare(strict_types=1)` at the top of new files when practical.
``` - Follow PSR-12 for spacing, line length (~120), and brace placement; prefer 4 spaces and avoid tabs.
- Use short arrays `[]`, and wrap multiline arguments/arrays with one-per-line items.
- Favor expression statements that return early (guard clauses) and keep nested logic shallow.
- Keep methods under ~40 lines when possible; extract private helpers for repeated flows.
### Naming & Types
- Classes, controllers, libraries, and traits: PascalCase (e.g., `PatientImportController`).
- Methods, services, traits: camelCase (`fetchActivePatients`).
- Properties: camelCase for new code; legacy snake_case may persist but avoid new snake_case unless mirroring legacy columns.
- Constants: UPPER_SNAKE_CASE.
- DTOs/array shapes: Use descriptive names (`$patientInput`, `$validatedPayload`).
- Type hints required for method arguments/returns; use union/nullables (e.g., `?string`) instead of doc-only comments.
- Prefer PHPDoc only when type inference fails (complex union or array shapes) but still keep method summaries concise.
### Imports & Structure
- Namespace declarations at the very top followed by grouped `use` statements.
- Import order: Core framework (`CodeIgniter`), then `App\`, then third-party packages (Firebase, Faker, etc.). Keep each group alphabetical.
- No inline `use` statements inside methods.
- Keep `use` statements de-duplicated; rely on IDE or `phpcbf` to reorder.
### Controller Structure
- Controllers orchestrate request validation, delegates to services/models, and return `ResponseTrait` responses; avoid direct DB queries here.
- Inject models/services via constructor when they are reused. When instantiating on the fly, reference FQCN (`new \App\Models\...`).
- Map HTTP verbs to semantic methods (`index`, `show`, `create`, `update`, `delete`). Keep action methods under 30 lines by delegating heavy lifting to models or libraries.
- Always respond through `$this->respond()` or `$this->respondCreated()` so JSON structure stays consistent.
### Response & Error Handling
- All responses follow `{ status, message, data }`. `status` values: `success`, `failed`, or `error`.
- Use `$this->respondCreated()`, `$this->respondNoContent()`, or `$this->respond()` with explicit HTTP codes.
- Wrap JWT/external calls in try/catch. Log unexpected exceptions with `log_message('error', $e->getMessage())` before responding with a sanitized failure.
- For validation failures, return HTTP 400 with detailed message; unauthorized access returns 401. Maintain parity with existing tests.
### Database & Transactions
- Use Query Builder or Model methods; enable `use App\Models\BaseModel` which handles UTC conversions.
- Always call `helper('utc')` when manipulating timestamps.
- Wrap multi-table changes in `$this->db->transStart()` / `$this->db->transComplete()` and check `transStatus()` to abort if false.
- Run `checkDbError()` (existing helper) after saves when manual queries are necessary.
### Service Helpers & Libraries
- Encapsulate complex lookups (ValueSet, encryption) inside `app/Libraries` or Traits.
- Reuse `App\Libraries\Lookups` for consistent label/value translations.
- Keep shared logic (e.g., response formatting, JWT decoding) inside Traits and import them via `use`.
### Testing & Coverage
- Place feature tests under `tests/Feature`, unit tests under `tests/Unit`.
- Test class names should follow `ClassNameTest`; methods follow `test<Action><Scenario><Result>` (e.g., `testCreatePatientValidationFail`).
- Use `FeatureTestTrait` and `CIUnitTestCase` for API tests; prefer `withBodyFormat('json')->post()` flows.
- Assert status codes: 200 for GET/PATCH, 201 for POST, 400 for validation, 401 for auth, 404 for missing resources, 500 for server errors.
- Run targeted tests during development, full suite before merging.
### Documentation & API Sync
- Whenever a controller or route changes, update `public/paths/<resource>.yaml` and matching `public/components/schemas`. Add tags or schema refs in `public/api-docs.yaml`.
- After editing OpenAPI files, regenerate the bundled docs with `node public/bundle-api-docs.js`. Check `public/api-docs.bundled.yaml` into version control.
- Keep the controller-to-YAML mapping table updated to reflect new resources.
### Routing Conventions
- Keep route definitions grouped inside `$routes->group('api/<resource>')` blocks in `app/Config/Routes.php`.
- Prefer nested controllers (e.g., `Patient\PatientController`) for domain partitioning.
- Use RESTful verbs (GET: index/show, POST: create, PATCH: update, DELETE: delete) to keep behavior predictable.
- Document side effects (snapshots, audit logs) directly in the corresponding OpenAPI `paths` file.
### Environment & Secrets
- Use `.env` as the source of truth for database/jwt settings. Do not commit production credentials.
- Sample values are provided in `.env`; copy to `.env.local` or CI secrets with overrides.
- `JWT_SECRET` must be treated as sensitive and rotated via environment updates only.
### Workflows & Misc
- Use `php spark migrate`/`migrate:rollback` for schema changes.
- For seeding or test fixtures, prefer factories (Faker) seeded in `tests/Support` when available.
- Document major changes in `issues.md` or dedicated feature docs under `docs/` before merging.
### Security & Filters
- Apply the `auth` filter to every protected route, and keep `ApiKey` or other custom filters consolidated under `app/Filters`.
- Sanitize user inputs via `filter_var`, `esc()` helpers, or validated entities before they hit the database.
- Always use parameterized queries/Model `save()` methods to prevent SQL injection, especially with legacy PascalCase columns.
- Respond 401 for missing tokens, 403 when permissions fail, and log sanitized details for ops debugging.
### Legacy Field Naming & ValueSets
- Databases use PascalCase columns such as `PatientID`, `NameFirst`, `CreatedAt`. Keep migration checks aware of these names.
- ValueSet lookups centralize label translation: `Lookups::get('gender')`, `Lookups::getLabel('gender', '1')`, `Lookups::transformLabels($payload, ['Sex' => 'gender'])`.
- Prefer `App\Libraries\Lookups` or `app/Traits/ValueSetTrait` to avoid ad-hoc mappings.
### Nested Data Handling ### Nested Data Handling
For entities with nested data (PatIdt, PatCom, PatAtt): - For entities that carry related collections (`PatIdt`, `PatCom`, `PatAtt`), extract nested arrays before filtering and validating.
- Extract nested arrays before filtering - Use transactions whenever multi-table inserts/updates occur so orphan rows are avoided.
- Use transactions for multi-table operations - Guard against empty/null arrays by normalizing to `[]` before iterating.
- Handle empty/null arrays appropriately
### Observability & Logging
- Use `log_message('info', ...)` for happy-path checkpoints and `'error'` for catch-all failures.
- Avoid leaking sensitive values (tokens, secrets) in logs; log IDs or hash digests instead.
- Keep `writable/logs` clean by rotating or pruning stale log files with automation outside the repo.
--- ---
## Environment Configuration ## Final Notes for Agents
- This repo has no UI layer; focus exclusively on REST interactions.
### Database (`.env`) - Always pull `public/api-docs.bundled.yaml` in after running `node public/bundle-api-docs.js` so downstream services see the latest contract.
```ini - When in doubt, align with existing controller traits and response helpers to avoid duplicating logic.
database.default.hostname = localhost
database.default.database = clqms01
database.default.username = root
database.default.password = adminsakti
database.default.DBDriver = MySQLi
```
### JWT Secret (`.env`)
```ini
JWT_SECRET = '5pandaNdutNdut'
```
---
## Additional Notes
- **API-Only**: No view layer - headless REST API
- **Frontend Agnostic**: Any client can consume these APIs
- **Stateless**: JWT-based authentication per request
- **UTC Dates**: All dates stored in UTC, converted for display
*© 2025 5Panda Team. Engineering Precision in Clinical Diagnostics.*

View File

@ -158,9 +158,9 @@ All API endpoints follow REST conventions:
| Method | Endpoint | Description | Auth Required | | Method | Endpoint | Description | Auth Required |
|--------|----------|-------------|---------------| |--------|----------|-------------|---------------|
| `POST` | `/api/edge/results` | Receive instrument results | API Key | | `POST` | `/api/edge/result` | Receive instrument results | API Key |
| `GET` | `/api/edge/orders` | Fetch pending orders | API Key | | `GET` | `/api/edge/order` | Fetch pending orders | API Key |
| `POST` | `/api/edge/orders/{id}/ack` | Acknowledge order | API Key | | `POST` | `/api/edge/order/{id}/ack` | Acknowledge order | API Key |
| `POST` | `/api/edge/status` | Log instrument status | API Key | | `POST` | `/api/edge/status` | Log instrument status | API Key |
### API Response Format ### API Response Format
@ -524,15 +524,15 @@ The **Edge API** provides endpoints for integrating laboratory instruments via t
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
| `POST` | `/api/edge/results` | Receive instrument results (stored in `edgeres`) | | `POST` | `/api/edge/result` | Receive instrument results (stored in `edgeres`) |
| `GET` | `/api/edge/orders` | Fetch pending orders for an instrument | | `GET` | `/api/edge/order` | Fetch pending orders for an instrument |
| `POST` | `/api/edge/orders/:id/ack` | Acknowledge order delivery to instrument | | `POST` | `/api/edge/order/:id/ack` | Acknowledge order delivery to instrument |
| `POST` | `/api/edge/status` | Log instrument status updates | | `POST` | `/api/edge/status` | Log instrument status updates |
### Workflow ### Workflow
``` ```
Instrument → tiny-edge → POST /api/edge/results → edgeres table → [Manual/Auto Processing] → patres table Instrument → tiny-edge → POST /api/edge/result → edgeres table → [Manual/Auto Processing] → patres table
``` ```
**Key Features:** **Key Features:**

View File

@ -174,7 +174,7 @@ class Database extends Config
'hostname' => 'localhost', 'hostname' => 'localhost',
'username' => 'root', 'username' => 'root',
'password' => 'adminsakti', 'password' => 'adminsakti',
'database' => 'clqms01', 'database' => 'clqms01_test',
'DBDriver' => 'MySQLi', 'DBDriver' => 'MySQLi',
'DBPrefix' => '', // Needed to ensure we're working correctly with prefixes live. DO NOT REMOVE FOR CI DEVS 'DBPrefix' => '', // Needed to ensure we're working correctly with prefixes live. DO NOT REMOVE FOR CI DEVS
'pConnect' => false, 'pConnect' => false,
@ -204,6 +204,9 @@ class Database extends Config
// we are currently running an automated test suite, so that // we are currently running an automated test suite, so that
// we don't overwrite live data on accident. // we don't overwrite live data on accident.
if (ENVIRONMENT === 'testing') { if (ENVIRONMENT === 'testing') {
if ($this->tests['database'] === $this->default['database']) {
throw new \RuntimeException('Tests database cannot match the default database.');
}
$this->defaultGroup = 'tests'; $this->defaultGroup = 'tests';
} }
} }

View File

@ -18,7 +18,7 @@ $routes->group('api', ['filter' => 'auth'], function ($routes) {
$routes->get('sample', 'SampleController::index'); $routes->get('sample', 'SampleController::index');
// Results CRUD // Results CRUD
$routes->group('results', function ($routes) { $routes->group('result', function ($routes) {
$routes->get('/', 'ResultController::index'); $routes->get('/', 'ResultController::index');
$routes->get('(:num)', 'ResultController::show/$1'); $routes->get('(:num)', 'ResultController::show/$1');
$routes->patch('(:num)', 'ResultController::update/$1'); $routes->patch('(:num)', 'ResultController::update/$1');
@ -26,7 +26,7 @@ $routes->group('api', ['filter' => 'auth'], function ($routes) {
}); });
// Reports // Reports
$routes->get('reports/(:num)', 'ReportController::view/$1'); $routes->get('report/(:num)', 'ReportController::view/$1');
}); });
@ -58,7 +58,7 @@ $routes->group('api', function ($routes) {
$routes->post('/', 'Patient\PatientController::create'); $routes->post('/', 'Patient\PatientController::create');
$routes->get('(:num)', 'Patient\PatientController::show/$1'); $routes->get('(:num)', 'Patient\PatientController::show/$1');
$routes->delete('/', 'Patient\PatientController::delete'); $routes->delete('/', 'Patient\PatientController::delete');
$routes->patch('/', 'Patient\PatientController::update'); $routes->patch('(:num)', 'Patient\PatientController::update/$1');
$routes->get('check', 'Patient\PatientController::patientCheck'); $routes->get('check', 'Patient\PatientController::patientCheck');
}); });
@ -69,14 +69,14 @@ $routes->group('api', function ($routes) {
$routes->get('patient/(:num)', 'PatVisitController::showByPatient/$1'); $routes->get('patient/(:num)', 'PatVisitController::showByPatient/$1');
$routes->get('(:any)', 'PatVisitController::show/$1'); $routes->get('(:any)', 'PatVisitController::show/$1');
$routes->delete('/', 'PatVisitController::delete'); $routes->delete('/', 'PatVisitController::delete');
$routes->patch('/', 'PatVisitController::update'); $routes->patch('(:any)', 'PatVisitController::update/$1');
}); });
$routes->group('patvisitadt', function ($routes) { $routes->group('patvisitadt', function ($routes) {
$routes->get('visit/(:num)', 'PatVisitController::getADTByVisit/$1'); $routes->get('visit/(:num)', 'PatVisitController::getADTByVisit/$1');
$routes->get('(:num)', 'PatVisitController::showADT/$1'); $routes->get('(:num)', 'PatVisitController::showADT/$1');
$routes->post('/', 'PatVisitController::createADT'); $routes->post('/', 'PatVisitController::createADT');
$routes->patch('/', 'PatVisitController::updateADT'); $routes->patch('(:num)', 'PatVisitController::updateADT/$1');
$routes->delete('/', 'PatVisitController::deleteADT'); $routes->delete('/', 'PatVisitController::deleteADT');
}); });
@ -87,7 +87,7 @@ $routes->group('api', function ($routes) {
$routes->get('/', 'LocationController::index'); $routes->get('/', 'LocationController::index');
$routes->get('(:num)', 'LocationController::show/$1'); $routes->get('(:num)', 'LocationController::show/$1');
$routes->post('/', 'LocationController::create'); $routes->post('/', 'LocationController::create');
$routes->patch('/', 'LocationController::update'); $routes->patch('(:num)', 'LocationController::update/$1');
$routes->delete('/', 'LocationController::delete'); $routes->delete('/', 'LocationController::delete');
}); });
@ -96,7 +96,7 @@ $routes->group('api', function ($routes) {
$routes->get('/', 'Contact\ContactController::index'); $routes->get('/', 'Contact\ContactController::index');
$routes->get('(:num)', 'Contact\ContactController::show/$1'); $routes->get('(:num)', 'Contact\ContactController::show/$1');
$routes->post('/', 'Contact\ContactController::create'); $routes->post('/', 'Contact\ContactController::create');
$routes->patch('/', 'Contact\ContactController::update'); $routes->patch('(:num)', 'Contact\ContactController::update/$1');
$routes->delete('/', 'Contact\ContactController::delete'); $routes->delete('/', 'Contact\ContactController::delete');
}); });
@ -104,7 +104,7 @@ $routes->group('api', function ($routes) {
$routes->get('/', 'Contact\OccupationController::index'); $routes->get('/', 'Contact\OccupationController::index');
$routes->get('(:num)', 'Contact\OccupationController::show/$1'); $routes->get('(:num)', 'Contact\OccupationController::show/$1');
$routes->post('/', 'Contact\OccupationController::create'); $routes->post('/', 'Contact\OccupationController::create');
$routes->patch('/', 'Contact\OccupationController::update'); $routes->patch('(:num)', 'Contact\OccupationController::update/$1');
//$routes->delete('/', 'Contact\OccupationController::delete'); //$routes->delete('/', 'Contact\OccupationController::delete');
}); });
@ -112,7 +112,7 @@ $routes->group('api', function ($routes) {
$routes->get('/', 'Contact\MedicalSpecialtyController::index'); $routes->get('/', 'Contact\MedicalSpecialtyController::index');
$routes->get('(:num)', 'Contact\MedicalSpecialtyController::show/$1'); $routes->get('(:num)', 'Contact\MedicalSpecialtyController::show/$1');
$routes->post('/', 'Contact\MedicalSpecialtyController::create'); $routes->post('/', 'Contact\MedicalSpecialtyController::create');
$routes->patch('/', 'Contact\MedicalSpecialtyController::update'); $routes->patch('(:num)', 'Contact\MedicalSpecialtyController::update/$1');
}); });
// Lib ValueSet (file-based) // Lib ValueSet (file-based)
@ -159,7 +159,7 @@ $routes->group('api', function ($routes) {
$routes->get('/', 'CounterController::index'); $routes->get('/', 'CounterController::index');
$routes->get('(:num)', 'CounterController::show/$1'); $routes->get('(:num)', 'CounterController::show/$1');
$routes->post('/', 'CounterController::create'); $routes->post('/', 'CounterController::create');
$routes->patch('/', 'CounterController::update'); $routes->patch('(:num)', 'CounterController::update/$1');
$routes->delete('/', 'CounterController::delete'); $routes->delete('/', 'CounterController::delete');
}); });
@ -177,7 +177,7 @@ $routes->group('api', function ($routes) {
$routes->get('/', 'Organization\AccountController::index'); $routes->get('/', 'Organization\AccountController::index');
$routes->get('(:num)', 'Organization\AccountController::show/$1'); $routes->get('(:num)', 'Organization\AccountController::show/$1');
$routes->post('/', 'Organization\AccountController::create'); $routes->post('/', 'Organization\AccountController::create');
$routes->patch('/', 'Organization\AccountController::update'); $routes->patch('(:num)', 'Organization\AccountController::update/$1');
$routes->delete('/', 'Organization\AccountController::delete'); $routes->delete('/', 'Organization\AccountController::delete');
}); });
@ -186,7 +186,7 @@ $routes->group('api', function ($routes) {
$routes->get('/', 'Organization\SiteController::index'); $routes->get('/', 'Organization\SiteController::index');
$routes->get('(:num)', 'Organization\SiteController::show/$1'); $routes->get('(:num)', 'Organization\SiteController::show/$1');
$routes->post('/', 'Organization\SiteController::create'); $routes->post('/', 'Organization\SiteController::create');
$routes->patch('/', 'Organization\SiteController::update'); $routes->patch('(:num)', 'Organization\SiteController::update/$1');
$routes->delete('/', 'Organization\SiteController::delete'); $routes->delete('/', 'Organization\SiteController::delete');
}); });
@ -195,7 +195,7 @@ $routes->group('api', function ($routes) {
$routes->get('/', 'Organization\DisciplineController::index'); $routes->get('/', 'Organization\DisciplineController::index');
$routes->get('(:num)', 'Organization\DisciplineController::show/$1'); $routes->get('(:num)', 'Organization\DisciplineController::show/$1');
$routes->post('/', 'Organization\DisciplineController::create'); $routes->post('/', 'Organization\DisciplineController::create');
$routes->patch('/', 'Organization\DisciplineController::update'); $routes->patch('(:num)', 'Organization\DisciplineController::update/$1');
$routes->delete('/', 'Organization\DisciplineController::delete'); $routes->delete('/', 'Organization\DisciplineController::delete');
}); });
@ -204,7 +204,7 @@ $routes->group('api', function ($routes) {
$routes->get('/', 'Organization\DepartmentController::index'); $routes->get('/', 'Organization\DepartmentController::index');
$routes->get('(:num)', 'Organization\DepartmentController::show/$1'); $routes->get('(:num)', 'Organization\DepartmentController::show/$1');
$routes->post('/', 'Organization\DepartmentController::create'); $routes->post('/', 'Organization\DepartmentController::create');
$routes->patch('/', 'Organization\DepartmentController::update'); $routes->patch('(:num)', 'Organization\DepartmentController::update/$1');
$routes->delete('/', 'Organization\DepartmentController::delete'); $routes->delete('/', 'Organization\DepartmentController::delete');
}); });
@ -213,7 +213,7 @@ $routes->group('api', function ($routes) {
$routes->get('/', 'Organization\WorkstationController::index'); $routes->get('/', 'Organization\WorkstationController::index');
$routes->get('(:num)', 'Organization\WorkstationController::show/$1'); $routes->get('(:num)', 'Organization\WorkstationController::show/$1');
$routes->post('/', 'Organization\WorkstationController::create'); $routes->post('/', 'Organization\WorkstationController::create');
$routes->patch('/', 'Organization\WorkstationController::update'); $routes->patch('(:num)', 'Organization\WorkstationController::update/$1');
$routes->delete('/', 'Organization\WorkstationController::delete'); $routes->delete('/', 'Organization\WorkstationController::delete');
}); });
@ -222,7 +222,7 @@ $routes->group('api', function ($routes) {
$routes->get('/', 'Organization\HostAppController::index'); $routes->get('/', 'Organization\HostAppController::index');
$routes->get('(:any)', 'Organization\HostAppController::show/$1'); $routes->get('(:any)', 'Organization\HostAppController::show/$1');
$routes->post('/', 'Organization\HostAppController::create'); $routes->post('/', 'Organization\HostAppController::create');
$routes->patch('/', 'Organization\HostAppController::update'); $routes->patch('(:any)', 'Organization\HostAppController::update/$1');
$routes->delete('/', 'Organization\HostAppController::delete'); $routes->delete('/', 'Organization\HostAppController::delete');
}); });
@ -231,7 +231,7 @@ $routes->group('api', function ($routes) {
$routes->get('/', 'Organization\HostComParaController::index'); $routes->get('/', 'Organization\HostComParaController::index');
$routes->get('(:any)', 'Organization\HostComParaController::show/$1'); $routes->get('(:any)', 'Organization\HostComParaController::show/$1');
$routes->post('/', 'Organization\HostComParaController::create'); $routes->post('/', 'Organization\HostComParaController::create');
$routes->patch('/', 'Organization\HostComParaController::update'); $routes->patch('(:any)', 'Organization\HostComParaController::update/$1');
$routes->delete('/', 'Organization\HostComParaController::delete'); $routes->delete('/', 'Organization\HostComParaController::delete');
}); });
@ -240,7 +240,7 @@ $routes->group('api', function ($routes) {
$routes->get('/', 'Organization\CodingSysController::index'); $routes->get('/', 'Organization\CodingSysController::index');
$routes->get('(:num)', 'Organization\CodingSysController::show/$1'); $routes->get('(:num)', 'Organization\CodingSysController::show/$1');
$routes->post('/', 'Organization\CodingSysController::create'); $routes->post('/', 'Organization\CodingSysController::create');
$routes->patch('/', 'Organization\CodingSysController::update'); $routes->patch('(:num)', 'Organization\CodingSysController::update/$1');
$routes->delete('/', 'Organization\CodingSysController::delete'); $routes->delete('/', 'Organization\CodingSysController::delete');
}); });
}); });
@ -250,16 +250,16 @@ $routes->group('api', function ($routes) {
$routes->get('/', 'Infrastructure\EquipmentListController::index'); $routes->get('/', 'Infrastructure\EquipmentListController::index');
$routes->get('(:num)', 'Infrastructure\EquipmentListController::show/$1'); $routes->get('(:num)', 'Infrastructure\EquipmentListController::show/$1');
$routes->post('/', 'Infrastructure\EquipmentListController::create'); $routes->post('/', 'Infrastructure\EquipmentListController::create');
$routes->patch('/', 'Infrastructure\EquipmentListController::update'); $routes->patch('(:num)', 'Infrastructure\EquipmentListController::update/$1');
$routes->delete('/', 'Infrastructure\EquipmentListController::delete'); $routes->delete('/', 'Infrastructure\EquipmentListController::delete');
}); });
// Users // Users
$routes->group('users', function ($routes) { $routes->group('user', function ($routes) {
$routes->get('/', 'User\UserController::index'); $routes->get('/', 'User\UserController::index');
$routes->get('(:num)', 'User\UserController::show/$1'); $routes->get('(:num)', 'User\UserController::show/$1');
$routes->post('/', 'User\UserController::create'); $routes->post('/', 'User\UserController::create');
$routes->patch('/', 'User\UserController::update'); $routes->patch('(:num)', 'User\UserController::update/$1');
$routes->delete('(:num)', 'User\UserController::delete/$1'); $routes->delete('(:num)', 'User\UserController::delete/$1');
}); });
@ -270,40 +270,40 @@ $routes->group('api', function ($routes) {
$routes->get('/', 'Specimen\ContainerDefController::index'); $routes->get('/', 'Specimen\ContainerDefController::index');
$routes->get('(:num)', 'Specimen\ContainerDefController::show/$1'); $routes->get('(:num)', 'Specimen\ContainerDefController::show/$1');
$routes->post('/', 'Specimen\ContainerDefController::create'); $routes->post('/', 'Specimen\ContainerDefController::create');
$routes->patch('/', 'Specimen\ContainerDefController::update'); $routes->patch('(:num)', 'Specimen\ContainerDefController::update/$1');
}); });
$routes->group('containerdef', function ($routes) { $routes->group('containerdef', function ($routes) {
$routes->get('/', 'Specimen\ContainerDefController::index'); $routes->get('/', 'Specimen\ContainerDefController::index');
$routes->get('(:num)', 'Specimen\ContainerDefController::show/$1'); $routes->get('(:num)', 'Specimen\ContainerDefController::show/$1');
$routes->post('/', 'Specimen\ContainerDefController::create'); $routes->post('/', 'Specimen\ContainerDefController::create');
$routes->patch('/', 'Specimen\ContainerDefController::update'); $routes->patch('(:num)', 'Specimen\ContainerDefController::update/$1');
}); });
$routes->group('prep', function ($routes) { $routes->group('prep', function ($routes) {
$routes->get('/', 'Specimen\SpecimenPrepController::index'); $routes->get('/', 'Specimen\SpecimenPrepController::index');
$routes->get('(:num)', 'Specimen\SpecimenPrepController::show/$1'); $routes->get('(:num)', 'Specimen\SpecimenPrepController::show/$1');
$routes->post('/', 'Specimen\SpecimenPrepController::create'); $routes->post('/', 'Specimen\SpecimenPrepController::create');
$routes->patch('/', 'Specimen\SpecimenPrepController::update'); $routes->patch('(:num)', 'Specimen\SpecimenPrepController::update/$1');
}); });
$routes->group('status', function ($routes) { $routes->group('status', function ($routes) {
$routes->get('/', 'Specimen\SpecimenStatusController::index'); $routes->get('/', 'Specimen\SpecimenStatusController::index');
$routes->get('(:num)', 'Specimen\SpecimenStatusController::show/$1'); $routes->get('(:num)', 'Specimen\SpecimenStatusController::show/$1');
$routes->post('/', 'Specimen\SpecimenStatusController::create'); $routes->post('/', 'Specimen\SpecimenStatusController::create');
$routes->patch('/', 'Specimen\SpecimenStatusController::update'); $routes->patch('(:num)', 'Specimen\SpecimenStatusController::update/$1');
}); });
$routes->group('collection', function ($routes) { $routes->group('collection', function ($routes) {
$routes->get('/', 'Specimen\SpecimenCollectionController::index'); $routes->get('/', 'Specimen\SpecimenCollectionController::index');
$routes->get('(:num)', 'Specimen\SpecimenCollectionController::show/$1'); $routes->get('(:num)', 'Specimen\SpecimenCollectionController::show/$1');
$routes->post('/', 'Specimen\SpecimenCollectionController::create'); $routes->post('/', 'Specimen\SpecimenCollectionController::create');
$routes->patch('/', 'Specimen\SpecimenCollectionController::update'); $routes->patch('(:num)', 'Specimen\SpecimenCollectionController::update/$1');
}); });
$routes->get('/', 'Specimen\SpecimenController::index'); $routes->get('/', 'Specimen\SpecimenController::index');
$routes->get('(:num)', 'Specimen\SpecimenController::show/$1'); $routes->get('(:num)', 'Specimen\SpecimenController::show/$1');
$routes->post('/', 'Specimen\SpecimenController::create'); $routes->post('/', 'Specimen\SpecimenController::create');
$routes->patch('/', 'Specimen\SpecimenController::update'); $routes->patch('(:num)', 'Specimen\SpecimenController::update/$1');
$routes->delete('(:num)', 'Specimen\SpecimenController::delete/$1'); $routes->delete('(:num)', 'Specimen\SpecimenController::delete/$1');
}); });
@ -312,12 +312,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('/', 'Test\TestsController::update'); $routes->patch('(:num)', '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('/', 'Test\TestMapController::update'); $routes->patch('(:num)', 'Test\TestMapController::update/$1');
$routes->delete('/', 'Test\TestMapController::delete'); $routes->delete('/', 'Test\TestMapController::delete');
// Filter routes // Filter routes
@ -328,7 +328,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('/', 'Test\TestMapDetailController::update'); $routes->patch('(:num)', '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');
@ -343,13 +343,13 @@ $routes->group('api', function ($routes) {
$routes->get('/', 'OrderTestController::index'); $routes->get('/', 'OrderTestController::index');
$routes->get('(:any)', 'OrderTestController::show/$1'); $routes->get('(:any)', 'OrderTestController::show/$1');
$routes->post('/', 'OrderTestController::create'); $routes->post('/', 'OrderTestController::create');
$routes->patch('/', 'OrderTestController::update'); $routes->patch('(:any)', 'OrderTestController::update/$1');
$routes->delete('/', 'OrderTestController::delete'); $routes->delete('/', 'OrderTestController::delete');
$routes->post('status', 'OrderTestController::updateStatus'); $routes->post('status', 'OrderTestController::updateStatus');
}); });
// Rules // Rules
$routes->group('rules', function ($routes) { $routes->group('rule', function ($routes) {
$routes->get('/', 'Rule\RuleController::index'); $routes->get('/', 'Rule\RuleController::index');
$routes->get('(:num)', 'Rule\RuleController::show/$1'); $routes->get('(:num)', 'Rule\RuleController::show/$1');
$routes->post('/', 'Rule\RuleController::create'); $routes->post('/', 'Rule\RuleController::create');
@ -362,14 +362,14 @@ $routes->group('api', function ($routes) {
// Demo/Test Routes (No Auth) // Demo/Test Routes (No Auth)
$routes->group('api/demo', function ($routes) { $routes->group('api/demo', function ($routes) {
$routes->post('order', 'Test\DemoOrderController::createDemoOrder'); $routes->post('order', 'Test\DemoOrderController::createDemoOrder');
$routes->get('orders', 'Test\DemoOrderController::listDemoOrders'); $routes->get('order', 'Test\DemoOrderController::listDemoOrders');
}); });
// Edge API - Integration with tiny-edge // Edge API - Integration with tiny-edge
$routes->group('edge', function ($routes) { $routes->group('edge', function ($routes) {
$routes->post('results', 'EdgeController::results'); $routes->post('result', 'EdgeController::results');
$routes->get('orders', 'EdgeController::orders'); $routes->get('order', 'EdgeController::orders');
$routes->post('orders/(:num)/ack', 'EdgeController::ack/$1'); $routes->post('order/(:num)/ack', 'EdgeController::ack/$1');
$routes->post('status', 'EdgeController::status'); $routes->post('status', 'EdgeController::status');
}); });
}); });

View File

@ -75,8 +75,16 @@ class ContactController extends BaseController {
} }
} }
public function update() { public function update($ContactID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
if (!$ContactID || !ctype_digit((string) $ContactID)) {
return $this->respond([
'status' => 'failed',
'message' => 'ContactID is required and must be a valid integer',
'data' => []
], 400);
}
$input['ContactID'] = (int) $ContactID;
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); } if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); }
try { try {
$this->model->saveContact($input); $this->model->saveContact($input);

View File

@ -51,8 +51,12 @@ class MedicalSpecialtyController extends BaseController {
} }
} }
public function update() { public function update($SpecialtyID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
if (!$SpecialtyID || !ctype_digit((string) $SpecialtyID)) {
return $this->respond(['status' => 'error', 'message' => 'SpecialtyID is required and must be a valid integer'], 400);
}
$input['SpecialtyID'] = (int) $SpecialtyID;
try { try {
$this->model->update($input['SpecialtyID'], $input); $this->model->update($input['SpecialtyID'], $input);
return $this->respondCreated([ 'status' => 'success', 'message' => 'Data updated successfully', 'data' => $input['SpecialtyID'] ], 201); return $this->respondCreated([ 'status' => 'success', 'message' => 'Data updated successfully', 'data' => $input['SpecialtyID'] ], 201);

View File

@ -51,8 +51,12 @@ class OccupationController extends BaseController {
} }
} }
public function update() { public function update($OccupationID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
if (!$OccupationID || !ctype_digit((string) $OccupationID)) {
return $this->respond(['status' => 'error', 'message' => 'OccupationID is required and must be a valid integer'], 400);
}
$input['OccupationID'] = (int) $OccupationID;
try { try {
$this->model->update($input['OccupationID'], $input); $this->model->update($input['OccupationID'], $input);
return $this->respondCreated([ 'status' => 'success', 'message' => 'Data updated successfully', 'data' => $input['OccupationID'] ], 201); return $this->respondCreated([ 'status' => 'success', 'message' => 'Data updated successfully', 'data' => $input['OccupationID'] ], 201);

View File

@ -43,8 +43,12 @@ class CounterController extends BaseController {
} }
} }
public function update() { public function update($CounterID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
if (!$CounterID || !ctype_digit((string) $CounterID)) {
return $this->respond(['status' => 'error', 'message' => 'CounterID is required and must be a valid integer'], 400);
}
$input['CounterID'] = (int) $CounterID;
try { try {
$this->model->update($input['CounterID'], $input); $this->model->update($input['CounterID'], $input);
return $this->respondCreated([ 'status' => 'success', 'message' => 'Data updated successfully', 'data' => $input['CounterID'] ], 201); return $this->respondCreated([ 'status' => 'success', 'message' => 'Data updated successfully', 'data' => $input['CounterID'] ], 201);

View File

@ -19,7 +19,7 @@ class EdgeController extends Controller
} }
/** /**
* POST /api/edge/results * POST /api/edge/result
* Receive results from tiny-edge * Receive results from tiny-edge
*/ */
public function results() public function results()
@ -70,7 +70,7 @@ class EdgeController extends Controller
} }
/** /**
* GET /api/edge/orders * GET /api/edge/order
* Return pending orders for an instrument * Return pending orders for an instrument
*/ */
public function orders() public function orders()
@ -96,7 +96,7 @@ class EdgeController extends Controller
} }
/** /**
* POST /api/edge/orders/:id/ack * POST /api/edge/order/:id/ack
* Acknowledge order delivery * Acknowledge order delivery
*/ */
public function ack($orderId = null) public function ack($orderId = null)

View File

@ -76,11 +76,14 @@ class EquipmentListController extends BaseController {
} }
} }
public function update() { public function update($EID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
try { try {
$EID = $input['EID']; if (!$EID || !ctype_digit((string) $EID)) {
return $this->failValidationErrors('EID is required.');
}
$input['EID'] = (int) $EID;
$this->model->update($EID, $input); $this->model->update($EID, $input);
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',

View File

@ -50,8 +50,16 @@ class LocationController extends BaseController {
} }
} }
public function update() { public function update($LocationID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
if (!$LocationID || !ctype_digit((string) $LocationID)) {
return $this->respond([
'status' => 'failed',
'message' => 'LocationID is required and must be a valid integer',
'data' => []
], 400);
}
$input['LocationID'] = (int) $LocationID;
try { try {
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors( $this->validator->getErrors()); } if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors( $this->validator->getErrors()); }
$result = $this->model->saveLocation($input, true); $result = $this->model->saveLocation($input, true);

View File

@ -191,15 +191,20 @@ class OrderTestController extends Controller {
} }
} }
public function update() { public function update($OrderID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
if (empty($input['OrderID'])) { if ($OrderID === null || $OrderID === '') {
return $this->failValidationErrors(['OrderID' => 'OrderID is required']); return $this->failValidationErrors(['OrderID' => 'OrderID is required']);
} }
if (isset($input['OrderID']) && (string) $input['OrderID'] !== (string) $OrderID) {
return $this->failValidationErrors(['OrderID' => 'OrderID in URL does not match body']);
}
try { try {
$order = $this->model->getOrder($input['OrderID']); $input['OrderID'] = $OrderID;
$order = $this->model->getOrder($OrderID);
if (!$order) { if (!$order) {
return $this->failNotFound('Order not found'); return $this->failNotFound('Order not found');
} }
@ -215,7 +220,7 @@ class OrderTestController extends Controller {
$this->model->update($order['InternalOID'], $updateData); $this->model->update($order['InternalOID'], $updateData);
} }
$updatedOrder = $this->model->getOrder($input['OrderID']); $updatedOrder = $this->model->getOrder($OrderID);
$updatedOrder['Specimens'] = $this->getOrderSpecimens($updatedOrder['InternalOID']); $updatedOrder['Specimens'] = $this->getOrderSpecimens($updatedOrder['InternalOID']);
$updatedOrder['Tests'] = $this->getOrderTests($updatedOrder['InternalOID']); $updatedOrder['Tests'] = $this->getOrderTests($updatedOrder['InternalOID']);

View File

@ -65,8 +65,12 @@ class AccountController extends BaseController {
} }
} }
public function update() { public function update($AccountID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
if (!$AccountID || !ctype_digit((string) $AccountID)) {
return $this->failValidationErrors('ID is required.');
}
$input['AccountID'] = (int) $AccountID;
try { try {
$id = $input['AccountID']; $id = $input['AccountID'];
if (!$id) { return $this->failValidationErrors('ID is required.'); } if (!$id) { return $this->failValidationErrors('ID is required.'); }

View File

@ -78,16 +78,21 @@ class CodingSysController extends BaseController {
} }
} }
public function update() { public function update($CodingSysID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
try { try {
$id = $input['CodingSysID'] ?? null; if (!$CodingSysID || !ctype_digit((string) $CodingSysID)) {
if (!$id) {
return $this->failValidationErrors('CodingSysID is required.'); return $this->failValidationErrors('CodingSysID is required.');
} }
if (isset($input['CodingSysID']) && (string) $input['CodingSysID'] !== (string) $CodingSysID) {
return $this->failValidationErrors('CodingSysID in URL does not match body.');
}
$id = (int) $CodingSysID;
$input['CodingSysID'] = $id;
$this->model->update($id, $input); $this->model->update($id, $input);
return $this->respondCreated(['status' => 'success', 'message' => 'data updated successfully', 'data' => $id], 201); return $this->respondCreated(['status' => 'success', 'message' => 'data updated successfully', 'data' => $id], 201);
} catch (\Throwable $e) { } catch (\Throwable $e) {

View File

@ -62,9 +62,13 @@ class DepartmentController extends BaseController {
} }
} }
public function update() { public function update($DepartmentID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
try { try {
if (!$DepartmentID || !ctype_digit((string) $DepartmentID)) {
return $this->failValidationErrors('ID is required.');
}
$input['DepartmentID'] = (int) $DepartmentID;
$id = $input['DepartmentID']; $id = $input['DepartmentID'];
$this->model->update($id, $input); $this->model->update($id, $input);
return $this->respondCreated([ 'status' => 'success', 'message' => 'data updated successfully', 'data' => $id ], 201); return $this->respondCreated([ 'status' => 'success', 'message' => 'data updated successfully', 'data' => $id ], 201);

View File

@ -63,8 +63,10 @@ class DisciplineController extends BaseController {
} }
} }
public function update() { public function update($DisciplineID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
if (!$DisciplineID || !ctype_digit((string) $DisciplineID)) { return $this->failValidationErrors('ID is required.'); }
$input['DisciplineID'] = (int) $DisciplineID;
$id = $input['DisciplineID']; $id = $input['DisciplineID'];
$this->model->update($id, $input); $this->model->update($id, $input);
return $this->respondCreated([ 'status' => 'success', 'message' => 'data updated successfully', 'data' => $id ], 201); return $this->respondCreated([ 'status' => 'success', 'message' => 'data updated successfully', 'data' => $id ], 201);

View File

@ -82,16 +82,20 @@ class HostAppController extends BaseController {
} }
} }
public function update() { public function update($HostAppID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
try { try {
$id = $input['HostAppID'] ?? null; if ($HostAppID === null || $HostAppID === '') {
if (!$id) {
return $this->failValidationErrors('HostAppID is required.'); return $this->failValidationErrors('HostAppID is required.');
} }
if (isset($input['HostAppID']) && (string) $input['HostAppID'] !== (string) $HostAppID) {
return $this->failValidationErrors('HostAppID in URL does not match body.');
}
$id = $HostAppID;
$input['HostAppID'] = $id;
$this->model->update($id, $input); $this->model->update($id, $input);
return $this->respondCreated(['status' => 'success', 'message' => 'data updated successfully', 'data' => $id], 201); return $this->respondCreated(['status' => 'success', 'message' => 'data updated successfully', 'data' => $id], 201);
} catch (\Throwable $e) { } catch (\Throwable $e) {

View File

@ -82,16 +82,21 @@ class HostComParaController extends BaseController {
} }
} }
public function update() { public function update($HostAppID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
try { try {
$id = $input['HostAppID'] ?? null; if ($HostAppID === null || $HostAppID === '') {
if (!$id) {
return $this->failValidationErrors('HostAppID is required.'); return $this->failValidationErrors('HostAppID is required.');
} }
if (isset($input['HostAppID']) && (string) $input['HostAppID'] !== (string) $HostAppID) {
return $this->failValidationErrors('HostAppID in URL does not match body.');
}
$id = $HostAppID;
$input['HostAppID'] = $id;
$this->model->update($id, $input); $this->model->update($id, $input);
return $this->respondCreated(['status' => 'success', 'message' => 'data updated successfully', 'data' => $id], 201); return $this->respondCreated(['status' => 'success', 'message' => 'data updated successfully', 'data' => $id], 201);
} catch (\Throwable $e) { } catch (\Throwable $e) {

View File

@ -59,7 +59,7 @@ class SiteController extends BaseController {
$validation = service('validation'); $validation = service('validation');
$validation->setRules([ $validation->setRules([
'SiteCode' => 'required|regex_match[/^[A-Z0-9]{2}$/]', 'SiteCode' => 'required|regex_match[/^[A-Z0-9]{2,6}$/]',
'SiteName' => 'required', 'SiteName' => 'required',
]); ]);
@ -75,16 +75,18 @@ class SiteController extends BaseController {
} }
} }
public function update() { public function update($SiteID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
if (!$SiteID || !ctype_digit((string) $SiteID)) { return $this->failValidationErrors('ID is required.'); }
$input['SiteID'] = (int) $SiteID;
$id = $input['SiteID']; $id = $input['SiteID'];
if (!$id) { return $this->failValidationErrors('ID is required.'); }
if (!empty($input['SiteCode'])) { if (!empty($input['SiteCode'])) {
$validation = service('validation'); $validation = service('validation');
$validation->setRules([ $validation->setRules([
'SiteCode' => 'regex_match[/^[A-Z0-9]{2}$/]', 'SiteCode' => 'regex_match[/^[A-Z0-9]{2,6}$/]',
]); ]);
if (!$validation->run($input)) { if (!$validation->run($input)) {

View File

@ -63,9 +63,13 @@ class WorkstationController extends BaseController {
} }
} }
public function update() { public function update($WorkstationID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
try { try {
if (!$WorkstationID || !ctype_digit((string) $WorkstationID)) {
return $this->failValidationErrors('ID is required.');
}
$input['WorkstationID'] = (int) $WorkstationID;
$id = $input['WorkstationID']; $id = $input['WorkstationID'];
$this->model->update($id, $input); $this->model->update($id, $input);
return $this->respondCreated([ 'status' => 'success', 'message' => 'data updated successfully', 'data' => $id ], 201); return $this->respondCreated([ 'status' => 'success', 'message' => 'data updated successfully', 'data' => $id ], 201);

View File

@ -94,13 +94,13 @@ class PatVisitController extends BaseController {
} }
} }
public function update() { public function update($InternalPVID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
if (!$InternalPVID || !is_numeric($InternalPVID)) {
return $this->respond(['status' => 'error', 'message' => 'Invalid or missing ID'], 400);
}
$input['InternalPVID'] = $InternalPVID;
try { try {
if (!isset($input["InternalPVID"]) || !is_numeric($input["InternalPVID"])) {
return $this->respond(['status' => 'error', 'message' => 'Invalid or missing ID'], 400);
}
// Check if visit exists // Check if visit exists
$visit = $this->model->find($input["InternalPVID"]); $visit = $this->model->find($input["InternalPVID"]);
if (!$visit) { if (!$visit) {
@ -174,9 +174,10 @@ class PatVisitController extends BaseController {
} }
} }
public function updateADT() { public function updateADT($PVADTID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
if (!$input["PVADTID"] || !is_numeric($input["PVADTID"])) { return $this->respond(['status' => 'error', 'message' => 'Invalid or missing ID'], 400); } if (!$PVADTID || !is_numeric($PVADTID)) { return $this->respond(['status' => 'error', 'message' => 'Invalid or missing ID'], 400); }
$input['PVADTID'] = $PVADTID;
$modelPVA = new PatVisitADTModel(); $modelPVA = new PatVisitADTModel();
try { try {
$data = $modelPVA->update($input['PVADTID'], $input); $data = $modelPVA->update($input['PVADTID'], $input);

View File

@ -115,9 +115,18 @@ class PatientController extends Controller {
} }
} }
public function update() { public function update($InternalPID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
if (!$InternalPID || !ctype_digit((string) $InternalPID)) {
return $this->respond([
'status' => 'error',
'message' => 'InternalPID is required and must be a valid integer.'
], 400);
}
$input['InternalPID'] = (int) $InternalPID;
// Khusus untuk Override PATIDT // Khusus untuk Override PATIDT
$type = $input['PatIdt']['IdentifierType'] ?? null; $type = $input['PatIdt']['IdentifierType'] ?? null;
$identifierRulesMap = [ $identifierRulesMap = [

View File

@ -23,7 +23,7 @@ class ReportController extends Controller {
/** /**
* Generate HTML lab report for an order * Generate HTML lab report for an order
* GET /api/reports/{orderID} * GET /api/report/{orderID}
*/ */
public function view($orderID) { public function view($orderID) {
try { try {

View File

@ -17,7 +17,7 @@ class ResultController extends Controller {
/** /**
* List results with optional filters * List results with optional filters
* GET /api/results * GET /api/result
*/ */
public function index() { public function index() {
try { try {
@ -57,7 +57,7 @@ class ResultController extends Controller {
/** /**
* Get single result * Get single result
* GET /api/results/{id} * GET /api/result/{id}
*/ */
public function show($id) { public function show($id) {
try { try {
@ -89,7 +89,7 @@ class ResultController extends Controller {
/** /**
* Update result with validation * Update result with validation
* PATCH /api/results/{id} * PATCH /api/result/{id}
*/ */
public function update($id) { public function update($id) {
try { try {
@ -137,7 +137,7 @@ class ResultController extends Controller {
/** /**
* Soft delete result * Soft delete result
* DELETE /api/results/{id} * DELETE /api/result/{id}
*/ */
public function delete($id) { public function delete($id) {
try { try {

View File

@ -73,8 +73,12 @@ class ContainerDefController extends BaseController {
} }
} }
public function update() { public function update($ConDefID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
if (!$ConDefID || !ctype_digit((string) $ConDefID)) {
return $this->failValidationErrors('ConDefID is required.');
}
$input['ConDefID'] = (int) $ConDefID;
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); } if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); }
try { try {
$ConDefID = $this->model->update($input['ConDefID'], $input); $ConDefID = $this->model->update($input['ConDefID'], $input);

View File

@ -66,8 +66,12 @@ class SpecimenCollectionController extends BaseController {
} }
} }
public function update() { public function update($SpcColID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
if (!$SpcColID || !ctype_digit((string) $SpcColID)) {
return $this->failValidationErrors('SpcColID is required.');
}
$input['SpcColID'] = (int) $SpcColID;
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); } if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); }
try { try {
$id = $this->model->update($input['SpcColID'], $input); $id = $this->model->update($input['SpcColID'], $input);

View File

@ -66,8 +66,12 @@ class SpecimenController extends BaseController {
} }
} }
public function update() { public function update($SID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
if (!$SID || !ctype_digit((string) $SID)) {
return $this->failValidationErrors('SID is required.');
}
$input['SID'] = (int) $SID;
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); } if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); }
try { try {
$id = $this->model->update($input['SID'], $input); $id = $this->model->update($input['SID'], $input);

View File

@ -51,8 +51,12 @@ class SpecimenPrepController extends BaseController {
} }
} }
public function update() { public function update($SpcPrpID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
if (!$SpcPrpID || !ctype_digit((string) $SpcPrpID)) {
return $this->failValidationErrors('SpcPrpID is required.');
}
$input['SpcPrpID'] = (int) $SpcPrpID;
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); } if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); }
try { try {
$id = $this->model->update($input['SpcPrpID'], $input); $id = $this->model->update($input['SpcPrpID'], $input);

View File

@ -64,8 +64,12 @@ class ContainerDef extends BaseController {
} }
} }
public function update() { public function update($SpcStaID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
if (!$SpcStaID || !ctype_digit((string) $SpcStaID)) {
return $this->failValidationErrors('SpcStaID is required.');
}
$input['SpcStaID'] = (int) $SpcStaID;
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); } if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); }
try { try {
$id = $this->model->update($input['SpcStaID'], $input); $id = $this->model->update($input['SpcStaID'], $input);

View File

@ -64,10 +64,14 @@ class TestMapController extends BaseController {
} }
} }
public function update() { public function update($TestMapID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
$id = $input["TestMapID"]; if (!$TestMapID || !ctype_digit((string) $TestMapID)) { return $this->failValidationErrors('TestMapID is required.'); }
if (!$id) { return $this->failValidationErrors('TestMapID is required.'); } $id = (int) $TestMapID;
if (isset($input['TestMapID']) && (string) $input['TestMapID'] !== (string) $id) {
return $this->failValidationErrors('TestMapID in URL does not match body.');
}
$input['TestMapID'] = $id;
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors( $this->validator->getErrors() ); } if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors( $this->validator->getErrors() ); }
try { try {
$this->model->update($id,$input); $this->model->update($id,$input);

View File

@ -89,13 +89,16 @@ class TestMapDetailController extends BaseController {
} }
} }
public function update() { public function update($TestMapDetailID = null) {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
$id = $input["TestMapDetailID"] ?? null; if (!$TestMapDetailID || !ctype_digit((string) $TestMapDetailID)) {
if (!$id) {
return $this->failValidationErrors('TestMapDetailID is required.'); return $this->failValidationErrors('TestMapDetailID is required.');
} }
$id = (int) $TestMapDetailID;
if (isset($input['TestMapDetailID']) && (string) $input['TestMapDetailID'] !== (string) $id) {
return $this->failValidationErrors('TestMapDetailID in URL does not match body.');
}
$input['TestMapDetailID'] = $id;
if (!$this->validateData($input, $this->rules)) { if (!$this->validateData($input, $this->rules)) {
return $this->failValidationErrors($this->validator->getErrors()); return $this->failValidationErrors($this->validator->getErrors());

View File

@ -171,7 +171,10 @@ class TestsController extends BaseController
$id = $this->model->insert($testSiteData); $id = $this->model->insert($testSiteData);
if (!$id) { if (!$id) {
throw new \Exception('Failed to insert main test definition'); $dbError = $db->error();
log_message('error', 'Test insert failed: ' . json_encode($dbError, JSON_UNESCAPED_SLASHES));
$message = $dbError['message'] ?? 'Failed to insert main test definition';
throw new \Exception('Failed to insert main test definition: ' . $message);
} }
$this->handleDetails($id, $input, 'insert'); $this->handleDetails($id, $input, 'insert');
@ -179,6 +182,12 @@ class TestsController extends BaseController
$db->transComplete(); $db->transComplete();
if ($db->transStatus() === false) { if ($db->transStatus() === false) {
$dbError = $db->error();
$lastQuery = $db->showLastQuery();
log_message('error', 'TestController transaction failed: ' . json_encode([
'error' => $dbError,
'last_query' => $lastQuery,
], JSON_UNESCAPED_SLASHES));
return $this->failServerError('Transaction failed'); return $this->failServerError('Transaction failed');
} }

View File

@ -25,7 +25,7 @@ class UserController extends BaseController
/** /**
* List users with pagination and search * List users with pagination and search
* GET /api/users?page=1&per_page=20&search=term * GET /api/user?page=1&per_page=20&search=term
*/ */
public function index() public function index()
{ {
@ -81,7 +81,7 @@ class UserController extends BaseController
/** /**
* Get single user by ID * Get single user by ID
* GET /api/users/(:num) * GET /api/user/(:num)
*/ */
public function show($id) public function show($id)
{ {
@ -116,7 +116,7 @@ class UserController extends BaseController
/** /**
* Create new user * Create new user
* POST /api/users * POST /api/user
*/ */
public function create() public function create()
{ {
@ -173,14 +173,14 @@ class UserController extends BaseController
/** /**
* Update existing user * Update existing user
* PATCH /api/users * PATCH /api/user/(:num)
*/ */
public function update() public function update($id)
{ {
try { try {
$data = $this->request->getJSON(true); $data = $this->request->getJSON(true);
if (empty($data['UserID'])) { if (empty($id) || !ctype_digit((string) $id)) {
return $this->respond([ return $this->respond([
'status' => 'failed', 'status' => 'failed',
'message' => 'UserID is required', 'message' => 'UserID is required',
@ -188,7 +188,15 @@ class UserController extends BaseController
], 400); ], 400);
} }
$userId = $data['UserID']; if (isset($data['UserID']) && (string) $data['UserID'] !== (string) $id) {
return $this->respond([
'status' => 'failed',
'message' => 'UserID in URL does not match body',
'data' => null
], 400);
}
$userId = (int) $id;
// Check if user exists // Check if user exists
$user = $this->model->where('UserID', $userId) $user = $this->model->where('UserID', $userId)
@ -243,7 +251,7 @@ class UserController extends BaseController
/** /**
* Delete user (soft delete) * Delete user (soft delete)
* DELETE /api/users/(:num) * DELETE /api/user/(:num)
*/ */
public function delete($id) public function delete($id)
{ {

View File

@ -168,8 +168,12 @@ class TestValidationService
* @param string $refType * @param string $refType
* @return string|null Returns table name or null if no reference table needed * @return string|null Returns table name or null if no reference table needed
*/ */
public static function getReferenceTable(string $resultType, string $refType): ?string public static function getReferenceTable(?string $resultType, ?string $refType): ?string
{ {
if ($resultType === null || $refType === null) {
return null;
}
$resultType = strtoupper($resultType); $resultType = strtoupper($resultType);
$refType = strtoupper($refType); $refType = strtoupper($refType);
@ -182,8 +186,12 @@ class TestValidationService
* @param string $resultType * @param string $resultType
* @return bool * @return bool
*/ */
public static function needsReferenceRanges(string $resultType): bool public static function needsReferenceRanges(?string $resultType): bool
{ {
if ($resultType === null) {
return false;
}
$resultType = strtoupper($resultType); $resultType = strtoupper($resultType);
return $resultType !== 'NORES'; return $resultType !== 'NORES';
} }
@ -195,7 +203,7 @@ class TestValidationService
* @param string $refType * @param string $refType
* @return bool * @return bool
*/ */
public static function usesRefNum(string $resultType, string $refType): bool public static function usesRefNum(?string $resultType, ?string $refType): bool
{ {
return self::getReferenceTable($resultType, $refType) === 'refnum'; return self::getReferenceTable($resultType, $refType) === 'refnum';
} }
@ -207,7 +215,7 @@ class TestValidationService
* @param string $refType * @param string $refType
* @return bool * @return bool
*/ */
public static function usesRefTxt(string $resultType, string $refType): bool public static function usesRefTxt(?string $resultType, ?string $refType): bool
{ {
return self::getReferenceTable($resultType, $refType) === 'reftxt'; return self::getReferenceTable($resultType, $refType) === 'reftxt';
} }

View File

@ -214,20 +214,21 @@ class PatientModel extends BaseModel {
try { try {
$InternalPID = $input['InternalPID']; $InternalPID = $input['InternalPID'];
$previousData = $this->find($InternalPID); $previousData = $this->find($InternalPID) ?? [];
$this->where('InternalPID',$InternalPID)->set($input)->update(); $this->where('InternalPID',$InternalPID)->set($input)->update();
$this->checkDbError($db, 'Update patient'); $this->checkDbError($db, 'Update patient');
$changedFields = array_keys(array_diff_assoc((array) $previousData, (array) $input));
AuditService::logData( AuditService::logData(
'UPDATE', 'UPDATE',
'patient', 'patient',
(string) $InternalPID, (string) $InternalPID,
'patient', 'patient',
null, null,
$previousData, (array) $previousData,
$input, $input,
'Patient data updated', 'Patient data updated',
['changed_fields' => array_keys(array_diff_assoc($previousData, $input))] ['changed_fields' => $changedFields]
); );
if (!empty($input['PatIdt'])) { if (!empty($input['PatIdt'])) {

View File

@ -90,18 +90,19 @@ class RefTxtModel extends BaseModel
*/ */
public function batchInsert($testSiteID, $siteID, $ranges) public function batchInsert($testSiteID, $siteID, $ranges)
{ {
foreach ($ranges as $range) { foreach ($ranges as $range) {
$this->insert([ $this->insert([
'TestSiteID' => $testSiteID, 'TestSiteID' => $testSiteID,
'SiteID' => $siteID, 'SiteID' => $siteID,
'TxtRefType' => $range['TxtRefType'], 'SpcType' => $range['SpcType'] ?? 'GEN',
'Sex' => $range['Sex'], 'TxtRefType' => $range['TxtRefType'],
'AgeStart' => (int) ($range['AgeStart'] ?? 0), 'Sex' => $range['Sex'],
'AgeEnd' => (int) ($range['AgeEnd'] ?? 150), 'AgeStart' => (int) ($range['AgeStart'] ?? 0),
'RefTxt' => $range['RefTxt'] ?? '', 'AgeEnd' => (int) ($range['AgeEnd'] ?? 150),
'Flag' => $range['Flag'] ?? null, 'RefTxt' => $range['RefTxt'] ?? '',
'CreateDate' => date('Y-m-d H:i:s'), 'Flag' => $range['Flag'] ?? null,
]); 'CreateDate' => date('Y-m-d H:i:s'),
} ]);
} }
}
} }

View File

@ -74,7 +74,7 @@ class RuleDefModel extends BaseModel
->get() ->get()
->getResultArray(); ->getResultArray();
return array_column($result, 'TestSiteID'); return array_map('intval', array_column($result, 'TestSiteID'));
} }
/** /**
@ -93,7 +93,8 @@ class RuleDefModel extends BaseModel
->where('RuleID', $ruleID) ->where('RuleID', $ruleID)
->where('TestSiteID', $testSiteID) ->where('TestSiteID', $testSiteID)
->where('EndDate IS NULL') ->where('EndDate IS NULL')
->first(); ->get()
->getRowArray();
if ($existing) { if ($existing) {
return true; // Already linked return true; // Already linked
@ -104,7 +105,8 @@ class RuleDefModel extends BaseModel
->where('RuleID', $ruleID) ->where('RuleID', $ruleID)
->where('TestSiteID', $testSiteID) ->where('TestSiteID', $testSiteID)
->where('EndDate IS NOT NULL') ->where('EndDate IS NOT NULL')
->first(); ->get()
->getRowArray();
if ($softDeleted) { if ($softDeleted) {
return $db->table('testrule') return $db->table('testrule')

View File

@ -11,7 +11,7 @@ class AuditService {
$this->db = \Config\Database::connect(); $this->db = \Config\Database::connect();
} }
public static function logData( public static function logData(
string $operation, string $operation,
string $entityType, string $entityType,
string $entityId, string $entityId,
@ -28,8 +28,8 @@ class AuditService {
'entity_id' => $entityId, 'entity_id' => $entityId,
'table_name' => $tableName, 'table_name' => $tableName,
'field_name' => $fieldName, 'field_name' => $fieldName,
'previous_value' => $previousValue, 'previous_value' => self::normalizeAuditValue($previousValue),
'new_value' => $newValue, 'new_value' => self::normalizeAuditValue($newValue),
'mechanism' => 'MANUAL', 'mechanism' => 'MANUAL',
'application_id' => 'CLQMS-WEB', 'application_id' => 'CLQMS-WEB',
'web_page' => self::getUri(), 'web_page' => self::getUri(),
@ -41,7 +41,7 @@ class AuditService {
'ip_address' => self::getIpAddress(), 'ip_address' => self::getIpAddress(),
'user_id' => self::getUserId(), 'user_id' => self::getUserId(),
'reason' => $reason, 'reason' => $reason,
'context' => $context, 'context' => self::normalizeAuditValue($context),
'created_at' => date('Y-m-d H:i:s') 'created_at' => date('Y-m-d H:i:s')
]); ]);
} }
@ -64,9 +64,9 @@ class AuditService {
'entity_id' => $entityId, 'entity_id' => $entityId,
'service_class' => $serviceClass, 'service_class' => $serviceClass,
'resource_type' => $resourceType, 'resource_type' => $resourceType,
'resource_details' => $resourceDetails, 'resource_details' => self::normalizeAuditValue($resourceDetails),
'previous_value' => $previousValue, 'previous_value' => self::normalizeAuditValue($previousValue),
'new_value' => $newValue, 'new_value' => self::normalizeAuditValue($newValue),
'mechanism' => 'AUTOMATIC', 'mechanism' => 'AUTOMATIC',
'application_id' => $serviceName ?? 'SYSTEM-SERVICE', 'application_id' => $serviceName ?? 'SYSTEM-SERVICE',
'service_name' => $serviceName, 'service_name' => $serviceName,
@ -79,7 +79,7 @@ class AuditService {
'port' => $resourceDetails['port'] ?? null, 'port' => $resourceDetails['port'] ?? null,
'user_id' => 'SYSTEM', 'user_id' => 'SYSTEM',
'reason' => null, 'reason' => null,
'context' => $context, 'context' => self::normalizeAuditValue($context),
'created_at' => date('Y-m-d H:i:s') 'created_at' => date('Y-m-d H:i:s')
]); ]);
} }
@ -102,8 +102,8 @@ class AuditService {
'entity_id' => $entityId, 'entity_id' => $entityId,
'security_class' => $securityClass, 'security_class' => $securityClass,
'resource_path' => $resourcePath, 'resource_path' => $resourcePath,
'previous_value' => $previousValue, 'previous_value' => self::normalizeAuditValue($previousValue),
'new_value' => $newValue, 'new_value' => self::normalizeAuditValue($newValue),
'mechanism' => 'MANUAL', 'mechanism' => 'MANUAL',
'application_id' => 'CLQMS-WEB', 'application_id' => 'CLQMS-WEB',
'web_page' => self::getUri(), 'web_page' => self::getUri(),
@ -115,7 +115,7 @@ class AuditService {
'ip_address' => self::getIpAddress(), 'ip_address' => self::getIpAddress(),
'user_id' => self::getUserId() ?? 'UNKNOWN', 'user_id' => self::getUserId() ?? 'UNKNOWN',
'reason' => $reason, 'reason' => $reason,
'context' => $context, 'context' => self::normalizeAuditValue($context),
'created_at' => date('Y-m-d H:i:s') 'created_at' => date('Y-m-d H:i:s')
]); ]);
} }
@ -138,9 +138,9 @@ class AuditService {
'entity_id' => $entityId, 'entity_id' => $entityId,
'error_code' => $errorCode, 'error_code' => $errorCode,
'error_message' => $errorMessage, 'error_message' => $errorMessage,
'error_details' => $errorDetails, 'error_details' => self::normalizeAuditValue($errorDetails),
'previous_value' => $previousValue, 'previous_value' => self::normalizeAuditValue($previousValue),
'new_value' => $newValue, 'new_value' => self::normalizeAuditValue($newValue),
'mechanism' => 'AUTOMATIC', 'mechanism' => 'AUTOMATIC',
'application_id' => 'CLQMS-WEB', 'application_id' => 'CLQMS-WEB',
'web_page' => self::getUri(), 'web_page' => self::getUri(),
@ -152,16 +152,29 @@ class AuditService {
'ip_address' => self::getIpAddress(), 'ip_address' => self::getIpAddress(),
'user_id' => self::getUserId() ?? 'SYSTEM', 'user_id' => self::getUserId() ?? 'SYSTEM',
'reason' => $reason, 'reason' => $reason,
'context' => $context, 'context' => self::normalizeAuditValue($context),
'created_at' => date('Y-m-d H:i:s') 'created_at' => date('Y-m-d H:i:s')
]); ]);
} }
private static function log(string $table, array $data): void { private static function log(string $table, array $data): void {
$db = \Config\Database::connect(); $db = \Config\Database::connect();
if (!$db->tableExists($table)) {
return;
}
$db->table($table)->insert($data); $db->table($table)->insert($data);
} }
private static function normalizeAuditValue($value)
{
if ($value === null || is_scalar($value)) {
return $value;
}
$json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return $json !== false ? $json : null;
}
private static function getUri(): ?string { private static function getUri(): ?string {
return $_SERVER['REQUEST_URI'] ?? null; return $_SERVER['REQUEST_URI'] ?? null;
} }

View File

@ -9,7 +9,7 @@ Rules are authored using a domain specific language stored in `ruledef.Condition
### Execution Flow ### Execution Flow
1. Write or edit the DSL in `ConditionExpr`. 1. Write or edit the DSL in `ConditionExpr`.
2. POST the expression to `POST /api/rules/compile` to validate syntax and produce compiled JSON. 2. POST the expression to `POST /api/rule/compile` to validate syntax and produce compiled JSON.
3. Save the compiled payload into `ConditionExprCompiled` and persist the rule in `ruledef`. 3. Save the compiled payload into `ConditionExprCompiled` and persist the rule in `ruledef`.
4. Link the rule to one or more tests through `testrule.TestSiteID` (rules only run for linked tests). 4. Link the rule to one or more tests through `testrule.TestSiteID` (rules only run for linked tests).
5. When the configured event fires (`test_created` or `result_updated`), the engine evaluates `ConditionExprCompiled` and runs the resulting `then` or `else` actions. 5. When the configured event fires (`test_created` or `result_updated`), the engine evaluates `ConditionExprCompiled` and runs the resulting `then` or `else` actions.
@ -34,7 +34,7 @@ Rule
└── Actions (what to do) └── Actions (what to do)
``` ```
The DSL expression lives in `ConditionExpr`. The compile endpoint (`/api/rules/compile`) renders the lifeblood of execution, producing `conditionExpr`, `valueExpr`, `then`, and `else` nodes that the engine consumes at runtime. The DSL expression lives in `ConditionExpr`. The compile endpoint (`/api/rule/compile`) renders the lifeblood of execution, producing `conditionExpr`, `valueExpr`, `then`, and `else` nodes that the engine consumes at runtime.
## Syntax Guide ## Syntax Guide
@ -148,7 +148,7 @@ if(requested('GLU'); test_delete('INS'):comment_insert('Duplicate insulin reques
Validates the DSL and returns a compiled JSON structure that should be persisted in `ConditionExprCompiled`. Validates the DSL and returns a compiled JSON structure that should be persisted in `ConditionExprCompiled`.
```http ```http
POST /api/rules/compile POST /api/rule/compile
Content-Type: application/json Content-Type: application/json
{ {
@ -163,7 +163,7 @@ The response contains `raw`, `compiled`, and `conditionExprCompiled` fields; sto
This endpoint simply evaluates an expression against a runtime context. It does not compile DSL or persist the result. This endpoint simply evaluates an expression against a runtime context. It does not compile DSL or persist the result.
```http ```http
POST /api/rules/validate POST /api/rule/validate
Content-Type: application/json Content-Type: application/json
{ {
@ -179,7 +179,7 @@ Content-Type: application/json
### Create Rule (example) ### Create Rule (example)
```http ```http
POST /api/rules POST /api/rule
Content-Type: application/json Content-Type: application/json
{ {
@ -211,7 +211,7 @@ Content-Type: application/json
## Best Practices ## Best Practices
1. Always run `POST /api/rules/compile` before persisting a rule so `ConditionExprCompiled` exists. 1. Always run `POST /api/rule/compile` before persisting a rule so `ConditionExprCompiled` exists.
2. Link each rule to the relevant tests via `testrule.TestSiteID`—rules are scoped to linked tests. 2. Link each rule to the relevant tests via `testrule.TestSiteID`—rules are scoped to linked tests.
3. Use multi-action (`:`) to bundle several actions in a single branch; finish the branch with `nothing` if no further work is needed. 3. Use multi-action (`:`) to bundle several actions in a single branch; finish the branch with `nothing` if no further work is needed.
4. Prefer `comment_insert()` over the removed `set_priority()` action when documenting priority decisions. 4. Prefer `comment_insert()` over the removed `set_priority()` action when documenting priority decisions.
@ -266,12 +266,12 @@ php spark migrate
2. Confirm the rule is linked to the relevant `TestSiteID` in `testrule`. 2. Confirm the rule is linked to the relevant `TestSiteID` in `testrule`.
3. Verify the `EventCode` matches the currently triggered event (`test_created` or `result_updated`). 3. Verify the `EventCode` matches the currently triggered event (`test_created` or `result_updated`).
4. Check that `EndDate IS NULL` for both `ruledef` and `testrule` (soft deletes disable execution). 4. Check that `EndDate IS NULL` for both `ruledef` and `testrule` (soft deletes disable execution).
5. Use `/api/rules/compile` to validate the DSL and view errors. 5. Use `/api/rule/compile` to validate the DSL and view errors.
### Invalid Expression ### Invalid Expression
1. POST the expression to `/api/rules/compile` to get a detailed compilation error. 1. POST the expression to `/api/rule/compile` to get a detailed compilation error.
2. If using `/api/rules/validate`, supply the expected `context` payload; the endpoint simply evaluates the expression without saving it. 2. If using `/api/rule/validate`, supply the expected `context` payload; the endpoint simply evaluates the expression without saving it.
### Runtime Errors ### Runtime Errors

View File

@ -2,7 +2,7 @@
<phpunit <phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="vendor/codeigniter4/framework/system/Test/bootstrap.php" bootstrap="tests/phpunit-bootstrap.php"
backupGlobals="false" backupGlobals="false"
beStrictAboutOutputDuringTests="true" beStrictAboutOutputDuringTests="true"
colors="true" colors="true"
@ -43,7 +43,7 @@
</source> </source>
<php> <php>
<!-- Enable / Disable --> <!-- WAJIB DISESUAIKAN --> <!-- Enable / Disable --> <!-- WAJIB DISESUAIKAN -->
<!-- <env name="CI_ENVIRONMENT" value="testing"/> --> <env name="CI_ENVIRONMENT" value="testing"/>
<!-- <server name="app.baseURL" value="http://example.com/"/> --> <!-- <server name="app.baseURL" value="http://example.com/"/> -->
<server name="app.baseURL" value="http://localhost/clqms01/"/> <!-- WAJIB DISESUAIKAN --> <server name="app.baseURL" value="http://localhost/clqms01/"/> <!-- WAJIB DISESUAIKAN -->
@ -56,7 +56,7 @@
<const name="PUBLICPATH" value="./public/"/> <const name="PUBLICPATH" value="./public/"/>
<!-- Database configuration --> <!-- Database configuration -->
<env name="database.tests.hostname" value="localhost"/> <!-- WAJIB DISESUAIKAN --> <env name="database.tests.hostname" value="localhost"/> <!-- WAJIB DISESUAIKAN -->
<env name="database.tests.database" value="clqms01"/> <!-- WAJIB DISESUAIKAN --> <env name="database.tests.database" value="clqms01_test"/> <!-- WAJIB DISESUAIKAN -->
<env name="database.tests.username" value="root"/> <!-- WAJIB DISESUAIKAN --> <env name="database.tests.username" value="root"/> <!-- WAJIB DISESUAIKAN -->
<env name="database.tests.password" value="adminsakti"/> <!-- WAJIB DISESUAIKAN --> <env name="database.tests.password" value="adminsakti"/> <!-- WAJIB DISESUAIKAN -->
<env name="database.tests.DBDriver" value="MySQLi"/> <!-- WAJIB DISESUAIKAN --> <env name="database.tests.DBDriver" value="MySQLi"/> <!-- WAJIB DISESUAIKAN -->

File diff suppressed because it is too large Load Diff

View File

@ -25,40 +25,40 @@ servers:
tags: tags:
- name: Authentication - name: Authentication
description: User authentication and session management description: User authentication and session management
- name: Patients - name: Patient
description: Patient registration and management description: Patient registration and management
- name: Patient Visits - name: Patient Visit
description: Patient visit/encounter management description: Patient visit/encounter management
- name: Organization - name: Organization
description: Organization structure (accounts, sites, disciplines, departments, workstations) description: Organization structure (accounts, sites, disciplines, departments, workstations)
- name: Location
description: Location management (rooms, wards, buildings)
- name: Equipment
description: Laboratory equipment and instrument management
- name: Specimen - name: Specimen
description: Specimen and container management description: Specimen and container management
- name: Tests - name: Test
description: Test definitions and test catalog description: Test definitions and test catalog
- name: Calculations - name: Rule
description: Rule engine - rules can be linked to multiple tests via testrule mapping table
- name: Calculation
description: Lightweight calculator endpoint for retrieving computed values by code or name description: Lightweight calculator endpoint for retrieving computed values by code or name
- name: Orders - name: Order
description: Laboratory order management description: Laboratory order management
- name: Results - name: Result
description: Patient results reporting with auto-validation description: Patient results reporting with auto-validation
- name: Reports - name: Report
description: Lab report generation (HTML view) description: Lab report generation (HTML view)
- name: Edge API - name: Edge API
description: Instrument integration endpoints description: Instrument integration endpoints
- name: Contacts - name: Contact
description: Contact management (doctors, practitioners, etc.) description: Contact management (doctors, practitioners, etc.)
- name: Locations - name: ValueSet
description: Location management (rooms, wards, buildings)
- name: ValueSets
description: Value set definitions and items description: Value set definitions and items
- name: User
description: User management and administration
- name: Demo - name: Demo
description: Demo/test endpoints (no authentication) description: Demo/test endpoints (no authentication)
- name: EquipmentList
description: Laboratory equipment and instrument management
- name: Users
description: User management and administration
- name: Rules
description: Rule engine - rules can be linked to multiple tests via testrule mapping table
components: components:
securitySchemes: securitySchemes:

View File

@ -1,6 +1,6 @@
/api/calc/{codeOrName}: /api/calc/{codeOrName}:
post: post:
tags: [Calculations] tags: [Calculation]
summary: Evaluate a configured calculation by test code or name and return the numeric result only. summary: Evaluate a configured calculation by test code or name and return the numeric result only.
security: [] security: []
parameters: parameters:

View File

@ -1,6 +1,6 @@
/api/contact: /api/contact:
get: get:
tags: [Contacts] tags: [Contact]
summary: List contacts summary: List contacts
security: security:
- bearerAuth: [] - bearerAuth: []
@ -33,7 +33,7 @@
$ref: '../components/schemas/master-data.yaml#/Contact' $ref: '../components/schemas/master-data.yaml#/Contact'
post: post:
tags: [Contacts] tags: [Contact]
summary: Create new contact summary: Create new contact
security: security:
- bearerAuth: [] - bearerAuth: []
@ -99,9 +99,10 @@
schema: schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse' $ref: '../components/schemas/common.yaml#/ErrorResponse'
patch:
tags: [Contacts] delete:
summary: Update contact tags: [Contact]
summary: Delete contact
security: security:
- bearerAuth: [] - bearerAuth: []
requestBody: requestBody:
@ -112,11 +113,67 @@
type: object type: object
required: required:
- ContactID - ContactID
- NameFirst
properties: properties:
ContactID: ContactID:
type: integer type: integer
description: Contact ID to update description: Contact ID to delete
responses:
'200':
description: Contact deleted successfully
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/SuccessResponse'
/api/contact/{id}:
get:
tags: [Contact]
summary: Get contact by ID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Contact ID
responses:
'200':
description: Contact details
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/master-data.yaml#/Contact'
patch:
tags: [Contact]
summary: Update contact
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Contact ID to update
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- NameFirst
properties:
NameFirst: NameFirst:
type: string type: string
description: First name description: First name
@ -169,56 +226,3 @@
application/json: application/json:
schema: schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse' $ref: '../components/schemas/common.yaml#/ErrorResponse'
delete:
tags: [Contacts]
summary: Delete contact
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- ContactID
properties:
ContactID:
type: integer
description: Contact ID to delete
responses:
'200':
description: Contact deleted successfully
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/SuccessResponse'
/api/contact/{id}:
get:
tags: [Contacts]
summary: Get contact by ID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Contact ID
responses:
'200':
description: Contact details
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/master-data.yaml#/Contact'

View File

@ -1,4 +1,4 @@
/api/edge/results: /api/edge/result:
post: post:
tags: [Edge API] tags: [Edge API]
summary: Receive results from instrument (tiny-edge) summary: Receive results from instrument (tiny-edge)
@ -21,7 +21,7 @@
'400': '400':
description: Invalid JSON payload description: Invalid JSON payload
/api/edge/orders: /api/edge/order:
get: get:
tags: [Edge API] tags: [Edge API]
summary: Fetch pending orders for instruments summary: Fetch pending orders for instruments
@ -53,7 +53,7 @@
items: items:
$ref: '../components/schemas/edge-api.yaml#/EdgeOrder' $ref: '../components/schemas/edge-api.yaml#/EdgeOrder'
/api/edge/orders/{orderId}/ack: /api/edge/order/{orderId}/ack:
post: post:
tags: [Edge API] tags: [Edge API]
summary: Acknowledge order delivery summary: Acknowledge order delivery

View File

@ -1,6 +1,6 @@
/api/equipmentlist: /api/equipmentlist:
get: get:
tags: [EquipmentList] tags: [Equipment]
summary: List equipment summary: List equipment
description: Get list of equipment with optional filters description: Get list of equipment with optional filters
security: security:
@ -50,7 +50,7 @@
$ref: '../components/schemas/equipmentlist.yaml#/EquipmentList' $ref: '../components/schemas/equipmentlist.yaml#/EquipmentList'
post: post:
tags: [EquipmentList] tags: [Equipment]
summary: Create equipment summary: Create equipment
description: Create a new equipment entry description: Create a new equipment entry
security: security:
@ -101,10 +101,11 @@
data: data:
type: integer type: integer
patch:
tags: [EquipmentList] delete:
summary: Update equipment tags: [Equipment]
description: Update an existing equipment entry summary: Delete equipment
description: Soft delete an equipment entry
security: security:
- bearerAuth: [] - bearerAuth: []
requestBody: requestBody:
@ -118,6 +119,68 @@
properties: properties:
EID: EID:
type: integer type: integer
responses:
'200':
description: Equipment deleted
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
/api/equipmentlist/{id}:
get:
tags: [Equipment]
summary: Get equipment by ID
description: Get a single equipment entry by its EID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Equipment ID
responses:
'200':
description: Equipment details
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/equipmentlist.yaml#/EquipmentList'
patch:
tags: [Equipment]
summary: Update equipment
description: Update an existing equipment entry
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Equipment ID
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
IEID: IEID:
type: string type: string
maxLength: 50 maxLength: 50
@ -151,62 +214,3 @@
type: string type: string
data: data:
type: integer type: integer
delete:
tags: [EquipmentList]
summary: Delete equipment
description: Soft delete an equipment entry
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- EID
properties:
EID:
type: integer
responses:
'200':
description: Equipment deleted
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
/api/equipmentlist/{id}:
get:
tags: [EquipmentList]
summary: Get equipment by ID
description: Get a single equipment entry by its EID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Equipment ID
responses:
'200':
description: Equipment details
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/equipmentlist.yaml#/EquipmentList'

View File

@ -1,6 +1,6 @@
/api/location: /api/location:
get: get:
tags: [Locations] tags: [Location]
summary: List locations summary: List locations
security: security:
- bearerAuth: [] - bearerAuth: []
@ -33,7 +33,7 @@
$ref: '../components/schemas/master-data.yaml#/Location' $ref: '../components/schemas/master-data.yaml#/Location'
post: post:
tags: [Locations] tags: [Location]
summary: Create location summary: Create location
security: security:
- bearerAuth: [] - bearerAuth: []
@ -83,9 +83,10 @@
schema: schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse' $ref: '../components/schemas/common.yaml#/ErrorResponse'
patch:
tags: [Locations] delete:
summary: Update location tags: [Location]
summary: Delete location
security: security:
- bearerAuth: [] - bearerAuth: []
requestBody: requestBody:
@ -99,7 +100,62 @@
properties: properties:
LocationID: LocationID:
type: integer type: integer
description: Location ID to update description: Location ID to delete
responses:
'200':
description: Location deleted successfully
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/SuccessResponse'
/api/location/{id}:
get:
tags: [Location]
summary: Get location by ID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Location ID
responses:
'200':
description: Location details
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/master-data.yaml#/Location'
patch:
tags: [Location]
summary: Update location
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Location ID to update
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
SiteID: SiteID:
type: integer type: integer
description: Reference to site description: Reference to site
@ -135,56 +191,3 @@
application/json: application/json:
schema: schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse' $ref: '../components/schemas/common.yaml#/ErrorResponse'
delete:
tags: [Locations]
summary: Delete location
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- LocationID
properties:
LocationID:
type: integer
description: Location ID to delete
responses:
'200':
description: Location deleted successfully
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/SuccessResponse'
/api/location/{id}:
get:
tags: [Locations]
summary: Get location by ID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Location ID
responses:
'200':
description: Location details
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/master-data.yaml#/Location'

View File

@ -1,6 +1,6 @@
/api/ordertest: /api/ordertest:
get: get:
tags: [Orders] tags: [Order]
summary: List orders summary: List orders
security: security:
- bearerAuth: [] - bearerAuth: []
@ -48,7 +48,7 @@
$ref: '../components/schemas/orders.yaml#/OrderTestList' $ref: '../components/schemas/orders.yaml#/OrderTestList'
post: post:
tags: [Orders] tags: [Order]
summary: Create order with specimens and tests summary: Create order with specimens and tests
description: Creates an order with associated specimens and patres records. Tests are grouped by container type to minimize specimen creation. description: Creates an order with associated specimens and patres records. Tests are grouped by container type to minimize specimen creation.
security: security:
@ -123,51 +123,9 @@
'500': '500':
description: Server error description: Server error
patch:
tags: [Orders]
summary: Update order
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- OrderID
properties:
OrderID:
type: string
Priority:
type: string
enum: [R, S, U]
OrderStatus:
type: string
enum: [ORD, SCH, ANA, VER, REV, REP]
OrderingProvider:
type: string
DepartmentID:
type: integer
WorkstationID:
type: integer
responses:
'200':
description: Order updated
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/orders.yaml#/OrderTest'
delete: delete:
tags: [Orders] tags: [Order]
summary: Delete order summary: Delete order
security: security:
- bearerAuth: [] - bearerAuth: []
@ -188,7 +146,7 @@
/api/ordertest/status: /api/ordertest/status:
post: post:
tags: [Orders] tags: [Order]
summary: Update order status summary: Update order status
security: security:
- bearerAuth: [] - bearerAuth: []
@ -231,7 +189,7 @@
/api/ordertest/{id}: /api/ordertest/{id}:
get: get:
tags: [Orders] tags: [Order]
summary: Get order by ID summary: Get order by ID
description: Returns order details with associated specimens and tests description: Returns order details with associated specimens and tests
security: security:
@ -257,3 +215,49 @@
type: string type: string
data: data:
$ref: '../components/schemas/orders.yaml#/OrderTest' $ref: '../components/schemas/orders.yaml#/OrderTest'
patch:
tags: [Order]
summary: Update order
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
description: Order ID
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
Priority:
type: string
enum: [R, S, U]
OrderStatus:
type: string
enum: [ORD, SCH, ANA, VER, REV, REP]
OrderingProvider:
type: string
DepartmentID:
type: integer
WorkstationID:
type: integer
responses:
'200':
description: Order updated
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/orders.yaml#/OrderTest'

View File

@ -43,31 +43,6 @@
'201': '201':
description: Site created description: Site created
patch:
tags: [Organization]
summary: Update site
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
properties:
id:
type: integer
SiteName:
type: string
SiteCode:
type: string
AccountID:
type: integer
responses:
'200':
description: Site updated
delete: delete:
tags: [Organization] tags: [Organization]
@ -105,6 +80,34 @@
'200': '200':
description: Site details description: Site details
patch:
tags: [Organization]
summary: Update site
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
SiteName:
type: string
SiteCode:
type: string
AccountID:
type: integer
responses:
'200':
description: Site updated
/api/organization/discipline: /api/organization/discipline:
get: get:
tags: [Organization] tags: [Organization]
@ -130,35 +133,6 @@
'201': '201':
description: Discipline created description: Discipline created
patch:
tags: [Organization]
summary: Update discipline
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
properties:
id:
type: integer
DisciplineName:
type: string
DisciplineCode:
type: string
SeqScr:
type: integer
description: Display order on screen
SeqRpt:
type: integer
description: Display order in reports
responses:
'200':
description: Discipline updated
delete: delete:
tags: [Organization] tags: [Organization]
@ -196,6 +170,38 @@
'200': '200':
description: Discipline details description: Discipline details
patch:
tags: [Organization]
summary: Update discipline
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
DisciplineName:
type: string
DisciplineCode:
type: string
SeqScr:
type: integer
description: Display order on screen
SeqRpt:
type: integer
description: Display order in reports
responses:
'200':
description: Discipline updated
/api/organization/department: /api/organization/department:
get: get:
tags: [Organization] tags: [Organization]
@ -221,31 +227,6 @@
'201': '201':
description: Department created description: Department created
patch:
tags: [Organization]
summary: Update department
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
properties:
id:
type: integer
DeptName:
type: string
DeptCode:
type: string
SiteID:
type: integer
responses:
'200':
description: Department updated
delete: delete:
tags: [Organization] tags: [Organization]
@ -283,6 +264,34 @@
'200': '200':
description: Department details description: Department details
patch:
tags: [Organization]
summary: Update department
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
DeptName:
type: string
DeptCode:
type: string
SiteID:
type: integer
responses:
'200':
description: Department updated
/api/organization/workstation: /api/organization/workstation:
get: get:
tags: [Organization] tags: [Organization]
@ -308,33 +317,6 @@
'201': '201':
description: Workstation created description: Workstation created
patch:
tags: [Organization]
summary: Update workstation
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
properties:
id:
type: integer
WorkstationName:
type: string
WorkstationCode:
type: string
SiteID:
type: integer
DepartmentID:
type: integer
responses:
'200':
description: Workstation updated
delete: delete:
tags: [Organization] tags: [Organization]
@ -372,6 +354,36 @@
'200': '200':
description: Workstation details description: Workstation details
patch:
tags: [Organization]
summary: Update workstation
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
WorkstationName:
type: string
WorkstationCode:
type: string
SiteID:
type: integer
DepartmentID:
type: integer
responses:
'200':
description: Workstation updated
# HostApp # HostApp
/api/organization/hostapp: /api/organization/hostapp:
get: get:
@ -420,29 +432,6 @@
'201': '201':
description: Host application created description: Host application created
patch:
tags: [Organization]
summary: Update host application
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- HostAppID
properties:
HostAppID:
type: string
HostAppName:
type: string
SiteID:
type: integer
responses:
'200':
description: Host application updated
delete: delete:
tags: [Organization] tags: [Organization]
@ -484,6 +473,32 @@
schema: schema:
$ref: '../components/schemas/organization.yaml#/HostApp' $ref: '../components/schemas/organization.yaml#/HostApp'
patch:
tags: [Organization]
summary: Update host application
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
HostAppName:
type: string
SiteID:
type: integer
responses:
'200':
description: Host application updated
# HostComPara # HostComPara
/api/organization/hostcompara: /api/organization/hostcompara:
get: get:
@ -532,31 +547,6 @@
'201': '201':
description: Host communication parameters created description: Host communication parameters created
patch:
tags: [Organization]
summary: Update host communication parameters
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- HostAppID
properties:
HostAppID:
type: string
HostIP:
type: string
HostPort:
type: string
HostPwd:
type: string
responses:
'200':
description: Host communication parameters updated
delete: delete:
tags: [Organization] tags: [Organization]
@ -598,6 +588,34 @@
schema: schema:
$ref: '../components/schemas/organization.yaml#/HostComPara' $ref: '../components/schemas/organization.yaml#/HostComPara'
patch:
tags: [Organization]
summary: Update host communication parameters
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
HostIP:
type: string
HostPort:
type: string
HostPwd:
type: string
responses:
'200':
description: Host communication parameters updated
# CodingSys # CodingSys
/api/organization/codingsys: /api/organization/codingsys:
get: get:
@ -646,31 +664,6 @@
'201': '201':
description: Coding system created description: Coding system created
patch:
tags: [Organization]
summary: Update coding system
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- CodingSysID
properties:
CodingSysID:
type: integer
CodingSysAbb:
type: string
FullText:
type: string
Description:
type: string
responses:
'200':
description: Coding system updated
delete: delete:
tags: [Organization] tags: [Organization]
@ -711,3 +704,31 @@
application/json: application/json:
schema: schema:
$ref: '../components/schemas/organization.yaml#/CodingSys' $ref: '../components/schemas/organization.yaml#/CodingSys'
patch:
tags: [Organization]
summary: Update coding system
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
CodingSysAbb:
type: string
FullText:
type: string
Description:
type: string
responses:
'200':
description: Coding system updated

View File

@ -1,6 +1,6 @@
/api/patvisit: /api/patvisit:
get: get:
tags: [Patient Visits] tags: [Patient Visit]
summary: List patient visits summary: List patient visits
security: security:
- bearerAuth: [] - bearerAuth: []
@ -72,7 +72,7 @@
description: Number of records per page description: Number of records per page
post: post:
tags: [Patient Visits] tags: [Patient Visit]
summary: Create patient visit summary: Create patient visit
description: | description: |
Creates a new patient visit. PVID is auto-generated with 'DV' prefix if not provided. Creates a new patient visit. PVID is auto-generated with 'DV' prefix if not provided.
@ -145,26 +145,66 @@
InternalPVID: InternalPVID:
type: integer type: integer
delete:
tags: [Patient Visit]
summary: Delete patient visit
security:
- bearerAuth: []
responses:
'200':
description: Visit deleted successfully
/api/patvisit/{id}:
get:
tags: [Patient Visit]
summary: Get visit by ID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
description: PVID (visit identifier like DV00001)
responses:
'200':
description: Visit details
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/patient-visit.yaml#/PatientVisit'
patch: patch:
tags: [Patient Visits] tags: [Patient Visit]
summary: Update patient visit summary: Update patient visit
description: | description: |
Updates an existing patient visit. InternalPVID is required. Updates an existing patient visit. InternalPVID is required.
Can update main visit data, PatDiag, and add new PatVisitADT records. Can update main visit data, PatDiag, and add new PatVisitADT records.
security: security:
- bearerAuth: [] - bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Internal visit ID (InternalPVID)
requestBody: requestBody:
required: true required: true
content: content:
application/json: application/json:
schema: schema:
type: object type: object
required:
- InternalPVID
properties: properties:
InternalPVID:
type: integer
description: Visit ID (required)
PVID: PVID:
type: string type: string
InternalPID: InternalPID:
@ -223,46 +263,9 @@
InternalPVID: InternalPVID:
type: integer type: integer
delete:
tags: [Patient Visits]
summary: Delete patient visit
security:
- bearerAuth: []
responses:
'200':
description: Visit deleted successfully
/api/patvisit/{id}:
get:
tags: [Patient Visits]
summary: Get visit by ID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
description: PVID (visit identifier like DV00001)
responses:
'200':
description: Visit details
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/patient-visit.yaml#/PatientVisit'
/api/patvisit/patient/{patientId}: /api/patvisit/patient/{patientId}:
get: get:
tags: [Patient Visits] tags: [Patient Visit]
summary: Get visits by patient ID summary: Get visits by patient ID
security: security:
- bearerAuth: [] - bearerAuth: []
@ -290,7 +293,7 @@
/api/patvisitadt: /api/patvisitadt:
post: post:
tags: [Patient Visits] tags: [Patient Visit]
summary: Create ADT record summary: Create ADT record
description: Create a new Admission/Discharge/Transfer record description: Create a new Admission/Discharge/Transfer record
security: security:
@ -309,28 +312,10 @@
schema: schema:
$ref: '../components/schemas/common.yaml#/SuccessResponse' $ref: '../components/schemas/common.yaml#/SuccessResponse'
patch:
tags: [Patient Visits]
summary: Update ADT record
description: Update an existing ADT record
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/patient-visit.yaml#/PatVisitADT'
responses:
'200':
description: ADT record updated successfully
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/SuccessResponse'
delete: delete:
tags: [Patient Visits] tags: [Patient Visit]
summary: Delete ADT visit (soft delete) summary: Delete ADT visit (soft delete)
security: security:
- bearerAuth: [] - bearerAuth: []
@ -352,7 +337,7 @@
/api/patvisitadt/visit/{visitId}: /api/patvisitadt/visit/{visitId}:
get: get:
tags: [Patient Visits] tags: [Patient Visit]
summary: Get ADT history by visit ID summary: Get ADT history by visit ID
description: Retrieve the complete Admission/Discharge/Transfer history for a visit, including all locations and doctors description: Retrieve the complete Admission/Discharge/Transfer history for a visit, including all locations and doctors
security: security:
@ -424,30 +409,9 @@
EndDate: EndDate:
type: string type: string
format: date-time format: date-time
delete:
tags: [Patient Visits]
summary: Delete ADT visit (soft delete)
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- PVADTID
properties:
PVADTID:
type: integer
description: ADT record ID to delete
responses:
'200':
description: ADT visit deleted successfully
/api/patvisitadt/{id}: /api/patvisitadt/{id}:
get: get:
tags: [Patient Visits] tags: [Patient Visit]
summary: Get ADT record by ID summary: Get ADT record by ID
description: Retrieve a single ADT record by its ID, including location and doctor details description: Retrieve a single ADT record by its ID, including location and doctor details
security: security:
@ -517,3 +481,30 @@
EndDate: EndDate:
type: string type: string
format: date-time format: date-time
patch:
tags: [Patient Visit]
summary: Update ADT record
description: Update an existing ADT record
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: ADT record ID (PVADTID)
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/patient-visit.yaml#/PatVisitADT'
responses:
'200':
description: ADT record updated successfully
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/SuccessResponse'

View File

@ -1,6 +1,6 @@
/api/patient: /api/patient:
get: get:
tags: [Patients] tags: [Patient]
summary: List patients summary: List patients
security: security:
- bearerAuth: [] - bearerAuth: []
@ -45,7 +45,7 @@
$ref: '../components/schemas/patient.yaml#/PatientListResponse' $ref: '../components/schemas/patient.yaml#/PatientListResponse'
post: post:
tags: [Patients] tags: [Patient]
summary: Create new patient summary: Create new patient
security: security:
- bearerAuth: [] - bearerAuth: []
@ -69,23 +69,9 @@
schema: schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse' $ref: '../components/schemas/common.yaml#/ErrorResponse'
patch:
tags: [Patients]
summary: Update patient
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/patient.yaml#/Patient'
responses:
'200':
description: Patient updated successfully
delete: delete:
tags: [Patients] tags: [Patient]
summary: Delete patient (soft delete) summary: Delete patient (soft delete)
security: security:
- bearerAuth: [] - bearerAuth: []
@ -107,7 +93,7 @@
/api/patient/check: /api/patient/check:
get: get:
tags: [Patients] tags: [Patient]
summary: Check if patient exists summary: Check if patient exists
security: security:
- bearerAuth: [] - bearerAuth: []
@ -138,7 +124,7 @@
/api/patient/{id}: /api/patient/{id}:
get: get:
tags: [Patients] tags: [Patient]
summary: Get patient by ID summary: Get patient by ID
security: security:
- bearerAuth: [] - bearerAuth: []
@ -161,3 +147,25 @@
type: string type: string
data: data:
$ref: '../components/schemas/patient.yaml#/Patient' $ref: '../components/schemas/patient.yaml#/Patient'
patch:
tags: [Patient]
summary: Update patient
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Internal patient record ID
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/patient.yaml#/Patient'
responses:
'200':
description: Patient updated successfully

View File

@ -1,6 +1,6 @@
/api/reports/{orderID}: /api/report/{orderID}:
get: get:
tags: [Reports] tags: [Report]
summary: Generate lab report summary: Generate lab report
description: Generate an HTML lab report for a specific order. Returns HTML content that can be viewed in browser or printed to PDF. description: Generate an HTML lab report for a specific order. Returns HTML content that can be viewed in browser or printed to PDF.
security: security:

View File

@ -1,6 +1,6 @@
/api/results: /api/result:
get: get:
tags: [Results] tags: [Result]
summary: List results summary: List results
description: Retrieve patient test results with optional filters by order or patient description: Retrieve patient test results with optional filters by order or patient
security: security:
@ -94,9 +94,9 @@
type: string type: string
nullable: true nullable: true
/api/results/{id}: /api/result/{id}:
get: get:
tags: [Results] tags: [Result]
summary: Get result by ID summary: Get result by ID
description: Retrieve a specific result entry with all related data description: Retrieve a specific result entry with all related data
security: security:
@ -203,7 +203,7 @@
$ref: '../components/schemas/common.yaml#/ErrorResponse' $ref: '../components/schemas/common.yaml#/ErrorResponse'
patch: patch:
tags: [Results] tags: [Result]
summary: Update result summary: Update result
description: Update a result value with automatic validation against reference ranges. Returns calculated flag (L/H) in response but does not store it. description: Update a result value with automatic validation against reference ranges. Returns calculated flag (L/H) in response but does not store it.
security: security:
@ -274,7 +274,7 @@
$ref: '../components/schemas/common.yaml#/ErrorResponse' $ref: '../components/schemas/common.yaml#/ErrorResponse'
delete: delete:
tags: [Results] tags: [Result]
summary: Delete result summary: Delete result
description: Soft delete a result entry by setting DelDate description: Soft delete a result entry by setting DelDate
security: security:

View File

@ -1,6 +1,6 @@
/api/rules: /api/rule:
get: get:
tags: [Rules] tags: [Rule]
summary: List rules summary: List rules
security: security:
- bearerAuth: [] - bearerAuth: []
@ -38,7 +38,7 @@
$ref: '../components/schemas/rules.yaml#/RuleDef' $ref: '../components/schemas/rules.yaml#/RuleDef'
post: post:
tags: [Rules] tags: [Rule]
summary: Create rule summary: Create rule
description: | description: |
Create a new rule. Rules must be linked to at least one test via TestSiteIDs. Create a new rule. Rules must be linked to at least one test via TestSiteIDs.
@ -77,15 +77,15 @@
ConditionExprCompiled: ConditionExprCompiled:
type: string type: string
nullable: true nullable: true
description: Compiled JSON payload from POST /api/rules/compile description: Compiled JSON payload from POST /api/rule/compile
required: [RuleCode, RuleName, EventCode, TestSiteIDs] required: [RuleCode, RuleName, EventCode, TestSiteIDs]
responses: responses:
'201': '201':
description: Rule created description: Rule created
/api/rules/{id}: /api/rule/{id}:
get: get:
tags: [Rules] tags: [Rule]
summary: Get rule with linked tests summary: Get rule with linked tests
security: security:
- bearerAuth: [] - bearerAuth: []
@ -114,7 +114,7 @@
description: Rule not found description: Rule not found
patch: patch:
tags: [Rules] tags: [Rule]
summary: Update rule summary: Update rule
description: | description: |
Update a rule. TestSiteIDs can be provided to update which tests the rule is linked to. Update a rule. TestSiteIDs can be provided to update which tests the rule is linked to.
@ -153,7 +153,7 @@
description: Rule not found description: Rule not found
delete: delete:
tags: [Rules] tags: [Rule]
summary: Soft delete rule summary: Soft delete rule
security: security:
- bearerAuth: [] - bearerAuth: []
@ -170,9 +170,9 @@
'404': '404':
description: Rule not found description: Rule not found
/api/rules/validate: /api/rule/validate:
post: post:
tags: [Rules] tags: [Rule]
summary: Validate/evaluate an expression summary: Validate/evaluate an expression
security: security:
- bearerAuth: [] - bearerAuth: []
@ -193,9 +193,9 @@
'200': '200':
description: Validation result description: Validation result
/api/rules/compile: /api/rule/compile:
post: post:
tags: [Rules] tags: [Rule]
summary: Compile DSL expression to engine-compatible structure summary: Compile DSL expression to engine-compatible structure
description: | description: |
Compile a DSL expression to the engine-compatible JSON structure. Compile a DSL expression to the engine-compatible JSON structure.

View File

@ -23,20 +23,6 @@
'201': '201':
description: Specimen created description: Specimen created
patch:
tags: [Specimen]
summary: Update specimen
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/specimen.yaml#/Specimen'
responses:
'200':
description: Specimen updated
/api/specimen/{id}: /api/specimen/{id}:
get: get:
@ -54,6 +40,28 @@
'200': '200':
description: Specimen details description: Specimen details
patch:
tags: [Specimen]
summary: Update specimen
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Specimen ID (SID)
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/specimen.yaml#/Specimen'
responses:
'200':
description: Specimen updated
delete: delete:
tags: [Specimen] tags: [Specimen]
summary: Delete specimen (soft delete) summary: Delete specimen (soft delete)
@ -141,14 +149,6 @@
'201': '201':
description: Container definition created description: Container definition created
patch:
tags: [Specimen]
summary: Update container definition
security:
- bearerAuth: []
responses:
'200':
description: Container definition updated
/api/specimen/container/{id}: /api/specimen/container/{id}:
get: get:
@ -166,6 +166,28 @@
'200': '200':
description: Container definition details description: Container definition details
patch:
tags: [Specimen]
summary: Update container definition
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Container definition ID (ConDefID)
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/specimen.yaml#/ContainerDef'
responses:
'200':
description: Container definition updated
/api/specimen/containerdef: /api/specimen/containerdef:
get: get:
tags: [Specimen] tags: [Specimen]
@ -191,11 +213,26 @@
'201': '201':
description: Container definition created description: Container definition created
/api/specimen/containerdef/{id}:
patch: patch:
tags: [Specimen] tags: [Specimen]
summary: Update container definition (alias) summary: Update container definition (alias)
security: security:
- bearerAuth: [] - bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Container definition ID (ConDefID)
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/specimen.yaml#/ContainerDef'
responses: responses:
'200': '200':
description: Container definition updated description: Container definition updated
@ -225,14 +262,6 @@
'201': '201':
description: Specimen preparation created description: Specimen preparation created
patch:
tags: [Specimen]
summary: Update specimen preparation
security:
- bearerAuth: []
responses:
'200':
description: Specimen preparation updated
/api/specimen/prep/{id}: /api/specimen/prep/{id}:
get: get:
@ -250,6 +279,28 @@
'200': '200':
description: Specimen preparation details description: Specimen preparation details
patch:
tags: [Specimen]
summary: Update specimen preparation
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Specimen preparation ID (SpcPrpID)
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/specimen.yaml#/SpecimenPrep'
responses:
'200':
description: Specimen preparation updated
/api/specimen/status: /api/specimen/status:
get: get:
tags: [Specimen] tags: [Specimen]
@ -275,14 +326,6 @@
'201': '201':
description: Specimen status created description: Specimen status created
patch:
tags: [Specimen]
summary: Update specimen status
security:
- bearerAuth: []
responses:
'200':
description: Specimen status updated
/api/specimen/status/{id}: /api/specimen/status/{id}:
get: get:
@ -300,6 +343,28 @@
'200': '200':
description: Specimen status details description: Specimen status details
patch:
tags: [Specimen]
summary: Update specimen status
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Specimen status ID (SpcStaID)
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/specimen.yaml#/SpecimenStatus'
responses:
'200':
description: Specimen status updated
/api/specimen/collection: /api/specimen/collection:
get: get:
tags: [Specimen] tags: [Specimen]
@ -325,14 +390,6 @@
'201': '201':
description: Collection method created description: Collection method created
patch:
tags: [Specimen]
summary: Update specimen collection method
security:
- bearerAuth: []
responses:
'200':
description: Collection method updated
/api/specimen/collection/{id}: /api/specimen/collection/{id}:
get: get:
@ -349,3 +406,25 @@
responses: responses:
'200': '200':
description: Collection method details description: Collection method details
patch:
tags: [Specimen]
summary: Update specimen collection method
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Specimen collection ID (SpcColID)
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/specimen.yaml#/SpecimenCollection'
responses:
'200':
description: Collection method updated

View File

@ -1,6 +1,6 @@
/api/test/testmap: /api/test/testmap:
get: get:
tags: [Tests] tags: [Test]
summary: List all test mappings summary: List all test mappings
security: security:
- bearerAuth: [] - bearerAuth: []
@ -38,7 +38,7 @@
type: string type: string
post: post:
tags: [Tests] tags: [Test]
summary: Create test mapping (header only) summary: Create test mapping (header only)
security: security:
- bearerAuth: [] - bearerAuth: []
@ -99,53 +99,9 @@
type: integer type: integer
description: Created TestMapID description: Created TestMapID
patch:
tags: [Tests]
summary: Update test mapping
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
TestMapID:
type: integer
description: Test Map ID (required)
TestCode:
type: string
description: Test Code - maps to HostTestCode or ClientTestCode
HostType:
type: string
HostID:
type: string
ClientType:
type: string
ClientID:
type: string
required:
- TestMapID
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
delete: delete:
tags: [Tests] tags: [Test]
summary: Soft delete test mapping (cascades to details) summary: Soft delete test mapping (cascades to details)
security: security:
- bearerAuth: [] - bearerAuth: []
@ -182,7 +138,7 @@
/api/test/testmap/{id}: /api/test/testmap/{id}:
get: get:
tags: [Tests] tags: [Test]
summary: Get test mapping by ID with details summary: Get test mapping by ID with details
security: security:
- bearerAuth: [] - bearerAuth: []
@ -210,9 +166,56 @@
'404': '404':
description: Test mapping not found description: Test mapping not found
patch:
tags: [Test]
summary: Update test mapping
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Test Map ID
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
TestCode:
type: string
description: Test Code - maps to HostTestCode or ClientTestCode
HostType:
type: string
HostID:
type: string
ClientType:
type: string
ClientID:
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: [Tests] tags: [Test]
summary: Get test mappings by test code with details summary: Get test mappings by test code with details
security: security:
- bearerAuth: [] - bearerAuth: []
@ -242,7 +245,7 @@
/api/test/testmap/detail: /api/test/testmap/detail:
get: get:
tags: [Tests] tags: [Test]
summary: List test mapping details summary: List test mapping details
security: security:
- bearerAuth: [] - bearerAuth: []
@ -270,7 +273,7 @@
$ref: '../components/schemas/tests.yaml#/TestMapDetail' $ref: '../components/schemas/tests.yaml#/TestMapDetail'
post: post:
tags: [Tests] tags: [Test]
summary: Create test mapping detail summary: Create test mapping detail
security: security:
- bearerAuth: [] - bearerAuth: []
@ -312,41 +315,9 @@
type: integer type: integer
description: Created TestMapDetailID description: Created TestMapDetailID
patch:
tags: [Tests]
summary: Update test mapping detail
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
TestMapDetailID:
type: integer
description: Test Map Detail ID (required)
TestMapID:
type: integer
HostTestCode:
type: string
HostTestName:
type: string
ConDefID:
type: integer
ClientTestCode:
type: string
ClientTestName:
type: string
required:
- TestMapDetailID
responses:
'200':
description: Test mapping detail updated
delete: delete:
tags: [Tests] tags: [Test]
summary: Soft delete test mapping detail summary: Soft delete test mapping detail
security: security:
- bearerAuth: [] - bearerAuth: []
@ -368,7 +339,7 @@
/api/test/testmap/detail/{id}: /api/test/testmap/detail/{id}:
get: get:
tags: [Tests] tags: [Test]
summary: Get test mapping detail by ID summary: Get test mapping detail by ID
security: security:
- bearerAuth: [] - bearerAuth: []
@ -394,9 +365,44 @@
data: data:
$ref: '../components/schemas/tests.yaml#/TestMapDetail' $ref: '../components/schemas/tests.yaml#/TestMapDetail'
patch:
tags: [Test]
summary: Update test mapping detail
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Test Map Detail ID
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
TestMapID:
type: integer
HostTestCode:
type: string
HostTestName:
type: string
ConDefID:
type: integer
ClientTestCode:
type: string
ClientTestName:
type: string
responses:
'200':
description: Test mapping detail updated
/api/test/testmap/detail/by-testmap/{testMapID}: /api/test/testmap/detail/by-testmap/{testMapID}:
get: get:
tags: [Tests] tags: [Test]
summary: Get test mapping details by test map ID summary: Get test mapping details by test map ID
security: security:
- bearerAuth: [] - bearerAuth: []
@ -426,7 +432,7 @@
/api/test/testmap/detail/batch: /api/test/testmap/detail/batch:
post: post:
tags: [Tests] tags: [Test]
summary: Batch create test mapping details summary: Batch create test mapping details
security: security:
- bearerAuth: [] - bearerAuth: []
@ -456,7 +462,7 @@
description: Batch create results description: Batch create results
patch: patch:
tags: [Tests] tags: [Test]
summary: Batch update test mapping details summary: Batch update test mapping details
security: security:
- bearerAuth: [] - bearerAuth: []
@ -488,7 +494,7 @@
description: Batch update results description: Batch update results
delete: delete:
tags: [Tests] tags: [Test]
summary: Batch delete test mapping details summary: Batch delete test mapping details
security: security:
- bearerAuth: [] - bearerAuth: []

View File

@ -1,6 +1,6 @@
/api/test: /api/test:
get: get:
tags: [Tests] tags: [Test]
summary: List test definitions summary: List test definitions
security: security:
- bearerAuth: [] - bearerAuth: []
@ -67,7 +67,7 @@
description: Total number of records matching the query description: Total number of records matching the query
post: post:
tags: [Tests] tags: [Test]
summary: Create test definition summary: Create test definition
security: security:
- bearerAuth: [] - bearerAuth: []
@ -205,29 +205,462 @@
- TestSiteName - TestSiteName
- TestType - TestType
examples: examples:
CALC_test: TEST_no_ref:
summary: Create calculated test with members summary: Technical test without reference or map
value: value:
SiteID: 1 SiteID: 1
TestSiteCode: IBIL TestSiteCode: TEST_NREF
TestSiteName: Indirect Bilirubin TestSiteName: Numeric Test
TestType: TEST
SeqScr: 500
SeqRpt: 500
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
details:
DisciplineID: 2
DepartmentID: 2
Unit1: mg/dL
Method: CBC Analyzer
PARAM_no_ref:
summary: Parameter without reference or map
value:
SiteID: 1
TestSiteCode: PARAM_NRF
TestSiteName: Clinical Parameter
TestType: PARAM
SeqScr: 10
SeqRpt: 10
VisibleScr: 1
VisibleRpt: 0
CountStat: 0
details:
DisciplineID: 10
DepartmentID: 0
Unit1: cm
Method: Manual entry
TEST_range_single:
summary: Technical test with numeric range reference (single)
value:
SiteID: 1
TestSiteCode: TEST_RANGE
TestSiteName: Glucose Range
TestType: TEST
SeqScr: 105
SeqRpt: 105
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
details:
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: RANGE
Unit1: mg/dL
Method: Hexokinase
refnum:
- NumRefType: NMRC
RangeType: REF
Sex: '2'
LowSign: GE
Low: 70
HighSign: LE
High: 100
AgeStart: 18
AgeEnd: 99
Flag: N
TEST_range_multiple_map:
summary: Numeric reference with multiple ranges and test map
value:
SiteID: 1
TestSiteCode: TEST_RMAP
TestSiteName: Glucose Panic Range
TestType: TEST
SeqScr: 110
SeqRpt: 110
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
details:
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: RANGE
Unit1: mg/dL
Method: Hexokinase
refnum:
- NumRefType: NMRC
RangeType: REF
Sex: '2'
LowSign: GE
Low: 70
HighSign: LE
High: 100
AgeStart: 18
AgeEnd: 99
Flag: N
- NumRefType: NMRC
RangeType: REF
Sex: '1'
LowSign: '>'
Low: 75
HighSign: '<'
High: 105
AgeStart: 18
AgeEnd: 99
Flag: N
testmap:
- HostType: SITE
HostID: '1'
ClientType: WST
ClientID: '1'
details:
- HostTestCode: GLU
HostTestName: Glucose
ConDefID: 1
ClientTestCode: GLU_C
ClientTestName: Glucose Client
- HostTestCode: CREA
HostTestName: Creatinine
ConDefID: 2
ClientTestCode: CREA_C
ClientTestName: Creatinine Client
- HostType: WST
HostID: '3'
ClientType: INST
ClientID: '2'
details:
- HostTestCode: HB
HostTestName: Hemoglobin
ConDefID: 3
ClientTestCode: HB_C
ClientTestName: Hemoglobin Client
TEST_threshold:
summary: Technical test with threshold reference
value:
SiteID: 1
TestSiteCode: TEST_THLD
TestSiteName: Sodium Threshold
TestType: TEST
SeqScr: 115
SeqRpt: 115
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
details:
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: THOLD
Unit1: mmol/L
Method: Auto Analyzer
refnum:
- NumRefType: THOLD
RangeType: PANIC
Sex: '2'
LowSign: LT
Low: 120
AgeStart: 0
AgeEnd: 125
Flag: H
TEST_threshold_map:
summary: Threshold reference plus test map
value:
SiteID: 1
TestSiteCode: TEST_TMAP
TestSiteName: Potassium Panic
TestType: TEST
SeqScr: 120
SeqRpt: 120
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
details:
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: THOLD
Unit1: mmol/L
Method: Auto Analyzer
refnum:
- NumRefType: THOLD
RangeType: PANIC
Sex: '2'
LowSign: LT
Low: 120
AgeStart: 0
AgeEnd: 125
Flag: H
- NumRefType: THOLD
RangeType: PANIC
Sex: '1'
LowSign: '<'
Low: 121
AgeStart: 0
AgeEnd: 125
Flag: H
testmap:
- HostType: SITE
HostID: '1'
ClientType: WST
ClientID: '1'
details:
- HostTestCode: HB
HostTestName: Hemoglobin
ConDefID: 3
ClientTestCode: HB_C
ClientTestName: Hemoglobin Client
- HostTestCode: GLU
HostTestName: Glucose
ConDefID: 1
ClientTestCode: GLU_C
ClientTestName: Glucose Client
TEST_text:
summary: Technical test with text reference
value:
SiteID: 1
TestSiteCode: TEST_TEXT
TestSiteName: Disease Stage
TestType: TEST
SeqScr: 130
SeqRpt: 130
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
details:
DisciplineID: 1
DepartmentID: 1
ResultType: TEXT
RefType: TEXT
Method: Morphology
reftxt:
- SpcType: GEN
TxtRefType: TEXT
Sex: '2'
AgeStart: 18
AgeEnd: 99
RefTxt: 'NORM=Normal;HIGH=High'
Flag: N
TEST_text_map:
summary: Text reference plus test map
value:
SiteID: 1
TestSiteCode: TEST_TXM
TestSiteName: Disease Stage (Map)
TestType: TEST
SeqScr: 135
SeqRpt: 135
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
details:
DisciplineID: 1
DepartmentID: 1
ResultType: TEXT
RefType: TEXT
reftxt:
- SpcType: GEN
TxtRefType: TEXT
Sex: '2'
AgeStart: 18
AgeEnd: 99
RefTxt: 'NORM=Normal'
Flag: N
- SpcType: GEN
TxtRefType: TEXT
Sex: '1'
AgeStart: 18
AgeEnd: 99
RefTxt: 'ABN=Abnormal'
Flag: N
testmap:
- HostType: SITE
HostID: '1'
ClientType: WST
ClientID: '1'
details:
- HostTestCode: STAGE
HostTestName: Disease Stage
ConDefID: 4
ClientTestCode: STAGE_C
ClientTestName: Disease Stage Client
TEST_valueset:
summary: Technical test using a value set result
value:
SiteID: 1
TestSiteCode: TEST_VSET
TestSiteName: Urine Color
TestType: TEST
SeqScr: 140
SeqRpt: 140
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
details:
DisciplineID: 4
DepartmentID: 4
ResultType: VSET
RefType: VSET
Method: Visual
reftxt:
- SpcType: GEN
TxtRefType: VSET
Sex: '2'
AgeStart: 0
AgeEnd: 120
RefTxt: 'NORM=Normal;MACRO=Macro'
Flag: N
TEST_valueset_map:
summary: Value set reference with test map
value:
SiteID: 1
TestSiteCode: TEST_VMAP
TestSiteName: Urine Color (Map)
TestType: TEST
SeqScr: 145
SeqRpt: 145
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
details:
DisciplineID: 4
DepartmentID: 4
ResultType: VSET
RefType: VSET
reftxt:
- SpcType: GEN
TxtRefType: VSET
Sex: '2'
AgeStart: 0
AgeEnd: 120
RefTxt: 'NORM=Normal;ABN=Abnormal'
Flag: N
testmap:
- HostType: SITE
HostID: '1'
ClientType: WST
ClientID: '8'
details:
- HostTestCode: UCOLOR
HostTestName: Urine Color
ConDefID: 12
ClientTestCode: UCOLOR_C
ClientTestName: Urine Color Client
TEST_valueset_map_no_reftxt:
summary: Value set result with mapping but without explicit text reference entries
value:
SiteID: 1
TestSiteCode: TEST_VSETM
TestSiteName: Urine Result Map
TestType: TEST
SeqScr: 150
SeqRpt: 150
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
details:
DisciplineID: 4
DepartmentID: 4
ResultType: VSET
RefType: VSET
testmap:
- HostType: SITE
HostID: '1'
ClientType: WST
ClientID: '8'
details:
- HostTestCode: UGLUC
HostTestName: Urine Glucose
ConDefID: 12
ClientTestCode: UGLUC_C
ClientTestName: Urine Glucose Client
CALC_basic:
summary: Calculated test with members (no references)
value:
SiteID: 1
TestSiteCode: CALC_BASE
TestSiteName: Estimated GFR
TestType: CALC TestType: CALC
Description: Bilirubin Indirek SeqScr: 190
SeqScr: 210 SeqRpt: 190
SeqRpt: 210
VisibleScr: 1 VisibleScr: 1
VisibleRpt: 1 VisibleRpt: 1
CountStat: 0 CountStat: 0
details: details:
DisciplineID: 2 DisciplineID: 2
DepartmentID: 2 DepartmentID: 2
FormulaCode: "{TBIL} - {DBIL}" FormulaCode: CKD_EPI(CREA,AGE,GENDER)
RefType: RANGE
Unit1: mg/dL
Decimal: 2
members: members:
- TestSiteID: 21
- TestSiteID: 22 - TestSiteID: 22
- TestSiteID: 23 CALC_full:
summary: Calculated test with numeric reference ranges and map
value:
SiteID: 1
TestSiteCode: CALC_FULL
TestSiteName: Estimated GFR (Map)
TestType: CALC
SeqScr: 195
SeqRpt: 195
VisibleScr: 1
VisibleRpt: 1
CountStat: 0
details:
DisciplineID: 2
DepartmentID: 2
FormulaCode: CKD_EPI(CREA,AGE,GENDER)
members:
- TestSiteID: 21
- TestSiteID: 22
refnum:
- NumRefType: NMRC
RangeType: REF
Sex: '2'
LowSign: GE
Low: 10
HighSign: LE
High: 20
AgeStart: 18
AgeEnd: 120
Flag: N
testmap:
- HostType: SITE
HostID: '1'
ClientType: WST
ClientID: '3'
details:
- HostTestCode: EGFR
HostTestName: eGFR
ConDefID: 1
ClientTestCode: EGFR_C
ClientTestName: eGFR Client
GROUP_with_members:
summary: Group/profile test with members and mapping
value:
SiteID: 1
TestSiteCode: GROUP_PNL
TestSiteName: Lipid Profile
TestType: GROUP
SeqScr: 10
SeqRpt: 10
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
details:
members:
- TestSiteID: 169
- TestSiteID: 170
testmap:
- HostType: SITE
HostID: '1'
ClientType: WST
ClientID: '3'
details:
- HostTestCode: LIPID
HostTestName: Lipid Profile
ConDefID: 1
ClientTestCode: LIPID_C
ClientTestName: Lipid Client
responses: responses:
'201': '201':
description: Test definition created description: Test definition created
@ -261,7 +694,7 @@
example: 'Invalid member TestSiteID(s): 185, 186. Make sure to use TestSiteID, not SeqScr or other values.' example: 'Invalid member TestSiteID(s): 185, 186. Make sure to use TestSiteID, not SeqScr or other values.'
patch: patch:
tags: [Tests] tags: [Test]
summary: Update test definition summary: Update test definition
security: security:
- bearerAuth: [] - bearerAuth: []
@ -426,7 +859,7 @@
/api/test/{id}: /api/test/{id}:
get: get:
tags: [Tests] tags: [Test]
summary: Get test definition by ID summary: Get test definition by ID
security: security:
- bearerAuth: [] - bearerAuth: []
@ -455,7 +888,7 @@
description: Test not found description: Test not found
delete: delete:
tags: [Tests] tags: [Test]
summary: Soft delete test definition summary: Soft delete test definition
security: security:
- bearerAuth: [] - bearerAuth: []

View File

@ -1,6 +1,6 @@
/api/users: /api/user:
get: get:
tags: [Users] tags: [User]
summary: List users with pagination and search summary: List users with pagination and search
security: security:
- bearerAuth: [] - bearerAuth: []
@ -58,7 +58,7 @@
description: Server error description: Server error
post: post:
tags: [Users] tags: [User]
summary: Create new user summary: Create new user
security: security:
- bearerAuth: [] - bearerAuth: []
@ -109,11 +109,44 @@
'500': '500':
description: Server error description: Server error
/api/user/{id}:
get:
tags: [User]
summary: Get user by ID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: User ID
responses:
'200':
description: User details
content:
application/json:
schema:
$ref: '../components/schemas/user.yaml#/User'
'404':
description: User not found
'500':
description: Server error
patch: patch:
tags: [Users] tags: [User]
summary: Update existing user summary: Update existing user
security: security:
- bearerAuth: [] - bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: User ID
requestBody: requestBody:
required: true required: true
content: content:
@ -150,33 +183,8 @@
'500': '500':
description: Server error description: Server error
/api/users/{id}:
get:
tags: [Users]
summary: Get user by ID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: User ID
responses:
'200':
description: User details
content:
application/json:
schema:
$ref: '../components/schemas/user.yaml#/User'
'404':
description: User not found
'500':
description: Server error
delete: delete:
tags: [Users] tags: [User]
summary: Delete user (soft delete) summary: Delete user (soft delete)
security: security:
- bearerAuth: [] - bearerAuth: []

View File

@ -1,6 +1,6 @@
/api/valueset: /api/valueset:
get: get:
tags: [ValueSets] tags: [ValueSet]
summary: List lib value sets summary: List lib value sets
description: List all library/system value sets from JSON files with item counts. Returns an array of objects with value, label, and count properties. description: List all library/system value sets from JSON files with item counts. Returns an array of objects with value, label, and count properties.
security: security:
@ -39,7 +39,7 @@
/api/valueset/{key}: /api/valueset/{key}:
get: get:
tags: [ValueSets] tags: [ValueSet]
summary: Get lib value set by key summary: Get lib value set by key
description: | description: |
Get a specific library/system value set from JSON files. Get a specific library/system value set from JSON files.
@ -119,7 +119,7 @@
/api/valueset/refresh: /api/valueset/refresh:
post: post:
tags: [ValueSets] tags: [ValueSet]
summary: Refresh lib ValueSet cache summary: Refresh lib ValueSet cache
description: Clear and reload the library/system ValueSet cache from JSON files. Call this after modifying JSON files in app/Libraries/Data/. description: Clear and reload the library/system ValueSet cache from JSON files. Call this after modifying JSON files in app/Libraries/Data/.
security: security:
@ -141,7 +141,7 @@
/api/valueset/user/items: /api/valueset/user/items:
get: get:
tags: [ValueSets] tags: [ValueSet]
summary: List user value set items summary: List user value set items
description: List value set items from database (user-defined) description: List value set items from database (user-defined)
security: security:
@ -178,7 +178,7 @@
$ref: '../components/schemas/valuesets.yaml#/ValueSetItem' $ref: '../components/schemas/valuesets.yaml#/ValueSetItem'
post: post:
tags: [ValueSets] tags: [ValueSet]
summary: Create user value set item summary: Create user value set item
description: Create value set item in database (user-defined) description: Create value set item in database (user-defined)
security: security:
@ -224,7 +224,7 @@
/api/valueset/user/items/{id}: /api/valueset/user/items/{id}:
get: get:
tags: [ValueSets] tags: [ValueSet]
summary: Get user value set item by ID summary: Get user value set item by ID
description: Get value set item from database (user-defined) description: Get value set item from database (user-defined)
security: security:
@ -249,7 +249,7 @@
$ref: '../components/schemas/valuesets.yaml#/ValueSetItem' $ref: '../components/schemas/valuesets.yaml#/ValueSetItem'
put: put:
tags: [ValueSets] tags: [ValueSet]
summary: Update user value set item summary: Update user value set item
description: Update value set item in database (user-defined) description: Update value set item in database (user-defined)
security: security:
@ -298,7 +298,7 @@
$ref: '../components/schemas/valuesets.yaml#/ValueSetItem' $ref: '../components/schemas/valuesets.yaml#/ValueSetItem'
delete: delete:
tags: [ValueSets] tags: [ValueSet]
summary: Delete user value set item summary: Delete user value set item
description: Delete value set item from database (user-defined) description: Delete value set item from database (user-defined)
security: security:
@ -324,7 +324,7 @@
/api/valueset/user/def: /api/valueset/user/def:
get: get:
tags: [ValueSets] tags: [ValueSet]
summary: List user value set definitions summary: List user value set definitions
description: List value set definitions from database (user-defined) description: List value set definitions from database (user-defined)
security: security:
@ -372,7 +372,7 @@
type: integer type: integer
post: post:
tags: [ValueSets] tags: [ValueSet]
summary: Create user value set definition summary: Create user value set definition
description: Create value set definition in database (user-defined) description: Create value set definition in database (user-defined)
security: security:
@ -410,7 +410,7 @@
/api/valueset/user/def/{id}: /api/valueset/user/def/{id}:
get: get:
tags: [ValueSets] tags: [ValueSet]
summary: Get user value set definition by ID summary: Get user value set definition by ID
description: Get value set definition from database (user-defined) description: Get value set definition from database (user-defined)
security: security:
@ -435,7 +435,7 @@
$ref: '../components/schemas/valuesets.yaml#/ValueSetDef' $ref: '../components/schemas/valuesets.yaml#/ValueSetDef'
put: put:
tags: [ValueSets] tags: [ValueSet]
summary: Update user value set definition summary: Update user value set definition
description: Update value set definition in database (user-defined) description: Update value set definition in database (user-defined)
security: security:
@ -478,7 +478,7 @@
$ref: '../components/schemas/valuesets.yaml#/ValueSetDef' $ref: '../components/schemas/valuesets.yaml#/ValueSetDef'
delete: delete:
tags: [ValueSets] tags: [ValueSet]
summary: Delete user value set definition summary: Delete user value set definition
description: Delete value set definition from database (user-defined) description: Delete value set definition from database (user-defined)
security: security:

View File

@ -1,37 +0,0 @@
<?php
namespace Tests\Support\Database\Migrations;
use CodeIgniter\Database\Migration;
class ExampleMigration extends Migration
{
protected $DBGroup = 'tests';
public function up(): void
{
$this->forge->addField('id');
$this->forge->addField([
'name' => ['type' => 'varchar', 'constraint' => 31],
'uid' => ['type' => 'varchar', 'constraint' => 31],
'class' => ['type' => 'varchar', 'constraint' => 63],
'icon' => ['type' => 'varchar', 'constraint' => 31],
'summary' => ['type' => 'varchar', 'constraint' => 255],
'created_at' => ['type' => 'datetime', 'null' => true],
'updated_at' => ['type' => 'datetime', 'null' => true],
'deleted_at' => ['type' => 'datetime', 'null' => true],
]);
$this->forge->addKey('name');
$this->forge->addKey('uid');
$this->forge->addKey(['deleted_at', 'id']);
$this->forge->addKey('created_at');
$this->forge->createTable('factories');
}
public function down(): void
{
$this->forge->dropTable('factories');
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace Tests\Support\Traits;
use App\Models\Patient\PatientModel;
use Faker\Factory;
trait CreatesPatients
{
protected function createTestPatient(array $overrides = []): int
{
$faker = Factory::create('id_ID');
$patientPayload = array_merge([
'PatientID' => 'PAT' . $faker->numerify('##########'),
'AlternatePID' => 'ALT' . $faker->numerify('##########'),
'Prefix' => $faker->title,
'NameFirst' => 'Test',
'NameMiddle' => $faker->firstName,
'NameLast' => 'Patient',
'Suffix' => 'S.Kom',
'Sex' => (string) $faker->numberBetween(5, 6),
'PlaceOfBirth' => $faker->city,
'Birthdate' => $faker->date('Y-m-d'),
'ZIP' => $faker->postcode,
'Street_1' => $faker->streetAddress,
'City' => $faker->city,
'Province' => $faker->state,
'EmailAddress1' => 'test.' . $faker->unique()->userName . '@example.com',
'Phone' => $faker->numerify('08##########'),
'MobilePhone' => $faker->numerify('08##########'),
'Race' => (string) $faker->numberBetween(175, 205),
'Country' => (string) $faker->numberBetween(221, 469),
'MaritalStatus' => (string) $faker->numberBetween(8, 15),
'Religion' => (string) $faker->numberBetween(206, 212),
'Ethnic' => (string) $faker->numberBetween(213, 220),
'Citizenship' => 'WNI',
'DeathIndicator' => (string) $faker->numberBetween(16, 17),
'PatIdt' => [
'IdentifierType' => 'ID',
'Identifier' => $faker->numerify('################')
],
'PatAtt' => [
[ 'Address' => '/api/upload/' . $faker->uuid . '.jpg' ]
],
'PatCom' => $faker->sentence,
], $overrides);
if ($patientPayload['DeathIndicator'] === '16') {
$patientPayload['DeathDateTime'] = $faker->date('Y-m-d H:i:s');
} else {
$patientPayload['DeathDateTime'] = null;
}
$patientModel = new PatientModel();
$internalPID = $patientModel->createPatient($patientPayload);
if (!$internalPID) {
throw new \RuntimeException('Failed to insert test patient');
}
return $internalPID;
}
}

View File

@ -1,148 +0,0 @@
<?php
namespace Tests\Feature\Calculator;
use App\Models\Test\TestDefCalModel;
use App\Models\Test\TestDefSiteModel;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\FeatureTestTrait;
class CalculatorEndpointTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected TestDefSiteModel $siteModel;
protected TestDefCalModel $calcModel;
protected ?int $siteId = null;
protected ?int $calcId = null;
protected string $calcName;
protected string $calcCode;
protected function setUp(): void
{
parent::setUp();
$this->siteModel = new TestDefSiteModel();
$this->calcModel = new TestDefCalModel();
$this->calcName = 'API Calc ' . uniqid();
$this->calcCode = $this->generateUniqueCalcCode();
$siteId = $this->siteModel->insert([
'SiteID' => 1,
'TestSiteCode' => $this->calcCode,
'TestSiteName' => $this->calcName,
'TestType' => 'CALC',
'ResultType' => 'NMRIC',
'RefType' => 'RANGE',
'Unit1' => 'mg/dL',
'VisibleScr' => 1,
'VisibleRpt' => 1,
'CountStat' => 0,
'CreateDate' => date('Y-m-d H:i:s'),
'StartDate' => date('Y-m-d H:i:s'),
]);
$this->assertNotFalse($siteId, 'Failed to insert testdefsite');
$this->siteId = $siteId;
$this->calcId = $this->calcModel->insert([
'TestSiteID' => $siteId,
'DisciplineID' => 1,
'DepartmentID' => 1,
'FormulaCode' => 'TBIL - DBIL',
'RefType' => 'RANGE',
'Unit1' => 'mg/dL',
'Factor' => 1,
'Decimal' => 2,
'CreateDate' => date('Y-m-d H:i:s'),
]);
$this->assertNotFalse($this->calcId, 'Failed to insert testdefcal');
$this->assertNotNull($this->calcModel->findActiveByCodeOrName($this->calcCode));
}
protected function tearDown(): void
{
if ($this->calcId) {
$this->calcModel->delete($this->calcId);
}
if ($this->siteId) {
$this->siteModel->delete($this->siteId);
}
parent::tearDown();
}
public function testCalculateByCodeReturnsValue()
{
$response = $this->postCalc($this->calcCode, ['TBIL' => 5, 'DBIL' => 3]);
$response->assertStatus(200);
$data = $this->decodeResponse($response);
$this->assertArrayHasKey($this->calcCode, $data);
$this->assertEquals(2.0, $data[$this->calcCode]);
}
public function testCalculateByNameReturnsValue()
{
$response = $this->postCalc($this->calcName, ['TBIL' => 4, 'DBIL' => 1]);
$response->assertStatus(200);
$data = $this->decodeResponse($response);
$this->assertArrayHasKey($this->calcCode, $data);
$this->assertEquals(3.0, $data[$this->calcCode]);
}
public function testIncompletePayloadReturnsEmptyObject()
{
$response = $this->postCalc($this->calcCode, ['TBIL' => 5]);
$response->assertStatus(200);
$data = $this->decodeResponse($response);
$this->assertSame([], $data);
}
public function testUnknownCalculatorReturnsEmptyObject()
{
$response = $this->postCalc('UNKNOWN_CALC', ['TBIL' => 3, 'DBIL' => 1]);
$response->assertStatus(200);
$data = $this->decodeResponse($response);
$this->assertSame([], $data);
}
private function postCalc(string $identifier, array $payload)
{
return $this->withHeaders(['Content-Type' => 'application/json'])
->withBody(json_encode($payload))
->call('post', 'api/calc/' . rawurlencode($identifier));
}
private function decodeResponse($response): array
{
$json = $response->getJSON();
if (empty($json)) {
return [];
}
return json_decode($json, true) ?: [];
}
private function generateUniqueCalcCode(): string
{
$tries = 0;
do {
$code = 'TC' . strtoupper(bin2hex(random_bytes(2)));
$exists = $this->siteModel->where('TestSiteCode', $code)
->where('EndDate IS NULL')
->first();
} while ($exists && ++$tries < 20);
return $code;
}
}

View File

@ -1,250 +0,0 @@
<?php
namespace Tests\Feature\Calculator;
use CodeIgniter\Test\CIUnitTestCase;
use App\Services\CalculatorService;
class CalculatorTest extends CIUnitTestCase
{
protected CalculatorService $calculator;
public function setUp(): void {
parent::setUp();
$this->calculator = new CalculatorService();
}
/**
* Test basic arithmetic operations
*/
public function testBasicArithmetic() {
// Addition and multiplication precedence
$result = $this->calculator->calculate('1+2*3');
$this->assertEquals(7.0, $result);
// Parentheses
$result = $this->calculator->calculate('(1+2)*3');
$this->assertEquals(9.0, $result);
// Division
$result = $this->calculator->calculate('10/2');
$this->assertEquals(5.0, $result);
// Power
$result = $this->calculator->calculate('2^3');
$this->assertEquals(8.0, $result);
}
/**
* Test formula with simple variables
*/
public function testFormulaWithVariables() {
$formula = '{result} * {factor}';
$variables = ['result' => 50, 'factor' => 2];
$result = $this->calculator->calculate($formula, $variables);
$this->assertEquals(100.0, $result);
}
/**
* Test formula with gender variable (numeric values)
*/
public function testFormulaWithGenderNumeric() {
// Gender: 0=Unknown, 1=Female, 2=Male
$formula = '50 + {gender} * 10';
// Male (2)
$result = $this->calculator->calculate($formula, ['gender' => 2]);
$this->assertEquals(70.0, $result);
// Female (1)
$result = $this->calculator->calculate($formula, ['gender' => 1]);
$this->assertEquals(60.0, $result);
// Unknown (0)
$result = $this->calculator->calculate($formula, ['gender' => 0]);
$this->assertEquals(50.0, $result);
}
/**
* Test formula with gender variable (string values)
*/
public function testFormulaWithGenderString() {
$formula = '50 + {gender} * 10';
// String values
$result = $this->calculator->calculate($formula, ['gender' => 'male']);
$this->assertEquals(70.0, $result);
$result = $this->calculator->calculate($formula, ['gender' => 'female']);
$this->assertEquals(60.0, $result);
$result = $this->calculator->calculate($formula, ['gender' => 'unknown']);
$this->assertEquals(50.0, $result);
}
/**
* Test mathematical functions
*/
public function testMathFunctions() {
// Square root
$result = $this->calculator->calculate('sqrt(16)');
$this->assertEquals(4.0, $result);
// Sine
$result = $this->calculator->calculate('sin(pi/2)');
$this->assertEqualsWithDelta(1.0, $result, 0.0001);
// Cosine
$result = $this->calculator->calculate('cos(0)');
$this->assertEquals(1.0, $result);
// Logarithm
$result = $this->calculator->calculate('log(100)');
$this->assertEqualsWithDelta(4.60517, $result, 0.0001);
// Natural log (ln)
$result = $this->calculator->calculate('ln(2.71828)');
$this->assertEqualsWithDelta(1.0, $result, 0.0001);
// Exponential
$result = $this->calculator->calculate('exp(1)');
$this->assertEqualsWithDelta(2.71828, $result, 0.0001);
}
/**
* Test formula validation
*/
public function testFormulaValidation() {
// Valid formula
$validation = $this->calculator->validate('{result} * 2 + 5');
$this->assertTrue($validation['valid']);
$this->assertNull($validation['error']);
// Invalid formula
$validation = $this->calculator->validate('{result} * * 2');
$this->assertFalse($validation['valid']);
$this->assertNotNull($validation['error']);
}
/**
* Test variable extraction
*/
public function testExtractVariables() {
$formula = '{result} * {factor} + {gender} - {age}';
$variables = $this->calculator->extractVariables($formula);
$this->assertEquals(['result', 'factor', 'gender', 'age'], $variables);
}
/**
* Test missing variable error
*/
public function testMissingVariableError() {
$this->expectException(\Exception::class);
$this->expectExceptionMessage("Missing variable value for: missing_var");
$this->calculator->calculate('{result} + {missing_var}', ['result' => 10]);
}
/**
* Test invalid formula syntax error
*/
public function testInvalidFormulaError() {
$this->expectException(\Exception::class);
$this->expectExceptionMessage("Invalid formula");
$this->calculator->calculate('1 + * 2');
}
/**
* Test complex formula with multiple variables
*/
public function testComplexFormula() {
// Complex formula: (result * factor / 100) + (gender * 5) - (age * 0.1)
$formula = '({result} * {factor} / 100) + ({gender} * 5) - ({age} * 0.1)';
$variables = [
'result' => 200,
'factor' => 10,
'gender' => 2, // Male
'age' => 30
];
// Expected: (200 * 10 / 100) + (2 * 5) - (30 * 0.1) = 20 + 10 - 3 = 27
$result = $this->calculator->calculate($formula, $variables);
$this->assertEquals(27.0, $result);
}
/**
* Test calculation from TestDefCal definition
*/
public function testCalculateFromDefinition() {
$calcDef = [
'FormulaCode' => '{result} * {factor} + 10',
'Factor' => 2,
];
$testValues = [
'result' => 50,
];
// Expected: 50 * 2 + 10 = 110
$result = $this->calculator->calculateFromDefinition($calcDef, $testValues);
$this->assertEquals(110.0, $result);
}
/**
* Test calculation with all optional variables
*/
public function testCalculateWithAllVariables() {
$calcDef = [
'FormulaCode' => '{result} + {factor} + {gender} + {age} + {ref_low} + {ref_high}',
'Factor' => 5,
];
$testValues = [
'result' => 10,
'gender' => 1,
'age' => 25,
'ref_low' => 5,
'ref_high' => 15,
];
// Expected: 10 + 5 + 1 + 25 + 5 + 15 = 61
$result = $this->calculator->calculateFromDefinition($calcDef, $testValues);
$this->assertEquals(61.0, $result);
}
/**
* Test empty formula error
*/
public function testEmptyFormulaError() {
$this->expectException(\Exception::class);
$this->expectExceptionMessage("No formula defined");
$calcDef = [
'FormulaCode' => '',
'Factor' => 1,
];
$this->calculator->calculateFromDefinition($calcDef, ['result' => 10]);
}
/**
* Test implicit multiplication
*/
public function testImplicitMultiplication() {
// math-parser supports implicit multiplication (2x means 2*x)
$result = $this->calculator->calculate('2*3');
$this->assertEquals(6.0, $result);
}
/**
* Test decimal calculations
*/
public function testDecimalCalculations() {
$formula = '{result} / 3';
$result = $this->calculator->calculate($formula, ['result' => 10]);
$this->assertEqualsWithDelta(3.33333, $result, 0.0001);
}
}

View File

@ -1,285 +0,0 @@
<?php
namespace Tests\Feature\Orders;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
use Faker\Factory;
class OrderCreateTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api/ordertest';
public function testCreateOrderSuccess()
{
$internalPID = $this->createOrderTestPatient();
// Get available tests from testdefsite
$testsResult = $this->call('get', 'api/test');
$testsBody = json_decode($testsResult->getBody(), true);
$availableTests = $testsBody['data'] ?? [];
// Skip if no tests available
if (empty($availableTests)) {
$this->markTestSkipped('No tests available in testdefsite table');
}
$testSiteID = $availableTests[0]['TestSiteID'];
// Create order with tests
$payload = [
'InternalPID' => $internalPID,
'Priority' => 'R',
'Tests' => [
['TestSiteID' => $testSiteID]
]
];
$result = $this->withBodyFormat('json')->call('post', $this->endpoint, $payload);
// Assertions
$result->assertStatus(201);
$body = json_decode($result->getBody(), true);
$this->assertEquals('success', $body['status']);
$this->assertArrayHasKey('data', $body);
$this->assertArrayHasKey('OrderID', $body['data']);
$this->assertArrayHasKey('Specimens', $body['data']);
$this->assertArrayHasKey('Tests', $body['data']);
$this->assertIsArray($body['data']['Specimens']);
$this->assertIsArray($body['data']['Tests']);
$this->assertNotEmpty($body['data']['Tests'], 'Tests array should not be empty');
return $body['data']['OrderID'];
}
public function testCreateOrderValidationFailsWithoutInternalPID()
{
$payload = [
'Tests' => [
['TestSiteID' => 1]
]
];
$result = $this->withBodyFormat('json')->call('post', $this->endpoint, $payload);
$result->assertStatus(400);
$body = json_decode(strip_tags($result->getBody()), true);
$this->assertIsArray($body);
$messages = $body['messages'] ?? $body['errors'] ?? [];
$this->assertIsArray($messages);
$this->assertArrayHasKey('InternalPID', $messages);
}
public function testCreateOrderFailsWithInvalidPatient()
{
$payload = [
'InternalPID' => 999999,
'Tests' => [
['TestSiteID' => 1]
]
];
$result = $this->withBodyFormat('json')->call('post', $this->endpoint, $payload);
$result->assertStatus(400);
$body = json_decode(strip_tags($result->getBody()), true);
$this->assertIsArray($body);
$messages = $body['messages'] ?? $body['errors'] ?? [];
$this->assertIsArray($messages);
$this->assertArrayHasKey('InternalPID', $messages);
}
public function testCreateOrderWithMultipleTests()
{
$internalPID = $this->createOrderTestPatient();
// Get available tests
$testsResult = $this->call('get', 'api/test');
$testsBody = json_decode($testsResult->getBody(), true);
$availableTests = $testsBody['data'] ?? [];
if (count($availableTests) < 2) {
$this->markTestSkipped('Need at least 2 tests for this test');
}
$testSiteID1 = $availableTests[0]['TestSiteID'];
$testSiteID2 = $availableTests[1]['TestSiteID'];
// Create order with multiple tests
$payload = [
'InternalPID' => $internalPID,
'Priority' => 'S',
'Comment' => 'Urgent order for multiple tests',
'Tests' => [
['TestSiteID' => $testSiteID1],
['TestSiteID' => $testSiteID2]
]
];
$result = $this->withBodyFormat('json')->call('post', $this->endpoint, $payload);
$result->assertStatus(201);
$body = json_decode($result->getBody(), true);
$this->assertEquals('success', $body['status']);
$this->assertGreaterThanOrEqual(1, count($body['data']['Specimens']), 'Should have at least one specimen');
$this->assertGreaterThanOrEqual(2, count($body['data']['Tests']), 'Should have at least two tests');
}
public function testOrderShowIncludesDisciplineAndSequenceOrdering()
{
$internalPID = $this->createOrderTestPatient();
$testSiteIDs = $this->collectTestSiteIDs(2);
$orderID = $this->createOrderWithTests($internalPID, $testSiteIDs);
$response = $this->call('get', $this->endpoint . '/' . $orderID);
$response->assertStatus(200);
$body = json_decode($response->getBody(), true);
$this->assertEquals('success', $body['status']);
$tests = $body['data']['Tests'] ?? [];
$this->assertNotEmpty($tests, 'Tests payload should not be empty');
$lastKey = null;
foreach ($tests as $test) {
$this->assertArrayHasKey('Discipline', $test);
$this->assertArrayHasKey('TestType', $test);
$this->assertNotEmpty($test['TestType'], 'Each test should report a test type');
$this->assertArrayHasKey('SeqScr', $test);
$this->assertArrayHasKey('SeqRpt', $test);
$discipline = $test['Discipline'];
$this->assertArrayHasKey('DisciplineID', $discipline);
$this->assertArrayHasKey('DisciplineName', $discipline);
$this->assertArrayHasKey('SeqScr', $discipline);
$this->assertArrayHasKey('SeqRpt', $discipline);
$currentKey = $this->buildTestSortKey($test);
if ($lastKey !== null) {
$this->assertGreaterThanOrEqual($lastKey, $currentKey, 'Tests are not ordered by discipline/test sequence');
}
$lastKey = $currentKey;
}
}
private function createOrderTestPatient(): int
{
$faker = Factory::create('id_ID');
$patientPayload = [
"PatientID" => "ORD" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000).$faker->numberBetween(1, 1000),
"AlternatePID" => "DMY" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000).$faker->numberBetween(1, 1000),
"Prefix" => $faker->title,
"NameFirst" => "Order",
"NameMiddle" => $faker->firstName,
"NameMaiden" => $faker->firstName,
"NameLast" => "Test",
"Suffix" => "S.Kom",
"NameAlias" => $faker->userName,
"Sex" => $faker->numberBetween(5, 6),
"PlaceOfBirth" => $faker->city,
"Birthdate" => "1990-01-01",
"ZIP" => $faker->postcode,
"Street_1" => $faker->streetAddress,
"Street_2" => "RT " . $faker->numberBetween(1, 10) . " RW " . $faker->numberBetween(1, 10),
"Street_3" => "Blok " . $faker->numberBetween(1, 20),
"City" => $faker->city,
"Province" => $faker->state,
"EmailAddress1" => "A" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000).$faker->numberBetween(1, 1000).'@gmail.com',
"EmailAddress2" => "B" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000).$faker->numberBetween(1, 1000).'@gmail.com',
"Phone" => $faker->numerify('08##########'),
"MobilePhone" => $faker->numerify('08##########'),
"Race" => (string) $faker->numberBetween(175, 205),
"Country" => (string) $faker->numberBetween(221, 469),
"MaritalStatus" => (string) $faker->numberBetween(8, 15),
"Religion" => (string) $faker->numberBetween(206, 212),
"Ethnic" => (string) $faker->numberBetween(213, 220),
"Citizenship" => "WNI",
"DeathIndicator" => (string) $faker->numberBetween(16, 17),
"LinkTo" => (string) $faker->numberBetween(2, 3),
"Custodian" => 1,
"PatIdt" => [
"IdentifierType" => "KTP",
"Identifier" => $faker->nik() ?? $faker->numerify('################')
],
"PatAtt" => [
[ "Address" => "/api/upload/" . $faker->uuid . ".jpg" ]
],
"PatCom" => $faker->sentence,
];
if ($patientPayload['DeathIndicator'] == '16') {
$patientPayload['DeathDateTime'] = $faker->date('Y-m-d H:i:s');
} else {
$patientPayload['DeathDateTime'] = null;
}
$patientModel = new \App\Models\Patient\PatientModel();
$internalPID = $patientModel->createPatient($patientPayload);
$this->assertNotNull($internalPID, 'Failed to create test patient. Response: ' . print_r($patientPayload, true));
return $internalPID;
}
private function createOrderWithTests(int $internalPID, array $testSiteIDs, string $priority = 'R'): string
{
$payload = [
'InternalPID' => $internalPID,
'Priority' => $priority,
'Tests' => array_map(fn ($testSiteID) => ['TestSiteID' => $testSiteID], $testSiteIDs),
];
$result = $this->withBodyFormat('json')->call('post', $this->endpoint, $payload);
$result->assertStatus(201);
$body = json_decode($result->getBody(), true);
$this->assertEquals('success', $body['status']);
$orderID = $body['data']['OrderID'] ?? null;
$this->assertNotNull($orderID, 'Order creation response is missing OrderID');
return $orderID;
}
private function collectTestSiteIDs(int $count = 2): array
{
$response = $this->call('get', 'api/test');
$body = json_decode($response->getBody(), true);
$availableTests = $body['data'] ?? [];
if (count($availableTests) < $count) {
$this->markTestSkipped('Need at least ' . $count . ' tests to validate ordering.');
}
$ids = array_values(array_filter(array_column($availableTests, 'TestSiteID')));
return array_slice($ids, 0, $count);
}
private function buildTestSortKey(array $test): string
{
$discipline = $test['Discipline'] ?? [];
$discSeqScr = $this->normalizeSequenceValue($discipline['SeqScr'] ?? null);
$discSeqRpt = $this->normalizeSequenceValue($discipline['SeqRpt'] ?? null);
$testSeqScr = $this->normalizeSequenceValue($test['SeqScr'] ?? null);
$testSeqRpt = $this->normalizeSequenceValue($test['SeqRpt'] ?? null);
$resultID = isset($test['ResultID']) ? (int)$test['ResultID'] : 0;
return sprintf('%06d-%06d-%06d-%06d-%010d', $discSeqScr, $discSeqRpt, $testSeqScr, $testSeqRpt, $resultID);
}
private function normalizeSequenceValue($value): int
{
if (is_numeric($value)) {
return (int)$value;
}
return 999999;
}
}

View File

@ -20,8 +20,8 @@ class CodingSysControllerTest extends CIUnitTestCase
public function testCreateCodingSys() public function testCreateCodingSys()
{ {
$payload = [ $payload = [
'CodingSysAbb' => 'ICD10', 'CodingSysAbb' => 'ICD' . substr(time(), -3),
'FullText' => 'International Classification of Diseases 10', 'FullText' => 'International Classification of Diseases 10 ' . time(),
'Description' => 'Medical diagnosis coding system' 'Description' => 'Medical diagnosis coding system'
]; ];

View File

@ -11,6 +11,11 @@ class HostAppControllerTest extends CIUnitTestCase
protected $endpoint = 'api/organization/hostapp'; protected $endpoint = 'api/organization/hostapp';
protected function setUp(): void
{
parent::setUp();
}
public function testIndexHostApp() public function testIndexHostApp()
{ {
$result = $this->get($this->endpoint); $result = $this->get($this->endpoint);

View File

@ -11,6 +11,11 @@ class PatVisitByPatientTest extends CIUnitTestCase
protected $endpoint = 'api/patvisit/patient'; protected $endpoint = 'api/patvisit/patient';
protected function setUp(): void
{
parent::setUp();
}
/** /**
* Test: Show all visits by valid InternalPID * Test: Show all visits by valid InternalPID
*/ */

View File

@ -4,20 +4,27 @@ namespace Tests\Feature\PatVisit;
use CodeIgniter\Test\FeatureTestTrait; use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\CIUnitTestCase;
use Tests\Support\Traits\CreatesPatients;
class PatVisitCreateTest extends CIUnitTestCase class PatVisitCreateTest extends CIUnitTestCase
{ {
use FeatureTestTrait; use FeatureTestTrait;
use CreatesPatients;
protected $endpoint = 'api/patvisit'; protected $endpoint = 'api/patvisit';
protected function setUp(): void
{
parent::setUp();
}
/** /**
* Test: Create patient visit with valid data * Test: Create patient visit with valid data
*/ */
public function testCreatePatientVisitSuccess() public function testCreatePatientVisitSuccess()
{ {
$payload = [ $payload = [
"InternalPID"=> "1", "InternalPID"=> $this->createTestPatient(),
"EpisodeID"=> null, "EpisodeID"=> null,
"PatDiag"=> [ "PatDiag"=> [
"DiagCode"=> null, "DiagCode"=> null,

View File

@ -4,13 +4,20 @@ namespace Tests\Feature\PatVisit;
use CodeIgniter\Test\FeatureTestTrait; use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\CIUnitTestCase;
use Tests\Support\Traits\CreatesPatients;
class PatVisitDeleteTest extends CIUnitTestCase class PatVisitDeleteTest extends CIUnitTestCase
{ {
use FeatureTestTrait; use FeatureTestTrait;
use CreatesPatients;
protected $endpoint = 'api/patvisit'; protected $endpoint = 'api/patvisit';
protected function setUp(): void
{
parent::setUp();
}
/** /**
* Test: Delete patient visit successfully (soft delete) * Test: Delete patient visit successfully (soft delete)
*/ */
@ -18,7 +25,7 @@ class PatVisitDeleteTest extends CIUnitTestCase
{ {
// Create a visit first to delete // Create a visit first to delete
$createPayload = [ $createPayload = [
"InternalPID"=> "1", "InternalPID"=> $this->createTestPatient(),
"EpisodeID"=> "TEST001", "EpisodeID"=> "TEST001",
"PatVisitADT"=> [ "PatVisitADT"=> [
"ADTCode"=> "A01", "ADTCode"=> "A01",

View File

@ -11,6 +11,11 @@ class PatVisitShowTest extends CIUnitTestCase
protected $endpoint = 'api/patvisit'; protected $endpoint = 'api/patvisit';
protected function setUp(): void
{
parent::setUp();
}
public function testShowPatientVisitSuccess() public function testShowPatientVisitSuccess()
{ {
$PVID = '1'; $PVID = '1';

View File

@ -4,12 +4,19 @@ namespace Tests\Feature\PatVisit;
use CodeIgniter\Test\FeatureTestTrait; use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\CIUnitTestCase;
use Tests\Support\Traits\CreatesPatients;
class PatVisitUpdateTest extends CIUnitTestCase class PatVisitUpdateTest extends CIUnitTestCase
{ {
use FeatureTestTrait; use FeatureTestTrait;
use CreatesPatients;
protected $endpoint = 'api/patvisit'; protected $endpoint = 'api/patvisit';
protected function setUp(): void
{
parent::setUp();
}
/** /**
* Test: Update patient visit successfully * Test: Update patient visit successfully
*/ */
@ -17,7 +24,7 @@ class PatVisitUpdateTest extends CIUnitTestCase
{ {
// First create a visit to update // First create a visit to update
$createPayload = [ $createPayload = [
"InternalPID"=> "1", "InternalPID"=> $this->createTestPatient(),
"EpisodeID"=> "TEST001", "EpisodeID"=> "TEST001",
"PatVisitADT"=> [ "PatVisitADT"=> [
"ADTCode"=> "A01", "ADTCode"=> "A01",
@ -34,7 +41,6 @@ class PatVisitUpdateTest extends CIUnitTestCase
// Now update it // Now update it
$payload = [ $payload = [
'InternalPVID' => $internalPVID,
'PVID' => $pvid, 'PVID' => $pvid,
'EpisodeID' => 'EPI001', 'EpisodeID' => 'EPI001',
'PatDiag' => [ 'PatDiag' => [
@ -47,7 +53,7 @@ class PatVisitUpdateTest extends CIUnitTestCase
] ]
]; ];
$response = $this->withBodyFormat('json')->call('patch', $this->endpoint, $payload); $response = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/' . $internalPVID, $payload);
// Pastikan response sukses (200 OK untuk update) // Pastikan response sukses (200 OK untuk update)
$response->assertStatus(200); $response->assertStatus(200);
@ -75,8 +81,8 @@ class PatVisitUpdateTest extends CIUnitTestCase
'PatDiag' => ['DiagCode' => 'B01', 'DiagName' => 'Flu'] 'PatDiag' => ['DiagCode' => 'B01', 'DiagName' => 'Flu']
]; ];
$response = $this->withBodyFormat('json')->call('patch', $this->endpoint, $payload); $response = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/0', $payload);
// Karena ID tidak ada → 400 Bad Request // Karena ID tidak valid → 400 Bad Request
$response->assertStatus(400); $response->assertStatus(400);
$response->assertJSONFragment([ $response->assertJSONFragment([
@ -91,7 +97,6 @@ class PatVisitUpdateTest extends CIUnitTestCase
public function testUpdatePatientVisitNotFound() public function testUpdatePatientVisitNotFound()
{ {
$payload = [ $payload = [
'InternalPVID' => 999999, // Non-existent visit
'EpisodeID' => 'EPI001', 'EpisodeID' => 'EPI001',
'PatDiag' => [ 'PatDiag' => [
'DiagCode' => 'A02', 'DiagCode' => 'A02',
@ -99,7 +104,7 @@ class PatVisitUpdateTest extends CIUnitTestCase
] ]
]; ];
$response = $this->withBodyFormat('json')->call('patch', $this->endpoint, $payload); $response = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/999999', $payload);
$response->assertStatus(404); $response->assertStatus(404);
$response->assertJSONFragment([ $response->assertJSONFragment([
'status' => 'error', 'status' => 'error',
@ -113,7 +118,6 @@ class PatVisitUpdateTest extends CIUnitTestCase
public function testUpdatePatientVisitInvalidId() public function testUpdatePatientVisitInvalidId()
{ {
$payload = [ $payload = [
'InternalPVID' => 'invalid',
'PVID' => 'DV0001', 'PVID' => 'DV0001',
'EpisodeID' => 'EPI001', 'EpisodeID' => 'EPI001',
'PatDiag' => [ 'PatDiag' => [
@ -126,7 +130,7 @@ class PatVisitUpdateTest extends CIUnitTestCase
] ]
]; ];
$response = $this->withBodyFormat('json')->call('patch', $this->endpoint, $payload); $response = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/invalid', $payload);
$response->assertStatus(400); $response->assertStatus(400);
$response->assertJSONFragment([ $response->assertJSONFragment([
'status' => 'error', 'status' => 'error',
@ -141,7 +145,7 @@ class PatVisitUpdateTest extends CIUnitTestCase
{ {
$payload = []; $payload = [];
$response = $this->withBodyFormat('json')->call('patch', $this->endpoint, $payload); $response = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/0', $payload);
$response->assertStatus(400); $response->assertStatus(400);
$response->assertJSONFragment([ $response->assertJSONFragment([
'status' => 'error', 'status' => 'error',

View File

@ -29,6 +29,11 @@ class PatientCheckTest extends CIUnitTestCase
protected $endpoint = 'api/patient/check'; protected $endpoint = 'api/patient/check';
protected function setUp(): void
{
parent::setUp();
}
/** /**
* Test Case 1: Check existing PatientID * Test Case 1: Check existing PatientID
* Expected: 200 OK with data: false (already exists) * Expected: 200 OK with data: false (already exists)

View File

@ -11,6 +11,11 @@ class PatientCreateTest extends CIUnitTestCase
use FeatureTestTrait; use FeatureTestTrait;
protected $endpoint = 'api/patient'; protected $endpoint = 'api/patient';
protected function setUp(): void
{
parent::setUp();
}
// 400 - Passed // 400 - Passed
// Validation Gagal - Array Tidak Complete // Validation Gagal - Array Tidak Complete
public function testCreatePatientValidationFail() { public function testCreatePatientValidationFail() {

View File

@ -28,6 +28,11 @@ class PatientDeleteTest extends CIUnitTestCase
protected $endpoint = 'api/patient'; protected $endpoint = 'api/patient';
protected function setUp(): void
{
parent::setUp();
}
/** /**
* Test Case 1: Delete without InternalPID key * Test Case 1: Delete without InternalPID key
* Expected: 500 Internal Server Error (Undefined array key) * Expected: 500 Internal Server Error (Undefined array key)

View File

@ -11,6 +11,11 @@ class PatientIndexTest extends CIUnitTestCase
protected $endpoint = 'api/patient'; protected $endpoint = 'api/patient';
protected function setUp(): void
{
parent::setUp();
}
/** /**
* Case 1: tanpa parameter, harus 200 dan status success - Passed * Case 1: tanpa parameter, harus 200 dan status success - Passed
*/ */

View File

@ -11,6 +11,11 @@ class PatientShowTest extends CIUnitTestCase
protected $endpoint = 'api/patient'; protected $endpoint = 'api/patient';
protected function setUp(): void
{
parent::setUp();
}
// 200 ok found - Passed // 200 ok found - Passed
public function testShowSingleRow() { public function testShowSingleRow() {
// Pastikan DB test punya seed patient InternalPID=10 tanpa id/addr // Pastikan DB test punya seed patient InternalPID=10 tanpa id/addr

View File

@ -10,6 +10,11 @@ class PatientUpdateTest extends CIUnitTestCase
{ {
use FeatureTestTrait; use FeatureTestTrait;
protected $endpoint = 'api/patient'; protected $endpoint = 'api/patient';
protected function setUp(): void
{
parent::setUp();
}
/** /**
* 400 - Validation Fail * 400 - Validation Fail
* Coba update tanpa field wajib harus gagal validasi. * Coba update tanpa field wajib harus gagal validasi.
@ -17,7 +22,7 @@ class PatientUpdateTest extends CIUnitTestCase
public function testUpdatePatientValidationFail() public function testUpdatePatientValidationFail()
{ {
$payload = [ 'InternalPID' => null, 'NameFirst' => '' ]; // Tidak valid $payload = [ 'InternalPID' => null, 'NameFirst' => '' ]; // Tidak valid
$result = $this->withBodyFormat('json')->call('patch', $this->endpoint, $payload); $result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload);
$result->assertStatus(400); $result->assertStatus(400);
$json = $result->getJSON(); $json = $result->getJSON();
@ -34,7 +39,6 @@ class PatientUpdateTest extends CIUnitTestCase
$faker = Factory::create('id_ID'); $faker = Factory::create('id_ID');
$payload = [ $payload = [
'InternalPID' => 999999, // Asumsi tidak ada di DB
"PatientID" => "SMAJ1", "PatientID" => "SMAJ1",
"EmailAddress1" => 'asaas7890@gmail.com', "EmailAddress1" => 'asaas7890@gmail.com',
"Phone" => $faker->numerify('08##########'), "Phone" => $faker->numerify('08##########'),
@ -50,7 +54,7 @@ class PatientUpdateTest extends CIUnitTestCase
], ],
]; ];
$result = $this->withBodyFormat('json')->call('patch', $this->endpoint, $payload); $result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/999999', $payload);
$result->assertStatus(201); // Update returns success even if no rows found (depending on logic) $result->assertStatus(201); // Update returns success even if no rows found (depending on logic)
} }
@ -66,7 +70,6 @@ class PatientUpdateTest extends CIUnitTestCase
// NOTE: Sebaiknya ambil InternalPID yang sudah ada (mock atau dari DB fixture) // NOTE: Sebaiknya ambil InternalPID yang sudah ada (mock atau dari DB fixture)
// Untuk contoh ini kita asumsikan ada ID 1 // Untuk contoh ini kita asumsikan ada ID 1
$payload = [ $payload = [
'InternalPID' => 1,
"PatientID" => "SMAJ1", "PatientID" => "SMAJ1",
'NameFirst' => $faker->firstName, 'NameFirst' => $faker->firstName,
'NameMiddle' => $faker->firstName, 'NameMiddle' => $faker->firstName,
@ -95,7 +98,7 @@ class PatientUpdateTest extends CIUnitTestCase
$payload['DeathDateTime'] = null; $payload['DeathDateTime'] = null;
} }
$result = $this->withBodyFormat('json')->call('patch', $this->endpoint, $payload); $result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload);
$result->assertStatus(201); $result->assertStatus(201);
$json = $result->getJSON(); $json = $result->getJSON();
$data = json_decode($json, true); $data = json_decode($json, true);
@ -110,7 +113,6 @@ class PatientUpdateTest extends CIUnitTestCase
$faker = Factory::create('id_ID'); $faker = Factory::create('id_ID');
$payload = [ $payload = [
'InternalPID' => 1,
"PatientID" => "SMAJ1", "PatientID" => "SMAJ1",
'NameFirst' => $faker->firstName, 'NameFirst' => $faker->firstName,
'NameMiddle' => $faker->firstName, 'NameMiddle' => $faker->firstName,
@ -138,7 +140,7 @@ class PatientUpdateTest extends CIUnitTestCase
$payload['DeathDateTime'] = null; $payload['DeathDateTime'] = null;
} }
$result = $this->withBodyFormat('json')->call('patch', $this->endpoint, $payload); $result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload);
$result->assertStatus(201); $result->assertStatus(201);
} }
@ -150,7 +152,6 @@ class PatientUpdateTest extends CIUnitTestCase
$faker = Factory::create('id_ID'); $faker = Factory::create('id_ID');
$payload = [ $payload = [
'InternalPID' => 1,
"PatientID" => "SMAJ1", "PatientID" => "SMAJ1",
'NameFirst' => $faker->firstName, 'NameFirst' => $faker->firstName,
'NameMiddle' => $faker->firstName, 'NameMiddle' => $faker->firstName,
@ -176,7 +177,7 @@ class PatientUpdateTest extends CIUnitTestCase
$payload['DeathDateTime'] = null; $payload['DeathDateTime'] = null;
} }
$result = $this->withBodyFormat('json')->call('patch', $this->endpoint, $payload); $result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload);
$result->assertStatus(201); $result->assertStatus(201);
} }
@ -188,7 +189,6 @@ class PatientUpdateTest extends CIUnitTestCase
$faker = Factory::create('id_ID'); $faker = Factory::create('id_ID');
$payload = [ $payload = [
'InternalPID' => 1,
"PatientID" => "SMAJ1", "PatientID" => "SMAJ1",
'NameFirst' => $faker->firstName, 'NameFirst' => $faker->firstName,
'NameMiddle' => $faker->firstName, 'NameMiddle' => $faker->firstName,
@ -216,7 +216,7 @@ class PatientUpdateTest extends CIUnitTestCase
$payload['DeathDateTime'] = null; $payload['DeathDateTime'] = null;
} }
$result = $this->withBodyFormat('json')->call('patch', $this->endpoint, $payload); $result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload);
$result->assertStatus(201); $result->assertStatus(201);
} }
@ -228,7 +228,6 @@ class PatientUpdateTest extends CIUnitTestCase
$faker = Factory::create('id_ID'); $faker = Factory::create('id_ID');
$payload = [ $payload = [
'InternalPID' => 1,
"PatientID" => "SMAJ1", "PatientID" => "SMAJ1",
'NameFirst' => $faker->firstName, 'NameFirst' => $faker->firstName,
'NameMiddle' => $faker->firstName, 'NameMiddle' => $faker->firstName,
@ -256,7 +255,7 @@ class PatientUpdateTest extends CIUnitTestCase
$payload['DeathDateTime'] = null; $payload['DeathDateTime'] = null;
} }
$result = $this->withBodyFormat('json')->call('patch', $this->endpoint, $payload); $result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload);
$result->assertStatus(500); $result->assertStatus(500);
$json = $result->getJSON(); $json = $result->getJSON();

View File

@ -1,16 +0,0 @@
<?php
namespace Tests\Feature;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
class SimpleTest extends CIUnitTestCase
{
use FeatureTestTrait;
public function testTrue()
{
$this->assertTrue(true);
}
}

View File

@ -0,0 +1,443 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Test;
use App\Models\Test\TestDefSiteModel;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\FeatureTestTrait;
class TestCreateVariantsTest extends CIUnitTestCase
{
use FeatureTestTrait;
private const SITE_ID = 1;
protected string $endpoint = 'api/test';
private TestDefSiteModel $testModel;
protected function setUp(): void
{
parent::setUp();
$this->testModel = new TestDefSiteModel();
}
public function testCreateTechnicalWithoutReferenceOrTestMap(): void
{
foreach (['TEST', 'PARAM'] as $type) {
$this->assertTechnicalCreated($type);
}
}
public function testCreateTechnicalWithNumericReference(): void
{
$refnum = $this->buildRefNumEntries('NMRC', true);
foreach (['TEST', 'PARAM'] as $type) {
$this->assertTechnicalCreated($type, [
'ResultType' => 'NMRIC',
'RefType' => 'RANGE',
], $refnum);
}
}
public function testCreateTechnicalWithThresholdReference(): void
{
$refnum = $this->buildRefNumEntries('THOLD', true);
foreach (['TEST', 'PARAM'] as $type) {
$this->assertTechnicalCreated($type, [
'ResultType' => 'NMRIC',
'RefType' => 'THOLD',
], $refnum);
}
}
public function testCreateTechnicalWithTextReference(): void
{
$reftxt = $this->buildRefTxtEntries('TEXT', true);
foreach (['TEST', 'PARAM'] as $type) {
$this->assertTechnicalCreated($type, [
'ResultType' => 'TEXT',
'RefType' => 'TEXT',
], null, $reftxt);
}
}
public function testCreateTechnicalWithValuesetReference(): void
{
$reftxt = $this->buildRefTxtEntries('VSET', true);
foreach (['TEST', 'PARAM'] as $type) {
$this->assertTechnicalCreated($type, [
'ResultType' => 'VSET',
'RefType' => 'VSET',
], null, $reftxt);
}
}
public function testCreateTechnicalWithNumericReferenceAndTestMap(): void
{
$refnum = $this->buildRefNumEntries('NMRC', true);
$testmap = $this->buildTestMap(true, true);
foreach (['TEST', 'PARAM'] as $type) {
$this->assertTechnicalCreated($type, [
'ResultType' => 'NMRIC',
'RefType' => 'RANGE',
], $refnum, null, $testmap);
}
}
public function testCreateTechnicalWithThresholdReferenceAndTestMap(): void
{
$refnum = $this->buildRefNumEntries('THOLD', true);
$testmap = $this->buildTestMap(true, true);
foreach (['TEST', 'PARAM'] as $type) {
$this->assertTechnicalCreated($type, [
'ResultType' => 'NMRIC',
'RefType' => 'THOLD',
], $refnum, null, $testmap);
}
}
public function testCreateTechnicalWithTextReferenceAndTestMap(): void
{
$reftxt = $this->buildRefTxtEntries('TEXT', true);
$testmap = $this->buildTestMap(true, true);
foreach (['TEST', 'PARAM'] as $type) {
$this->assertTechnicalCreated($type, [
'ResultType' => 'TEXT',
'RefType' => 'TEXT',
], null, $reftxt, $testmap);
}
}
public function testCreateTechnicalWithValuesetReferenceAndTestMap(): void
{
$reftxt = $this->buildRefTxtEntries('VSET', true);
$testmap = $this->buildTestMap(true, true);
foreach (['TEST', 'PARAM'] as $type) {
$this->assertTechnicalCreated($type, [
'ResultType' => 'VSET',
'RefType' => 'VSET',
], null, $reftxt, $testmap);
}
}
public function testCreateTechnicalValuesetWithoutReferenceButWithMap(): void
{
$testmap = $this->buildTestMap(false, true);
foreach (['TEST', 'PARAM'] as $type) {
$this->assertTechnicalCreated($type, [
'ResultType' => 'VSET',
'RefType' => 'VSET',
], null, null, $testmap);
}
}
public function testCreateCalculatedTestWithoutReferenceOrMap(): void
{
$this->assertCalculatedCreated(false);
}
public function testCreateCalculatedTestWithReferenceAndTestMap(): void
{
$refnum = $this->buildRefNumEntries('NMRC', true);
$testmap = $this->buildTestMap(true, true);
$members = $this->resolveMemberIds(['GLU', 'CREA']);
$this->assertCalculatedCreated(true, $refnum, $testmap, $members);
}
public function testCreateGroupTestWithMembers(): void
{
$members = $this->resolveMemberIds(['GLU', 'CREA']);
$testmap = $this->buildTestMap(true, true);
$payload = $this->buildGroupPayload($members, $testmap);
$response = $this->withBodyFormat('json')->call('post', $this->endpoint, $payload);
$response->assertStatus(201);
$response->assertJSONFragment([
'status' => 'created',
'message' => 'Test created successfully',
]);
$json = json_decode($response->getJSON(), true);
$this->assertArrayHasKey('data', $json);
$this->assertArrayHasKey('TestSiteId', $json['data']);
$this->assertIsInt($json['data']['TestSiteId']);
}
private function assertTechnicalCreated(
string $type,
array $details = [],
?array $refnum = null,
?array $reftxt = null,
?array $testmap = null
): void {
$payload = $this->buildTechnicalPayload($type, $details);
if ($refnum !== null) {
$payload['refnum'] = $refnum;
}
if ($reftxt !== null) {
$payload['reftxt'] = $reftxt;
}
if ($testmap !== null) {
$payload['testmap'] = $testmap;
}
$response = $this->withBodyFormat('json')->call('post', $this->endpoint, $payload);
$response->assertStatus(201);
$response->assertJSONFragment([
'status' => 'created',
'message' => 'Test created successfully',
]);
$json = json_decode($response->getJSON(), true);
$this->assertArrayHasKey('data', $json);
$this->assertArrayHasKey('TestSiteId', $json['data']);
$this->assertIsInt($json['data']['TestSiteId']);
}
private function assertCalculatedCreated(
bool $withDetails,
?array $refnum = null,
?array $testmap = null,
array $members = []
): void {
$payload = $this->buildCalculatedPayload($members);
if ($withDetails && $refnum !== null) {
$payload['refnum'] = $refnum;
}
if ($withDetails && $testmap !== null) {
$payload['testmap'] = $testmap;
}
$response = $this->withBodyFormat('json')->call('post', $this->endpoint, $payload);
$response->assertStatus(201);
$response->assertJSONFragment([
'status' => 'created',
'message' => 'Test created successfully',
]);
$json = json_decode($response->getJSON(), true);
$this->assertArrayHasKey('data', $json);
$this->assertArrayHasKey('TestSiteId', $json['data']);
$this->assertIsInt($json['data']['TestSiteId']);
}
private function buildTechnicalPayload(string $testType, array $details = []): array
{
$payload = [
'SiteID' => self::SITE_ID,
'TestSiteCode' => $this->generateTestCode($testType),
'TestSiteName' => 'Auto ' . strtoupper($testType),
'TestType' => $testType,
'SeqScr' => 900,
'SeqRpt' => 900,
'VisibleScr' => 1,
'VisibleRpt' => 1,
'CountStat' => 1,
];
$payload['details'] = $this->normalizeDetails($details);
return $payload;
}
private function buildCalculatedPayload(array $members = []): array
{
$payload = [
'SiteID' => self::SITE_ID,
'TestSiteCode' => $this->generateTestCode('CALC'),
'TestSiteName' => 'Auto CALC',
'TestType' => 'CALC',
'SeqScr' => 1000,
'SeqRpt' => 1000,
'VisibleScr' => 1,
'VisibleRpt' => 1,
'CountStat' => 0,
'details' => [
'DisciplineID' => 2,
'DepartmentID' => 2,
'FormulaCode' => '{GLU} + {CREA}',
'members' => array_map(fn ($id) => ['TestSiteID' => $id], $members),
],
];
return $payload;
}
private function buildGroupPayload(array $members, array $testmap): array
{
return [
'SiteID' => self::SITE_ID,
'TestSiteCode' => $this->generateTestCode('PANEL'),
'TestSiteName' => 'Auto Group',
'TestType' => 'GROUP',
'SeqScr' => 300,
'SeqRpt' => 300,
'VisibleScr' => 1,
'VisibleRpt' => 1,
'CountStat' => 1,
'details' => [
'members' => array_map(fn ($id) => ['TestSiteID' => $id], $members),
],
'testmap' => $testmap,
];
}
private function normalizeDetails(array $details): array
{
$normalized = [
'DisciplineID' => $details['DisciplineID'] ?? 2,
'DepartmentID' => $details['DepartmentID'] ?? 2,
'Method' => $details['Method'] ?? 'Automated test',
'Unit1' => $details['Unit1'] ?? 'mg/dL',
'Decimal' => $details['Decimal'] ?? 0,
];
foreach (['ResultType', 'RefType', 'FormulaCode', 'members', 'ExpectedTAT'] as $key) {
if (array_key_exists($key, $details)) {
$normalized[$key] = $details[$key];
}
}
return $normalized;
}
private function buildRefNumEntries(string $numRefType, bool $multiple = false): array
{
$rangeType = $numRefType === 'THOLD' ? 'PANIC' : 'REF';
$entries = [
[
'NumRefType' => $numRefType,
'RangeType' => $rangeType,
'Sex' => '2',
'LowSign' => 'GE',
'Low' => 10,
'HighSign' => 'LE',
'High' => $numRefType === 'THOLD' ? 40 : 20,
'AgeStart' => 0,
'AgeEnd' => 120,
'Flag' => 'N',
'Interpretation' => 'Normal range',
],
];
if ($multiple) {
$entries[] = [
'NumRefType' => $numRefType,
'RangeType' => $rangeType,
'Sex' => '1',
'LowSign' => '>',
'Low' => 5,
'HighSign' => '<',
'High' => $numRefType === 'THOLD' ? 50 : 15,
'AgeStart' => 0,
'AgeEnd' => 99,
'Flag' => 'N',
'Interpretation' => 'Alternate range',
];
}
return $entries;
}
private function buildRefTxtEntries(string $txtRefType, bool $multiple = false): array
{
$entries = [
[
'SpcType' => 'GEN',
'TxtRefType' => $txtRefType,
'Sex' => '2',
'AgeStart' => 0,
'AgeEnd' => 120,
'RefTxt' => $txtRefType === 'VSET' ? 'NORM=Normal;ABN=Abnormal' : 'NORM=Normal',
'Flag' => 'N',
],
];
if ($multiple) {
$entries[] = [
'SpcType' => 'GEN',
'TxtRefType' => $txtRefType,
'Sex' => '1',
'AgeStart' => 0,
'AgeEnd' => 120,
'RefTxt' => $txtRefType === 'VSET' ? 'HIGH=High;LOW=Low' : 'ABN=Abnormal',
'Flag' => 'N',
];
}
return $entries;
}
private function buildTestMap(bool $multipleMaps = false, bool $multipleDetails = false): array
{
$map = [
[
'HostType' => 'SITE',
'HostID' => '1',
'ClientType' => 'WST',
'ClientID' => '1',
'details' => [
[
'HostTestCode' => 'GLU',
'HostTestName' => 'Glucose',
'ConDefID' => 1,
'ClientTestCode' => 'GLU_C',
'ClientTestName' => 'Glucose Client',
],
],
],
];
if ($multipleDetails) {
$map[0]['details'][] = [
'HostTestCode' => 'CREA',
'HostTestName' => 'Creatinine',
'ConDefID' => 2,
'ClientTestCode' => 'CREA_C',
'ClientTestName' => 'Creatinine Client',
];
}
if ($multipleMaps) {
$map[] = [
'HostType' => 'WST',
'HostID' => '3',
'ClientType' => 'INST',
'ClientID' => '2',
'details' => [
[
'HostTestCode' => 'HB',
'HostTestName' => 'Hemoglobin',
'ConDefID' => 3,
'ClientTestCode' => 'HB_C',
'ClientTestName' => 'Hemoglobin Client',
],
],
];
}
return $map;
}
private function generateTestCode(string $prefix): string
{
$clean = strtoupper(substr($prefix, 0, 3));
$suffix = strtoupper(substr(md5((string) microtime(true) . random_int(0, 9999)), 0, 6));
return substr($clean . $suffix, 0, 10);
}
private function resolveMemberIds(array $codes): array
{
$ids = [];
foreach ($codes as $code) {
$row = $this->testModel->where('TestSiteCode', $code)->where('EndDate IS NULL')->first();
$this->assertNotEmpty($row, "Seeded test code {$code} not found");
$ids[] = (int) $row['TestSiteID'];
}
return $ids;
}
}

View File

@ -1,417 +0,0 @@
<?php
namespace Tests\Feature;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
use Firebase\JWT\JWT;
class TestsControllerTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $token;
protected function setUp(): void
{
parent::setUp();
// Generate JWT Token
$key = getenv('JWT_SECRET') ?: 'my-secret-key';
$payload = [
'iss' => 'localhost',
'aud' => 'localhost',
'iat' => time(),
'nbf' => time(),
'exp' => time() + 3600,
'uid' => 1,
'email' => 'admin@admin.com'
];
$this->token = JWT::encode($payload, $key, 'HS256');
}
protected function callProtected($method, $path, $params = [])
{
return $this->withHeaders(['Cookie' => 'token=' . $this->token])
->call($method, $path, $params);
}
public function testIndexReturnsSuccess()
{
$result = $this->callProtected('get', 'api/tests');
$result->assertStatus(200);
$json = $result->getJSON();
$data = json_decode($json, true);
$this->assertEquals('success', $data['status']);
$this->assertIsArray($data['data']);
}
public function testShowReturnsDataIfFound()
{
// First get an ID
$indexResult = $this->callProtected('get', 'api/tests');
$indexData = json_decode($indexResult->getJSON(), true);
if (empty($indexData['data'])) {
$this->markTestSkipped('No test definitions found in database to test show.');
}
$id = $indexData['data'][0]['TestSiteID'];
$result = $this->callProtected('get', "api/tests/$id");
$result->assertStatus(200);
$json = $result->getJSON();
$data = json_decode($json, true);
$this->assertEquals('success', $data['status']);
$this->assertIsArray($data['data']);
$this->assertEquals($id, $data['data']['TestSiteID']);
}
public function testCreateTestWithThreshold()
{
$testData = [
'TestSiteCode' => 'TH' . substr(time(), -4),
'TestSiteName' => 'Threshold Test ' . time(),
'TestType' => 'TEST',
'SiteID' => 1,
'details' => [
'RefType' => 'THOLD',
'ResultType' => 'NMRIC'
],
'refnum' => [
[
'NumRefType' => 'THOLD',
'RangeType' => 'VALUE',
'Sex' => '1',
'AgeStart' => 0,
'AgeEnd' => 100,
'LowSign' => '>',
'Low' => 5.5,
'Interpretation' => 'High'
]
]
];
$result = $this->withHeaders(['Cookie' => 'token=' . $this->token])
->withBody(json_encode($testData))
->call('post', 'api/tests');
$result->assertStatus(201);
$json = $result->getJSON();
$data = json_decode($json, true);
$this->assertEquals('created', $data['status']);
$id = $data['data']['TestSiteId'];
// Verify retrieval
$showResult = $this->callProtected('get', "api/tests/$id");
$showData = json_decode($showResult->getJSON(), true);
$this->assertArrayHasKey('refnum', $showData['data']);
$this->assertCount(1, $showData['data']['refnum']);
$this->assertEquals(5.5, $showData['data']['refnum'][0]['Low']);
$this->assertEquals('High', $showData['data']['refnum'][0]['Interpretation']);
}
/**
* Test valid TestType and ResultType combinations
* @dataProvider validTestTypeResultTypeProvider
*/
public function testValidTestTypeResultTypeCombinations($testType, $resultType, $refType, $shouldSucceed)
{
$testData = [
'TestSiteCode' => 'TT' . substr(time(), -4) . rand(10, 99),
'TestSiteName' => 'Type Test ' . time(),
'TestType' => $testType,
'SiteID' => 1,
'details' => [
'ResultType' => $resultType,
'RefType' => $refType
]
];
// Add reference data if needed
if ($refType === 'RANGE' || $refType === 'THOLD') {
$testData['refnum'] = [
[
'NumRefType' => $refType,
'RangeType' => 'VALUE',
'Sex' => '1',
'AgeStart' => 0,
'AgeEnd' => 100,
'LowSign' => '>',
'Low' => 5.5,
'Interpretation' => 'Normal'
]
];
} elseif ($refType === 'VSET' || $refType === 'TEXT') {
$testData['reftxt'] = [
[
'TxtRefType' => $refType,
'Sex' => '1',
'AgeStart' => 0,
'AgeEnd' => 100,
'RefTxt' => 'Normal range text',
'Flag' => 'N'
]
];
}
$result = $this->withHeaders(['Cookie' => 'token=' . $this->token])
->withBody(json_encode($testData))
->call('post', 'api/tests');
if ($shouldSucceed) {
$result->assertStatus(201);
$data = json_decode($result->getJSON(), true);
$this->assertEquals('created', $data['status']);
} else {
// Invalid combinations should fail validation or return error
$this->assertGreaterThanOrEqual(400, $result->getStatusCode());
}
}
public function validTestTypeResultTypeProvider()
{
return [
// TEST type - can have NMRIC, RANGE, TEXT, VSET
'TEST with NMRIC' => ['TEST', 'NMRIC', 'RANGE', true],
'TEST with RANGE' => ['TEST', 'RANGE', 'RANGE', true],
'TEST with TEXT' => ['TEST', 'TEXT', 'TEXT', true],
'TEST with VSET' => ['TEST', 'VSET', 'VSET', true],
'TEST with THOLD' => ['TEST', 'NMRIC', 'THOLD', true],
// PARAM type - can have NMRIC, RANGE, TEXT, VSET
'PARAM with NMRIC' => ['PARAM', 'NMRIC', 'RANGE', true],
'PARAM with RANGE' => ['PARAM', 'RANGE', 'RANGE', true],
'PARAM with TEXT' => ['PARAM', 'TEXT', 'TEXT', true],
'PARAM with VSET' => ['PARAM', 'VSET', 'VSET', true],
// CALC type - only NMRIC
'CALC with NMRIC' => ['CALC', 'NMRIC', 'RANGE', true],
// GROUP type - only NORES
'GROUP with NORES' => ['GROUP', 'NORES', 'NOREF', true],
// TITLE type - only NORES
'TITLE with NORES' => ['TITLE', 'NORES', 'NOREF', true],
];
}
/**
* Test ResultType to RefType mapping
* @dataProvider resultTypeToRefTypeProvider
*/
public function testResultTypeToRefTypeMapping($resultType, $refType, $expectedRefTable)
{
$testData = [
'TestSiteCode' => 'RT' . substr(time(), -4) . rand(10, 99),
'TestSiteName' => 'RefType Test ' . time(),
'TestType' => 'TEST',
'SiteID' => 1,
'details' => [
'ResultType' => $resultType,
'RefType' => $refType
]
];
// Add appropriate reference data
if ($expectedRefTable === 'refnum') {
$testData['refnum'] = [
[
'NumRefType' => $refType,
'RangeType' => 'VALUE',
'Sex' => '1',
'AgeStart' => 18,
'AgeEnd' => 99,
'LowSign' => 'GE',
'Low' => 10,
'HighSign' => 'LE',
'High' => 20,
'Interpretation' => 'Normal'
]
];
} elseif ($expectedRefTable === 'reftxt') {
$testData['reftxt'] = [
[
'TxtRefType' => $refType,
'Sex' => '1',
'AgeStart' => 18,
'AgeEnd' => 99,
'RefTxt' => 'Reference text',
'Flag' => 'N'
]
];
}
$result = $this->withHeaders(['Cookie' => 'token=' . $this->token])
->withBody(json_encode($testData))
->call('post', 'api/tests');
$result->assertStatus(201);
$data = json_decode($result->getJSON(), true);
$id = $data['data']['TestSiteId'];
// Verify the reference data was stored in correct table
$showResult = $this->callProtected('get', "api/tests/$id");
$showData = json_decode($showResult->getJSON(), true);
if ($expectedRefTable === 'refnum') {
$this->assertArrayHasKey('refnum', $showData['data']);
$this->assertNotEmpty($showData['data']['refnum']);
} elseif ($expectedRefTable === 'reftxt') {
$this->assertArrayHasKey('reftxt', $showData['data']);
$this->assertNotEmpty($showData['data']['reftxt']);
}
}
public function resultTypeToRefTypeProvider()
{
return [
// NMRIC with RANGE → refnum table
'NMRIC with RANGE uses refnum' => ['NMRIC', 'RANGE', 'refnum'],
// NMRIC with THOLD → refnum table
'NMRIC with THOLD uses refnum' => ['NMRIC', 'THOLD', 'refnum'],
// RANGE with RANGE → refnum table
'RANGE with RANGE uses refnum' => ['RANGE', 'RANGE', 'refnum'],
// RANGE with THOLD → refnum table
'RANGE with THOLD uses refnum' => ['RANGE', 'THOLD', 'refnum'],
// VSET with VSET → reftxt table
'VSET with VSET uses reftxt' => ['VSET', 'VSET', 'reftxt'],
// TEXT with TEXT → reftxt table
'TEXT with TEXT uses reftxt' => ['TEXT', 'TEXT', 'reftxt'],
];
}
/**
* Test CALC type always has NMRIC result type
*/
public function testCalcTypeAlwaysHasNmricResultType()
{
$testData = [
'TestSiteCode' => 'CALC' . substr(time(), -4),
'TestSiteName' => 'Calc Test ' . time(),
'TestType' => 'CALC',
'SiteID' => 1,
'details' => [
'DisciplineID' => 1,
'DepartmentID' => 1,
'FormulaCode' => 'WEIGHT/(HEIGHT/100)^2',
'Unit1' => 'kg/m2',
'Decimal' => 1
],
'members' => []
];
$result = $this->withHeaders(['Cookie' => 'token=' . $this->token])
->withBody(json_encode($testData))
->call('post', 'api/tests');
$result->assertStatus(201);
$data = json_decode($result->getJSON(), true);
$id = $data['data']['TestSiteId'];
// Verify CALC test was created
$showResult = $this->callProtected('get', "api/tests/$id");
$showData = json_decode($showResult->getJSON(), true);
$this->assertEquals('CALC', $showData['data']['TestType']);
$this->assertArrayHasKey('testdefcal', $showData['data']);
}
/**
* Test GROUP type has no result (NORES)
*/
public function testGroupTypeHasNoResult()
{
// First create member tests
$member1Data = [
'TestSiteCode' => 'M1' . substr(time(), -4),
'TestSiteName' => 'Member 1 ' . time(),
'TestType' => 'TEST',
'SiteID' => 1,
'details' => [
'ResultType' => 'NMRIC',
'RefType' => 'RANGE'
],
'refnum' => [
[
'NumRefType' => 'RANGE',
'RangeType' => 'VALUE',
'Sex' => '1',
'AgeStart' => 0,
'AgeEnd' => 100,
'LowSign' => '>',
'Low' => 5.5,
'Interpretation' => 'Normal'
]
]
];
$result1 = $this->withHeaders(['Cookie' => 'token=' . $this->token])
->withBody(json_encode($member1Data))
->call('post', 'api/tests');
$data1 = json_decode($result1->getJSON(), true);
$member1Id = $data1['data']['TestSiteId'];
$member2Data = [
'TestSiteCode' => 'M2' . substr(time(), -4),
'TestSiteName' => 'Member 2 ' . time(),
'TestType' => 'TEST',
'SiteID' => 1,
'details' => [
'ResultType' => 'NMRIC',
'RefType' => 'RANGE'
],
'refnum' => [
[
'NumRefType' => 'RANGE',
'RangeType' => 'VALUE',
'Sex' => '1',
'AgeStart' => 0,
'AgeEnd' => 100,
'LowSign' => '>',
'Low' => 5.5,
'Interpretation' => 'Normal'
]
]
];
$result2 = $this->withHeaders(['Cookie' => 'token=' . $this->token])
->withBody(json_encode($member2Data))
->call('post', 'api/tests');
$data2 = json_decode($result2->getJSON(), true);
$member2Id = $data2['data']['TestSiteId'];
// Create group test
$groupData = [
'TestSiteCode' => 'GRP' . substr(time(), -4),
'TestSiteName' => 'Group Test ' . time(),
'TestType' => 'GROUP',
'SiteID' => 1,
'details' => [
'ResultType' => 'NORES',
'RefType' => 'NOREF'
],
'members' => [$member1Id, $member2Id]
];
$result = $this->withHeaders(['Cookie' => 'token=' . $this->token])
->withBody(json_encode($groupData))
->call('post', 'api/tests');
$result->assertStatus(201);
$data = json_decode($result->getJSON(), true);
$id = $data['data']['TestSiteId'];
// Verify GROUP test was created with members
$showResult = $this->callProtected('get', "api/tests/$id");
$showData = json_decode($showResult->getJSON(), true);
$this->assertEquals('GROUP', $showData['data']['TestType']);
$this->assertArrayHasKey('testdefgrp', $showData['data']);
}
}

View File

@ -1,111 +0,0 @@
<?php
namespace Tests\Feature;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
class UniformShowTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api';
protected $token;
protected function setUp(): void
{
parent::setUp();
// Generate Token
$key = getenv('JWT_SECRET') ?: 'my-secret-key'; // Fallback if env not loaded in test
$payload = [
'iss' => 'localhost',
'aud' => 'localhost',
'iat' => time(),
'nbf' => time(),
'exp' => time() + 3600,
'uid' => 1,
'email' => 'admin@admin.com'
];
$this->token = \Firebase\JWT\JWT::encode($payload, $key, 'HS256');
}
// Override get to inject cookie header
public function get(string $path, array $options = []) {
$this->withHeaders(['Cookie' => 'token=' . $this->token]);
return $this->call('get', $path, $options);
}
/**
* Test that show endpoints return a single object (associative array) in 'data' when found.
*/
public function testShowEndpointsReturnObjectWhenFound()
{
// representative endpoints.
$endpoints = [
'api/location',
'api/organization/site',
'api/organization/account',
'api/patient',
'api/tests',
'api/specimen/containerdef',
'api/contact',
];
foreach ($endpoints as $url) {
// We first check index to get a valid ID if possible
$indexResult = $this->get($url);
$indexBody = json_decode($indexResult->response()->getBody(), true);
$id = 1; // logical default
if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) {
$firstItem = $indexBody['data'][0];
// Try to guess ID key
$idKeys = ['LocationID', 'SiteID', 'AccountID', 'InternalPID', 'TestSiteID', 'ConDefID', 'ContactID', 'VID', 'id'];
foreach ($idKeys as $key) {
if (isset($firstItem[$key])) {
$id = $firstItem[$key];
break;
}
}
}
$showUrl = $url . '/' . $id;
$result = $this->get($showUrl);
$body = json_decode($result->response()->getBody(), true);
if ($result->response()->getStatusCode() === 200 && isset($body['data']) && $body['data'] !== null) {
$this->assertTrue(
$this->is_assoc($body['data']),
"Endpoint $showUrl should return an object in 'data', but got a sequential array or empty array. Body: " . $result->response()->getBody()
);
}
}
}
public function testShowEndpointsReturnNullWhenNotFound()
{
$endpoints = [
'api/location/9999999',
'api/organization/site/9999999',
'api/patient/9999999',
];
foreach ($endpoints as $url) {
$result = $this->get($url);
$result->assertStatus(200);
$body = json_decode($result->response()->getBody(), true);
$this->assertArrayHasKey('data', $body, "Endpoint $url missing 'data' key. Body: " . $result->response()->getBody());
$this->assertNull($body['data'], "Endpoint $url should return null in 'data' when not found. Body: " . $result->response()->getBody());
}
}
/**
* Helper to check if array is associative.
*/
private function is_assoc(array $arr)
{
if (array() === $arr) return false;
return array_keys($arr) !== range(0, count($arr) - 1);
}
}

View File

@ -76,8 +76,8 @@ class ValueSetControllerTest extends CIUnitTestCase
$values = array_column($data['data'], 'value'); $values = array_column($data['data'], 'value');
$labels = array_column($data['data'], 'label'); $labels = array_column($data['data'], 'label');
$this->assertContains('1', $values); $this->assertContains('F', $values);
$this->assertContains('2', $values); $this->assertContains('M', $values);
$this->assertContains('Female', $labels); $this->assertContains('Female', $labels);
$this->assertContains('Male', $labels); $this->assertContains('Male', $labels);
} }

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/codeigniter4/framework/system/Test/bootstrap.php';
use CodeIgniter\Database\MigrationRunner;
use Config\Database;
use Config\Migrations as MigrationsConfig;
if (defined('CLQMS_PHPUNIT_BOOTSTRAPPED') || ENVIRONMENT !== 'testing') {
return;
}
define('CLQMS_PHPUNIT_BOOTSTRAPPED', true);
$db = Database::connect('tests');
$forge = Database::forge('tests');
$db->query('SET FOREIGN_KEY_CHECKS=0');
foreach ($db->listTables() as $table) {
$forge->dropTable($table, true);
}
$db->query('SET FOREIGN_KEY_CHECKS=1');
$migrationsConfig = config(MigrationsConfig::class);
$migrationRunner = new MigrationRunner($migrationsConfig, 'tests');
try {
$migrationRunner->latest();
} catch (DatabaseException $e) {
$message = $e->getMessage();
if (strpos($message, 'already exists') === false) {
throw $e;
}
}
$initialBufferLevel = ob_get_level();
ob_start();
try {
$seeder = Database::seeder('tests');
$seeder->setSilent(true)->call('DBSeeder');
} finally {
while (ob_get_level() > $initialBufferLevel) {
ob_end_clean();
}
}

View File

@ -1,271 +0,0 @@
<?php
namespace Tests\Unit\Rule;
use App\Models\Rule\RuleDefModel;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
class RuleDefModelTest extends CIUnitTestCase
{
use DatabaseTestTrait;
protected $model;
protected $seed = \App\Database\Seeds\TestSeeder::class;
protected function setUp(): void
{
parent::setUp();
$this->model = new RuleDefModel();
}
/**
* Test that getActiveByEvent returns empty array when TestSiteID is null
* This ensures rules are standalone and must be explicitly included by test
*/
public function testGetActiveByEventReturnsEmptyWithoutTestSiteID(): void
{
$rules = $this->model->getActiveByEvent('test_created', null);
$this->assertIsArray($rules);
$this->assertEmpty($rules);
}
/**
* Test that a rule can be linked to multiple tests
*/
public function testRuleCanBeLinkedToMultipleTests(): void
{
$db = \Config\Database::connect();
// Get two existing tests
$tests = $db->table('testdefsite')
->where('EndDate', null)
->limit(2)
->get()
->getResultArray();
if (count($tests) < 2) {
$this->markTestSkipped('Need at least 2 tests available in testdefsite table');
}
$testSiteID1 = (int) $tests[0]['TestSiteID'];
$testSiteID2 = (int) $tests[1]['TestSiteID'];
// Create a rule
$ruleData = [
'RuleCode' => 'MULTI_TEST_RULE',
'RuleName' => 'Multi Test Rule',
'EventCode' => 'test_created',
'ConditionExpr' => 'order["InternalOID"] > 0',
'CreateDate' => date('Y-m-d H:i:s'),
];
$ruleID = $this->model->insert($ruleData, true);
$this->assertNotFalse($ruleID);
// Link rule to both tests
$this->model->linkTest($ruleID, $testSiteID1);
$this->model->linkTest($ruleID, $testSiteID2);
// Verify rule is returned for both test sites
$rules1 = $this->model->getActiveByEvent('test_created', $testSiteID1);
$this->assertNotEmpty($rules1);
$this->assertCount(1, $rules1);
$this->assertEquals($ruleID, $rules1[0]['RuleID']);
$rules2 = $this->model->getActiveByEvent('test_created', $testSiteID2);
$this->assertNotEmpty($rules2);
$this->assertCount(1, $rules2);
$this->assertEquals($ruleID, $rules2[0]['RuleID']);
// Cleanup
$this->model->delete($ruleID);
}
/**
* Test that rules only work when explicitly linked to a test
*/
public function testRulesOnlyWorkWhenExplicitlyLinked(): void
{
$db = \Config\Database::connect();
// Get an existing test
$test = $db->table('testdefsite')
->where('EndDate', null)
->limit(1)
->get()
->getRowArray();
if (!$test) {
$this->markTestSkipped('No tests available in testdefsite table');
}
$testSiteID = (int) $test['TestSiteID'];
// Create a rule (not linked to any test yet)
$ruleData = [
'RuleCode' => 'UNLINKED_RULE',
'RuleName' => 'Unlinked Test Rule',
'EventCode' => 'test_created',
'ConditionExpr' => 'true',
'CreateDate' => date('Y-m-d H:i:s'),
];
$ruleID = $this->model->insert($ruleData, true);
$this->assertNotFalse($ruleID);
// Verify rule is NOT returned when not linked
$rules = $this->model->getActiveByEvent('test_created', $testSiteID);
$this->assertEmpty($rules);
// Now link the rule
$this->model->linkTest($ruleID, $testSiteID);
// Verify rule is now returned
$rules = $this->model->getActiveByEvent('test_created', $testSiteID);
$this->assertNotEmpty($rules);
$this->assertCount(1, $rules);
// Cleanup
$this->model->delete($ruleID);
}
/**
* Test that unlinking a test removes the rule for that test
*/
public function testUnlinkingTestRemovesRule(): void
{
$db = \Config\Database::connect();
// Get two existing tests
$tests = $db->table('testdefsite')
->where('EndDate', null)
->limit(2)
->get()
->getResultArray();
if (count($tests) < 2) {
$this->markTestSkipped('Need at least 2 tests available in testdefsite table');
}
$testSiteID1 = (int) $tests[0]['TestSiteID'];
$testSiteID2 = (int) $tests[1]['TestSiteID'];
// Create a rule and link to both tests
$ruleData = [
'RuleCode' => 'UNLINK_TEST',
'RuleName' => 'Unlink Test Rule',
'EventCode' => 'test_created',
'ConditionExpr' => 'true',
'CreateDate' => date('Y-m-d H:i:s'),
];
$ruleID = $this->model->insert($ruleData, true);
$this->model->linkTest($ruleID, $testSiteID1);
$this->model->linkTest($ruleID, $testSiteID2);
// Verify rule is returned for both
$this->assertNotEmpty($this->model->getActiveByEvent('test_created', $testSiteID1));
$this->assertNotEmpty($this->model->getActiveByEvent('test_created', $testSiteID2));
// Unlink from first test
$this->model->unlinkTest($ruleID, $testSiteID1);
// Verify rule is NOT returned for first test but still for second
$this->assertEmpty($this->model->getActiveByEvent('test_created', $testSiteID1));
$this->assertNotEmpty($this->model->getActiveByEvent('test_created', $testSiteID2));
// Cleanup
$this->model->delete($ruleID);
}
/**
* Test that deleted (soft deleted) rules are not returned
*/
public function testDeletedRulesAreNotReturned(): void
{
$db = \Config\Database::connect();
$test = $db->table('testdefsite')
->where('EndDate', null)
->limit(1)
->get()
->getRowArray();
if (!$test) {
$this->markTestSkipped('No tests available in testdefsite table');
}
$testSiteID = (int) $test['TestSiteID'];
// Create a rule and link it
$ruleData = [
'RuleCode' => 'DELETED_RULE',
'RuleName' => 'Deleted Test Rule',
'EventCode' => 'test_created',
'ConditionExpr' => 'true',
'CreateDate' => date('Y-m-d H:i:s'),
];
$ruleID = $this->model->insert($ruleData, true);
$this->assertNotFalse($ruleID);
$this->model->linkTest($ruleID, $testSiteID);
// Verify rule is returned
$rules = $this->model->getActiveByEvent('test_created', $testSiteID);
$this->assertNotEmpty($rules);
// Soft delete the rule
$this->model->delete($ruleID);
// Verify deleted rule is NOT returned
$rules = $this->model->getActiveByEvent('test_created', $testSiteID);
$this->assertEmpty($rules);
}
/**
* Test getting linked tests for a rule
*/
public function testGetLinkedTests(): void
{
$db = \Config\Database::connect();
// Get two existing tests
$tests = $db->table('testdefsite')
->where('EndDate', null)
->limit(2)
->get()
->getResultArray();
if (count($tests) < 2) {
$this->markTestSkipped('Need at least 2 tests available');
}
$testSiteID1 = (int) $tests[0]['TestSiteID'];
$testSiteID2 = (int) $tests[1]['TestSiteID'];
// Create a rule
$ruleData = [
'RuleCode' => 'LINKED_TESTS',
'RuleName' => 'Linked Tests Rule',
'EventCode' => 'test_created',
'ConditionExpr' => 'true',
'CreateDate' => date('Y-m-d H:i:s'),
];
$ruleID = $this->model->insert($ruleData, true);
$this->model->linkTest($ruleID, $testSiteID1);
$this->model->linkTest($ruleID, $testSiteID2);
// Get linked tests
$linkedTests = $this->model->getLinkedTests($ruleID);
$this->assertCount(2, $linkedTests);
$this->assertContains($testSiteID1, $linkedTests);
$this->assertContains($testSiteID2, $linkedTests);
// Cleanup
$this->model->delete($ruleID);
}
}

View File

@ -1,355 +0,0 @@
<?php
namespace Tests\Unit\Rules;
use App\Services\RuleEngineService;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
/**
* Integration tests for Rule Engine with multi-action support
*/
class RuleEngineMultiActionTest extends CIUnitTestCase
{
use DatabaseTestTrait;
protected RuleEngineService $engine;
protected $seed = [];
public function setUp(): void
{
parent::setUp();
$this->engine = new RuleEngineService();
// Seed test data
$this->seedTestData();
}
private function seedTestData(): void
{
$db = \Config\Database::connect();
// Insert test testdefsite
$db->table('testdefsite')->insert([
'TestSiteCode' => 'GLU',
'TestSiteName' => 'Glucose',
'CreateDate' => date('Y-m-d H:i:s'),
]);
$db->table('testdefsite')->insert([
'TestSiteCode' => 'HBA1C',
'TestSiteName' => 'HbA1c',
'CreateDate' => date('Y-m-d H:i:s'),
]);
// Insert test order
$db->table('orders')->insert([
'OrderID' => 'ORD001',
'Sex' => 'M',
'Age' => 45,
'Priority' => 'R',
'CreateDate' => date('Y-m-d H:i:s'),
]);
}
public function testExecuteSetResult()
{
$db = \Config\Database::connect();
// Get order ID
$order = $db->table('orders')->where('OrderID', 'ORD001')->get()->getRowArray();
$internalOID = $order['InternalOID'] ?? null;
// Get test site ID
$testSite = $db->table('testdefsite')->where('TestSiteCode', 'GLU')->get()->getRowArray();
$testSiteID = $testSite['TestSiteID'] ?? null;
$this->assertNotNull($internalOID);
$this->assertNotNull($testSiteID);
// Insert initial patres row
$db->table('patres')->insert([
'OrderID' => $internalOID,
'TestSiteID' => $testSiteID,
'CreateDate' => date('Y-m-d H:i:s'),
]);
// Execute RESULT_SET action
$action = [
'type' => 'RESULT_SET',
'value' => 5.5,
];
$context = [
'order' => [
'InternalOID' => $internalOID,
'Sex' => 'M',
'Age' => 45,
],
'testSiteID' => $testSiteID,
];
$this->invokeMethod($this->engine, 'executeAction', [$action, $context]);
// Verify result was set
$patres = $db->table('patres')
->where('OrderID', $internalOID)
->where('TestSiteID', $testSiteID)
->where('DelDate', null)
->get()->getRowArray();
$this->assertNotNull($patres);
$this->assertEquals(5.5, $patres['Result']);
}
public function testExecuteInsertTest()
{
$db = \Config\Database::connect();
// Get order ID
$order = $db->table('orders')->where('OrderID', 'ORD001')->get()->getRowArray();
$internalOID = $order['InternalOID'] ?? null;
$this->assertNotNull($internalOID);
// Execute TEST_INSERT action
$action = [
'type' => 'TEST_INSERT',
'testCode' => 'HBA1C',
];
$context = [
'order' => [
'InternalOID' => $internalOID,
],
];
$this->invokeMethod($this->engine, 'executeAction', [$action, $context]);
// Verify test was inserted
$testSite = $db->table('testdefsite')->where('TestSiteCode', 'HBA1C')->get()->getRowArray();
$testSiteID = $testSite['TestSiteID'] ?? null;
$patres = $db->table('patres')
->where('OrderID', $internalOID)
->where('TestSiteID', $testSiteID)
->where('DelDate', null)
->get()->getRowArray();
$this->assertNotNull($patres);
}
public function testExecuteAddComment()
{
$db = \Config\Database::connect();
// Get order ID
$order = $db->table('orders')->where('OrderID', 'ORD001')->get()->getRowArray();
$internalOID = $order['InternalOID'] ?? null;
$this->assertNotNull($internalOID);
// Execute COMMENT_INSERT action
$action = [
'type' => 'COMMENT_INSERT',
'comment' => 'Test comment from rule engine',
];
$context = [
'order' => [
'InternalOID' => $internalOID,
],
];
$this->invokeMethod($this->engine, 'executeAction', [$action, $context]);
// Verify comment was added
$comment = $db->table('ordercom')
->where('InternalOID', $internalOID)
->where('Comment', 'Test comment from rule engine')
->get()->getRowArray();
$this->assertNotNull($comment);
}
public function testExecuteNoOp()
{
// Execute NO_OP action - should not throw or do anything
$action = [
'type' => 'NO_OP',
];
$context = [
'order' => [
'InternalOID' => 1,
],
];
// Should not throw exception
$this->invokeMethod($this->engine, 'executeAction', [$action, $context]);
// Test passes if no exception is thrown
$this->assertTrue(true);
}
public function testMultiActionExecution()
{
$db = \Config\Database::connect();
// Get order ID
$order = $db->table('orders')->where('OrderID', 'ORD001')->get()->getRowArray();
$internalOID = $order['InternalOID'] ?? null;
// Get test site ID
$testSite = $db->table('testdefsite')->where('TestSiteCode', 'GLU')->get()->getRowArray();
$testSiteID = $testSite['TestSiteID'] ?? null;
$this->assertNotNull($internalOID);
$this->assertNotNull($testSiteID);
// Insert initial patres row
$db->table('patres')->insert([
'OrderID' => $internalOID,
'TestSiteID' => $testSiteID,
'CreateDate' => date('Y-m-d H:i:s'),
]);
// Execute multiple actions
$actions = [
[
'type' => 'RESULT_SET',
'value' => 7.2,
],
[
'type' => 'COMMENT_INSERT',
'comment' => 'Multi-action test',
],
[
'type' => 'TEST_INSERT',
'testCode' => 'HBA1C',
],
];
$context = [
'order' => [
'InternalOID' => $internalOID,
],
'testSiteID' => $testSiteID,
];
foreach ($actions as $action) {
$this->invokeMethod($this->engine, 'executeAction', [$action, $context]);
}
// Verify SET_RESULT
$patres = $db->table('patres')
->where('OrderID', $internalOID)
->where('TestSiteID', $testSiteID)
->where('DelDate', null)
->get()->getRowArray();
$this->assertEquals(7.2, $patres['Result']);
// Verify ADD_COMMENT
$comment = $db->table('ordercom')
->where('InternalOID', $internalOID)
->where('Comment', 'Multi-action test')
->get()->getRowArray();
$this->assertNotNull($comment);
// Verify INSERT_TEST
$hba1cSite = $db->table('testdefsite')->where('TestSiteCode', 'HBA1C')->get()->getRowArray();
$hba1cPatres = $db->table('patres')
->where('OrderID', $internalOID)
->where('TestSiteID', $hba1cSite['TestSiteID'])
->where('DelDate', null)
->get()->getRowArray();
$this->assertNotNull($hba1cPatres);
}
public function testIsTestRequested()
{
$db = \Config\Database::connect();
// Get order ID
$order = $db->table('orders')->where('OrderID', 'ORD001')->get()->getRowArray();
$internalOID = $order['InternalOID'] ?? null;
// Get test site ID
$testSite = $db->table('testdefsite')->where('TestSiteCode', 'GLU')->get()->getRowArray();
$testSiteID = $testSite['TestSiteID'] ?? null;
// Insert patres row for GLU test
$db->table('patres')->insert([
'OrderID' => $internalOID,
'TestSiteID' => $testSiteID,
'CreateDate' => date('Y-m-d H:i:s'),
]);
// Test isTestRequested method
$result = $this->invokeMethod($this->engine, 'isTestRequested', ['GLU', [
'order' => ['InternalOID' => $internalOID],
]]);
$this->assertTrue($result);
// Test for non-existent test
$result = $this->invokeMethod($this->engine, 'isTestRequested', ['NONEXISTENT', [
'order' => ['InternalOID' => $internalOID],
]]);
$this->assertFalse($result);
}
public function testExecuteDeleteTest(): void
{
$db = \Config\Database::connect();
$order = $db->table('orders')->where('OrderID', 'ORD001')->get()->getRowArray();
$internalOID = $order['InternalOID'] ?? null;
$this->assertNotNull($internalOID);
$testSite = $db->table('testdefsite')->where('TestSiteCode', 'HBA1C')->get()->getRowArray();
$testSiteID = $testSite['TestSiteID'] ?? null;
$this->assertNotNull($testSiteID);
// Insert a patres row to delete
$db->table('patres')->insert([
'OrderID' => $internalOID,
'TestSiteID' => $testSiteID,
'CreateDate' => date('Y-m-d H:i:s'),
]);
$action = [
'type' => 'TEST_DELETE',
'testCode' => 'HBA1C',
];
$context = [
'order' => [
'InternalOID' => $internalOID,
],
];
$this->invokeMethod($this->engine, 'executeAction', [$action, $context]);
$deleted = $db->table('patres')
->where('OrderID', $internalOID)
->where('TestSiteID', $testSiteID)
->get()
->getRowArray();
$this->assertNotNull($deleted);
$this->assertNotEmpty($deleted['DelDate'] ?? null);
}
/**
* Helper to invoke protected/private methods
*/
private function invokeMethod($object, $methodName, array $parameters = [])
{
$reflection = new \ReflectionClass(get_class($object));
$method = $reflection->getMethod($methodName);
$method->setAccessible(true);
return $method->invokeArgs($object, $parameters);
}
}

View File

@ -1,60 +0,0 @@
<?php
namespace Tests\Unit\Rules;
use App\Services\RuleExpressionService;
use CodeIgniter\Test\CIUnitTestCase;
class RuleExpressionCompileTest extends CIUnitTestCase
{
protected function setUp(): void
{
parent::setUp();
if (!class_exists('Symfony\Component\ExpressionLanguage\ExpressionLanguage')) {
$this->markTestSkipped('Symfony ExpressionLanguage not installed');
}
}
public function testCompileSexCondition(): void
{
$svc = new RuleExpressionService();
$compiled = $svc->compile("if(sex('F') ? result_set(0.7) : result_set(1))");
$this->assertIsArray($compiled);
$this->assertEquals('patient["Sex"] == "F"', $compiled['conditionExpr']);
$this->assertEquals(0.7, $compiled['then'][0]['value']);
$this->assertEquals(1, $compiled['else'][0]['value']);
$this->assertStringContainsString('patient["Sex"] == "F"', $compiled['valueExpr']);
}
public function testCompilePriorityCondition(): void
{
$svc = new RuleExpressionService();
$compiled = $svc->compile("if(priority('S') ? result_set('urgent') : result_set('normal'))");
$this->assertIsArray($compiled);
$this->assertEquals('order["Priority"] == "S"', $compiled['conditionExpr']);
$this->assertEquals('urgent', $compiled['then'][0]['value']);
$this->assertEquals('normal', $compiled['else'][0]['value']);
}
public function testCompileInvalidSyntax(): void
{
$svc = new RuleExpressionService();
$this->expectException(\InvalidArgumentException::class);
$svc->compile("invalid syntax here");
}
public function testCompileEmptyReturnsEmpty(): void
{
$svc = new RuleExpressionService();
$compiled = $svc->compile("");
$this->assertIsArray($compiled);
$this->assertEmpty($compiled);
}
}

View File

@ -1,24 +0,0 @@
<?php
namespace Tests\Unit\Rules;
use App\Services\RuleExpressionService;
use CodeIgniter\Test\CIUnitTestCase;
class RuleExpressionServiceTest extends CIUnitTestCase
{
public function testEvaluateBooleanWithArrayContext(): void
{
$svc = new RuleExpressionService();
$ok = $svc->evaluateBoolean('order["SiteID"] == 1', [
'order' => ['SiteID' => 1],
]);
$this->assertTrue($ok);
$no = $svc->evaluateBoolean('order["SiteID"] == 2', [
'order' => ['SiteID' => 1],
]);
$this->assertFalse($no);
}
}

View File

@ -1,346 +0,0 @@
<?php
namespace Tests\Unit\Rules;
use App\Services\RuleExpressionService;
use CodeIgniter\Test\CIUnitTestCase;
/**
* Tests for Rule DSL syntax - semicolon syntax, multi-actions, operators
*/
class RuleExpressionSyntaxTest extends CIUnitTestCase
{
protected RuleExpressionService $service;
public function setUp(): void
{
parent::setUp();
$this->service = new RuleExpressionService();
}
// ============================================
// SYNTAX TESTS
// ============================================
public function testSemicolonSyntaxBasic()
{
$result = $this->service->compile('if(sex("M"); result_set(0.5); result_set(0.6))');
$this->assertArrayHasKey('conditionExpr', $result);
$this->assertArrayHasKey('valueExpr', $result);
$this->assertArrayHasKey('then', $result);
$this->assertArrayHasKey('else', $result);
$this->assertEquals('patient["Sex"] == "M"', $result['conditionExpr']);
$this->assertEquals('0.5', $result['then'][0]['valueExpr']);
$this->assertEquals('0.6', $result['else'][0]['valueExpr']);
}
public function testTernarySyntaxFallback()
{
// Legacy ternary syntax should still work (fallback)
$result = $this->service->compile('if(sex("M") ? result_set(0.5) : result_set(0.6))');
$this->assertArrayHasKey('conditionExpr', $result);
$this->assertEquals('patient["Sex"] == "M"', $result['conditionExpr']);
}
// ============================================
// MULTI-ACTION TESTS
// ============================================
public function testMultiActionWithColon()
{
$result = $this->service->compile('if(sex("M"); result_set(0.5):test_insert("HBA1C"); nothing)');
$this->assertCount(2, $result['then']);
$this->assertEquals('RESULT_SET', $result['then'][0]['type']);
$this->assertEquals(0.5, $result['then'][0]['value']);
$this->assertEquals('TEST_INSERT', $result['then'][1]['type']);
$this->assertEquals('HBA1C', $result['then'][1]['testCode']);
$this->assertEquals('NO_OP', $result['else'][0]['type']);
}
public function testThreeActions()
{
$result = $this->service->compile('if(priority("S"); result_set("URGENT"):test_insert("STAT_TEST"):comment_insert("Stat order"); nothing)');
$this->assertCount(3, $result['then']);
$this->assertEquals('RESULT_SET', $result['then'][0]['type']);
$this->assertEquals('URGENT', $result['then'][0]['value']);
$this->assertEquals('TEST_INSERT', $result['then'][1]['type']);
$this->assertEquals('STAT_TEST', $result['then'][1]['testCode']);
$this->assertEquals('COMMENT_INSERT', $result['then'][2]['type']);
$this->assertEquals('Stat order', $result['then'][2]['comment']);
}
public function testMultiActionInElse()
{
$result = $this->service->compile('if(sex("M"); result_set(1.0); result_set(0.8):comment_insert("Default"))');
$this->assertCount(1, $result['then']);
$this->assertCount(2, $result['else']);
$this->assertEquals('RESULT_SET', $result['else'][0]['type']);
$this->assertEquals(0.8, $result['else'][0]['value']);
$this->assertEquals('COMMENT_INSERT', $result['else'][1]['type']);
}
// ============================================
// LOGICAL OPERATOR TESTS
// ============================================
public function testAndOperator()
{
$result = $this->service->compile('if(sex("M") && age > 40; result_set(1.2); result_set(1.0))');
$this->assertStringContainsString('and', strtolower($result['conditionExpr']));
$this->assertStringContainsString('patient["Sex"] == "M"', $result['conditionExpr']);
$this->assertStringContainsString('age > 40', $result['conditionExpr']);
}
public function testOrOperator()
{
$result = $this->service->compile('if(sex("M") || age > 65; result_set(1.0); result_set(0.8))');
$this->assertStringContainsString('or', strtolower($result['conditionExpr']));
$this->assertStringContainsString('patient["Sex"] == "M"', $result['conditionExpr']);
$this->assertStringContainsString('age > 65', $result['conditionExpr']);
}
public function testCombinedAndOr()
{
$result = $this->service->compile('if((sex("M") && age > 40) || (sex("F") && age > 50); result_set(1.5); result_set(1.0))');
$this->assertStringContainsString('or', strtolower($result['conditionExpr']));
$this->assertStringContainsString('and', strtolower($result['conditionExpr']));
}
public function testComplexNestedCondition()
{
$result = $this->service->compile('if(sex("M") && (age > 40 || priority("S")); result_set(1.2); nothing)');
$this->assertStringContainsString('patient["Sex"] == "M"', $result['conditionExpr']);
$this->assertStringContainsString('or', strtolower($result['conditionExpr']));
}
// ============================================
// ACTION TESTS
// ============================================
public function testNothingAction()
{
$result = $this->service->compile('if(sex("M"); result_set(0.5); nothing)');
$this->assertEquals('NO_OP', $result['else'][0]['type']);
}
public function testNothingActionInThen()
{
$result = $this->service->compile('if(sex("M"); nothing; result_set(0.6))');
$this->assertEquals('NO_OP', $result['then'][0]['type']);
}
public function testInsertAction()
{
$result = $this->service->compile('if(requested("GLU"); test_insert("HBA1C"); nothing)');
$this->assertEquals('TEST_INSERT', $result['then'][0]['type']);
$this->assertEquals('HBA1C', $result['then'][0]['testCode']);
}
public function testAddCommentAction()
{
$result = $this->service->compile('if(sex("M"); comment_insert("Male patient"); nothing)');
$this->assertEquals('COMMENT_INSERT', $result['then'][0]['type']);
$this->assertEquals('Male patient', $result['then'][0]['comment']);
}
// ============================================
// CONDITION TESTS
// ============================================
public function testSexCondition()
{
$result = $this->service->compile('if(sex("F"); result_set(0.7); result_set(1.0))');
$this->assertEquals('patient["Sex"] == "F"', $result['conditionExpr']);
}
public function testPriorityCondition()
{
$result = $this->service->compile('if(priority("S"); result_set("URGENT"); result_set("NORMAL"))');
$this->assertEquals('order["Priority"] == "S"', $result['conditionExpr']);
}
public function testPriorityUrgent()
{
$result = $this->service->compile('if(priority("U"); result_set("CRITICAL"); nothing)');
$this->assertEquals('order["Priority"] == "U"', $result['conditionExpr']);
}
public function testAgeCondition()
{
$result = $this->service->compile('if(age > 18; result_set(1.0); result_set(0.5))');
$this->assertEquals('age > 18', $result['conditionExpr']);
}
public function testAgeGreaterThanEqual()
{
$result = $this->service->compile('if(age >= 18; result_set(1.0); nothing)');
$this->assertEquals('age >= 18', $result['conditionExpr']);
}
public function testAgeLessThan()
{
$result = $this->service->compile('if(age < 65; result_set(1.0); nothing)');
$this->assertEquals('age < 65', $result['conditionExpr']);
}
public function testAgeRange()
{
$result = $this->service->compile('if(age >= 18 && age <= 65; result_set(1.0); nothing)');
$this->assertStringContainsString('age >= 18', $result['conditionExpr']);
$this->assertStringContainsString('age <= 65', $result['conditionExpr']);
}
public function testRequestedCondition()
{
$result = $this->service->compile('if(requested("GLU"); test_insert("HBA1C"); nothing)');
$this->assertStringContainsString('requested', $result['conditionExpr']);
$this->assertStringContainsString('GLU', $result['conditionExpr']);
}
// ============================================
// MULTI-RULE TESTS
// ============================================
public function testParseMultiRule()
{
$expr = 'if(sex("M"); result_set(0.5); nothing), if(age > 65; result_set(1.0); nothing)';
$rules = $this->service->parseMultiRule($expr);
$this->assertCount(2, $rules);
$this->assertStringContainsString('sex("M")', $rules[0]);
$this->assertStringContainsString('age > 65', $rules[1]);
}
public function testParseMultiRuleThreeRules()
{
$expr = 'if(sex("M"); result_set(0.5); nothing), if(age > 65; result_set(1.0); nothing), if(priority("S"); result_set(2.0); nothing)';
$rules = $this->service->parseMultiRule($expr);
$this->assertCount(3, $rules);
}
// ============================================
// ERROR HANDLING TESTS
// ============================================
public function testInvalidSyntaxThrowsException()
{
$this->expectException(\InvalidArgumentException::class);
$this->service->compile('invalid syntax here');
}
public function testUnknownActionThrowsException()
{
$this->expectException(\InvalidArgumentException::class);
$this->service->compile('if(sex("M"); unknown_action(); nothing)');
}
public function testEmptyExpressionReturnsEmptyArray()
{
$result = $this->service->compile('');
$this->assertEmpty($result);
}
// ============================================
// DOCUMENTATION EXAMPLES
// ============================================
public function testExample1SexBasedResult()
{
// Example 1 from docs
$result = $this->service->compile('if(sex("M"); result_set(0.5); result_set(0.6))');
$this->assertEquals('RESULT_SET', $result['then'][0]['type']);
$this->assertEquals(0.5, $result['then'][0]['value']);
$this->assertEquals('RESULT_SET', $result['else'][0]['type']);
$this->assertEquals(0.6, $result['else'][0]['value']);
}
public function testExample2ConditionalTestInsertion()
{
// Example 2 from docs
$result = $this->service->compile("if(requested('GLU'); test_insert('HBA1C'):test_insert('INS'); nothing)");
$this->assertCount(2, $result['then']);
$this->assertEquals('TEST_INSERT', $result['then'][0]['type']);
$this->assertEquals('HBA1C', $result['then'][0]['testCode']);
$this->assertEquals('INS', $result['then'][1]['testCode']);
}
public function testExample3MultipleConditionsAnd()
{
// Example 3 from docs
$result = $this->service->compile("if(sex('M') && age > 40; result_set(1.2); result_set(1.0))");
$this->assertStringContainsString('and', strtolower($result['conditionExpr']));
}
public function testExample4OrCondition()
{
// Example 4 from docs
$result = $this->service->compile("if(sex('M') || age > 65; result_set(1.0); result_set(0.8))");
$this->assertStringContainsString('or', strtolower($result['conditionExpr']));
}
public function testExample5CombinedAndOr()
{
// Example 5 from docs
$result = $this->service->compile("if((sex('M') && age > 40) || (sex('F') && age > 50); result_set(1.5); result_set(1.0))");
$this->assertStringContainsString('or', strtolower($result['conditionExpr']));
}
public function testExample6MultipleActions()
{
// Example 6 from docs
$result = $this->service->compile("if(priority('S'); result_set('URGENT'):test_insert('STAT_TEST'); result_set('NORMAL'))");
$this->assertCount(2, $result['then']);
$this->assertEquals('RESULT_SET', $result['then'][0]['type']);
$this->assertEquals('TEST_INSERT', $result['then'][1]['type']);
}
public function testExample7ThreeActions()
{
// Example 7 from docs
$result = $this->service->compile("if(sex('M') && age > 40; result_set(1.5):test_insert('EXTRA_TEST'):comment_insert('Male over 40'); nothing)");
$this->assertCount(3, $result['then']);
$this->assertEquals('RESULT_SET', $result['then'][0]['type']);
$this->assertEquals('TEST_INSERT', $result['then'][1]['type']);
$this->assertEquals('COMMENT_INSERT', $result['then'][2]['type']);
}
public function testExample8ComplexRule()
{
// Example 8 from docs
$result = $this->service->compile("if(sex('F') && (age >= 18 && age <= 50) && priority('S'); result_set('HIGH_PRIO'):comment_insert('Female stat 18-50'); result_set('NORMAL'))");
$this->assertCount(2, $result['then']);
$this->assertStringContainsString('and', strtolower($result['conditionExpr']));
}
}

View File

@ -1,191 +0,0 @@
<?php
namespace Tests\Unit\TestDef;
use CodeIgniter\Test\CIUnitTestCase;
use App\Models\Test\TestDefSiteModel;
use App\Models\Test\TestDefCalModel;
use App\Models\Test\TestDefGrpModel;
use App\Models\Test\TestMapModel;
class TestDefModelsTest extends CIUnitTestCase
{
protected $testDefSiteModel;
protected $testDefCalModel;
protected $testDefGrpModel;
protected $testMapModel;
protected function setUp(): void
{
parent::setUp();
$this->testDefSiteModel = new TestDefSiteModel();
$this->testDefCalModel = new TestDefCalModel();
$this->testDefGrpModel = new TestDefGrpModel();
$this->testMapModel = new TestMapModel();
}
/**
* Test TestDefSiteModel has correct table name
*/
public function testTestDefSiteModelTable()
{
$this->assertEquals('testdefsite', $this->testDefSiteModel->table);
}
/**
* Test TestDefSiteModel has correct primary key
*/
public function testTestDefSiteModelPrimaryKey()
{
$this->assertEquals('TestSiteID', $this->testDefSiteModel->primaryKey);
}
/**
* Test TestDefSiteModel has correct allowed fields
*/
public function testTestDefSiteModelAllowedFields()
{
$allowedFields = $this->testDefSiteModel->allowedFields;
$this->assertContains('SiteID', $allowedFields);
$this->assertContains('TestSiteCode', $allowedFields);
$this->assertContains('TestSiteName', $allowedFields);
$this->assertContains('TestType', $allowedFields);
$this->assertContains('Description', $allowedFields);
$this->assertContains('SeqScr', $allowedFields);
$this->assertContains('SeqRpt', $allowedFields);
$this->assertContains('IndentLeft', $allowedFields);
$this->assertContains('FontStyle', $allowedFields);
$this->assertContains('VisibleScr', $allowedFields);
$this->assertContains('VisibleRpt', $allowedFields);
$this->assertContains('CountStat', $allowedFields);
$this->assertContains('CreateDate', $allowedFields);
$this->assertContains('StartDate', $allowedFields);
$this->assertContains('EndDate', $allowedFields);
$this->assertContains('ResultType', $allowedFields);
$this->assertContains('RefType', $allowedFields);
}
/**
* Test TestDefSiteModel uses soft deletes
*/
public function testTestDefSiteModelSoftDeletes()
{
$this->assertTrue($this->testDefSiteModel->useSoftDeletes);
$this->assertEquals('EndDate', $this->testDefSiteModel->deletedField);
}
/**
* Test TestDefCalModel has correct table name
*/
public function testTestDefCalModelTable()
{
$this->assertEquals('testdefcal', $this->testDefCalModel->table);
}
/**
* Test TestDefCalModel has correct primary key
*/
public function testTestDefCalModelPrimaryKey()
{
$this->assertEquals('TestCalID', $this->testDefCalModel->primaryKey);
}
/**
* Test TestDefCalModel has correct allowed fields
*/
public function testTestDefCalModelAllowedFields()
{
$allowedFields = $this->testDefCalModel->allowedFields;
$this->assertContains('TestSiteID', $allowedFields);
$this->assertContains('DisciplineID', $allowedFields);
$this->assertContains('DepartmentID', $allowedFields);
$this->assertContains('FormulaCode', $allowedFields);
$this->assertContains('RefType', $allowedFields);
$this->assertContains('Unit1', $allowedFields);
$this->assertContains('Factor', $allowedFields);
$this->assertContains('Unit2', $allowedFields);
$this->assertContains('Decimal', $allowedFields);
$this->assertContains('Method', $allowedFields);
}
/**
* Test TestDefGrpModel has correct table name
*/
public function testTestDefGrpModelTable()
{
$this->assertEquals('testdefgrp', $this->testDefGrpModel->table);
}
/**
* Test TestDefGrpModel has correct primary key
*/
public function testTestDefGrpModelPrimaryKey()
{
$this->assertEquals('TestGrpID', $this->testDefGrpModel->primaryKey);
}
/**
* Test TestDefGrpModel has correct allowed fields
*/
public function testTestDefGrpModelAllowedFields()
{
$allowedFields = $this->testDefGrpModel->allowedFields;
$this->assertContains('TestSiteID', $allowedFields);
$this->assertContains('Member', $allowedFields);
}
/**
* Test TestMapModel has correct table name
*/
public function testTestMapModelTable()
{
$this->assertEquals('testmap', $this->testMapModel->table);
}
/**
* Test TestMapModel has correct primary key
*/
public function testTestMapModelPrimaryKey()
{
$this->assertEquals('TestMapID', $this->testMapModel->primaryKey);
}
/**
* Test TestMapModel has correct allowed fields
*/
public function testTestMapModelAllowedFields()
{
$allowedFields = $this->testMapModel->allowedFields;
$this->assertContains('HostType', $allowedFields);
$this->assertContains('HostID', $allowedFields);
$this->assertContains('ClientType', $allowedFields);
$this->assertContains('ClientID', $allowedFields);
}
/**
* Test all models use soft deletes
*/
public function testAllModelsUseSoftDeletes()
{
$this->assertTrue($this->testDefCalModel->useSoftDeletes);
$this->assertTrue($this->testDefGrpModel->useSoftDeletes);
$this->assertTrue($this->testMapModel->useSoftDeletes);
}
/**
* Test all models have EndDate as deleted field
*/
public function testAllModelsUseEndDateAsDeletedField()
{
$this->assertEquals('EndDate', $this->testDefCalModel->deletedField);
$this->assertEquals('EndDate', $this->testDefGrpModel->deletedField);
$this->assertEquals('EndDate', $this->testMapModel->deletedField);
}
}

View File

@ -28,9 +28,9 @@ class ValueSetTest extends CIUnitTestCase
$values = array_column($result, 'value'); $values = array_column($result, 'value');
$labels = array_column($result, 'label'); $labels = array_column($result, 'label');
$this->assertContains('1', $values); $this->assertContains('F', $values);
$this->assertContains('2', $values); $this->assertContains('M', $values);
$this->assertContains('3', $values); $this->assertContains('U', $values);
$this->assertContains('Female', $labels); $this->assertContains('Female', $labels);
$this->assertContains('Male', $labels); $this->assertContains('Male', $labels);
$this->assertContains('Unknown', $labels); $this->assertContains('Unknown', $labels);
@ -51,17 +51,17 @@ class ValueSetTest extends CIUnitTestCase
$keys = array_column($result, 'key'); $keys = array_column($result, 'key');
$values = array_column($result, 'value'); $values = array_column($result, 'value');
$this->assertContains('1', $keys); $this->assertContains('F', $keys);
$this->assertContains('2', $keys); $this->assertContains('M', $keys);
$this->assertContains('Female', $values); $this->assertContains('Female', $values);
$this->assertContains('Male', $values); $this->assertContains('Male', $values);
} }
public function testGetLabelConvertsCodeToLabel() public function testGetLabelConvertsCodeToLabel()
{ {
$this->assertEquals('Female', ValueSet::getLabel('sex', '1')); $this->assertEquals('Female', ValueSet::getLabel('sex', 'F'));
$this->assertEquals('Male', ValueSet::getLabel('sex', '2')); $this->assertEquals('Male', ValueSet::getLabel('sex', 'M'));
$this->assertEquals('Unknown', ValueSet::getLabel('sex', '3')); $this->assertEquals('Unknown', ValueSet::getLabel('sex', 'U'));
} }
public function testGetLabelForOrderPriority() public function testGetLabelForOrderPriority()
@ -118,9 +118,9 @@ class ValueSetTest extends CIUnitTestCase
public function testGetPatientSex() public function testGetPatientSex()
{ {
$result = ValueSet::get('sex'); $result = ValueSet::get('sex');
$this->assertEquals('1', $result[0]['value']); $this->assertEquals('F', $result[0]['value']);
$this->assertEquals('Female', $result[0]['label']); $this->assertEquals('Female', $result[0]['label']);
$this->assertEquals('2', $result[1]['value']); $this->assertEquals('M', $result[1]['value']);
$this->assertEquals('Male', $result[1]['label']); $this->assertEquals('Male', $result[1]['label']);
} }
@ -270,9 +270,9 @@ class ValueSetTest extends CIUnitTestCase
public function testGetReturnsFormattedValues() public function testGetReturnsFormattedValues()
{ {
$result = ValueSet::get('sex'); $result = ValueSet::get('sex');
$this->assertEquals('1', $result[0]['value']); $this->assertEquals('F', $result[0]['value']);
$this->assertEquals('Female', $result[0]['label']); $this->assertEquals('Female', $result[0]['label']);
$this->assertEquals('2', $result[1]['value']); $this->assertEquals('M', $result[1]['value']);
$this->assertEquals('Male', $result[1]['label']); $this->assertEquals('Male', $result[1]['label']);
} }
@ -355,8 +355,8 @@ class ValueSetTest extends CIUnitTestCase
public function testTransformLabels() public function testTransformLabels()
{ {
$data = [ $data = [
['Gender' => '1', 'Country' => 'IDN'], ['Gender' => 'F', 'Country' => 'IDN'],
['Gender' => '2', 'Country' => 'USA'] ['Gender' => 'M', 'Country' => 'USA']
]; ];
$result = ValueSet::transformLabels($data, [ $result = ValueSet::transformLabels($data, [
@ -364,9 +364,9 @@ class ValueSetTest extends CIUnitTestCase
'Country' => 'country' 'Country' => 'country'
]); ]);
$this->assertEquals('1', $result[0]['Gender']); $this->assertEquals('F', $result[0]['Gender']);
$this->assertEquals('Female', $result[0]['GenderLabel']); $this->assertEquals('Female', $result[0]['GenderLabel']);
$this->assertEquals('2', $result[1]['Gender']); $this->assertEquals('M', $result[1]['Gender']);
$this->assertEquals('Male', $result[1]['GenderLabel']); $this->assertEquals('Male', $result[1]['GenderLabel']);
$this->assertEquals('USA', $result[1]['Country']); $this->assertEquals('USA', $result[1]['Country']);
$this->assertEquals('United States of America', $result[1]['CountryLabel']); $this->assertEquals('United States of America', $result[1]['CountryLabel']);