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

468
AGENTS.md
View File

@ -1,316 +1,152 @@
# 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.
---
## Build, Test & Lint Commands
```bash
# Run all tests
./vendor/bin/phpunit
# Run a specific test file
./vendor/bin/phpunit tests/feature/Patients/PatientCreateTest.php
# Run a specific test method
./vendor/bin/phpunit --filter testCreatePatientSuccess tests/feature/Patients/PatientCreateTest.php
# Run tests with coverage
./vendor/bin/phpunit --coverage-html build/logs/html
# Run tests by suite
./vendor/bin/phpunit --testsuite App
# Generate scaffolding
php spark make:migration <name>
php spark make:model <name>
php spark make:controller <name>
# Database migrations
php spark migrate
php spark migrate:rollback
```
---
## 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
```
This produces `public/api-docs.bundled.yaml` which is used by Swagger UI/Redoc.
### 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
Database uses PascalCase columns: `PatientID`, `NameFirst`, `Birthdate`, `CreatedAt`, `UpdatedAt`
### ValueSet System
```php
use App\Libraries\Lookups;
$genders = Lookups::get('gender');
$label = Lookups::getLabel('gender', '1'); // Returns 'Female'
$options = Lookups::getOptions('gender');
$labeled = Lookups::transformLabels($data, ['Sex' => 'gender']);
```
### Nested Data Handling
For entities with nested data (PatIdt, PatCom, PatAtt):
- Extract nested arrays before filtering
- Use transactions for multi-table operations
- Handle empty/null arrays appropriately
---
## Environment Configuration
### Database (`.env`)
```ini
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.*
# AGENTS.md - Code Guidelines for CLQMS
> **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.
---
## 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
# Run the entire PHPUnit suite
./vendor/bin/phpunit
# Target a single test file (fast verification)
./vendor/bin/phpunit tests/feature/Patients/PatientCreateTest.php
# Run one test case by method
./vendor/bin/phpunit --filter testCreatePatientSuccess tests/feature/Patients/PatientCreateTest.php
# Generate scaffolding (model, controller, migration)
php spark make:model <Name>
php spark make:controller <Name>
php spark make:migration <name>
# Database migrations
php spark migrate
php spark migrate:rollback
# After OpenAPI edits
node public/bundle-api-docs.js
```
Use `php spark test --filter <Class>::<method>` when filtering more than one test file is cumbersome.
---
## Agent Rules Scan
- 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.
---
## Coding Standards
### Language & Formatting
- 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
- For entities that carry related collections (`PatIdt`, `PatCom`, `PatAtt`), extract nested arrays before filtering and validating.
- Use transactions whenever multi-table inserts/updates occur so orphan rows are avoided.
- Guard against empty/null arrays by normalizing to `[]` before iterating.
### 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.
---
## Final Notes for Agents
- This repo has no UI layer; focus exclusively on REST interactions.
- Always pull `public/api-docs.bundled.yaml` in after running `node public/bundle-api-docs.js` so downstream services see the latest contract.
- When in doubt, align with existing controller traits and response helpers to avoid duplicating logic.

View File

@ -158,9 +158,9 @@ All API endpoints follow REST conventions:
| Method | Endpoint | Description | Auth Required |
|--------|----------|-------------|---------------|
| `POST` | `/api/edge/results` | Receive instrument results | API Key |
| `GET` | `/api/edge/orders` | Fetch pending orders | API Key |
| `POST` | `/api/edge/orders/{id}/ack` | Acknowledge order | API Key |
| `POST` | `/api/edge/result` | Receive instrument results | API Key |
| `GET` | `/api/edge/order` | Fetch pending orders | API Key |
| `POST` | `/api/edge/order/{id}/ack` | Acknowledge order | API Key |
| `POST` | `/api/edge/status` | Log instrument status | API Key |
### API Response Format
@ -524,15 +524,15 @@ The **Edge API** provides endpoints for integrating laboratory instruments via t
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/edge/results` | Receive instrument results (stored in `edgeres`) |
| `GET` | `/api/edge/orders` | Fetch pending orders for an instrument |
| `POST` | `/api/edge/orders/:id/ack` | Acknowledge order delivery to instrument |
| `POST` | `/api/edge/result` | Receive instrument results (stored in `edgeres`) |
| `GET` | `/api/edge/order` | Fetch pending orders for an instrument |
| `POST` | `/api/edge/order/:id/ack` | Acknowledge order delivery to instrument |
| `POST` | `/api/edge/status` | Log instrument status updates |
### 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:**

View File

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

View File

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

View File

@ -75,12 +75,20 @@ class ContactController extends BaseController {
}
}
public function update() {
$input = $this->request->getJSON(true);
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); }
try {
$this->model->saveContact($input);
$id = $input['ContactID'];
public function update($ContactID = null) {
$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()); }
try {
$this->model->saveContact($input);
$id = $input['ContactID'];
return $this->respondCreated([ 'status' => 'success', 'message' => 'data updated successfully', 'data' => $id ], 201);
} catch (\Throwable $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());

View File

@ -51,11 +51,15 @@ class MedicalSpecialtyController extends BaseController {
}
}
public function update() {
$input = $this->request->getJSON(true);
try {
$this->model->update($input['SpecialtyID'], $input);
return $this->respondCreated([ 'status' => 'success', 'message' => 'Data updated successfully', 'data' => $input['SpecialtyID'] ], 201);
public function update($SpecialtyID = null) {
$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 {
$this->model->update($input['SpecialtyID'], $input);
return $this->respondCreated([ 'status' => 'success', 'message' => 'Data updated successfully', 'data' => $input['SpecialtyID'] ], 201);
} catch (\Throwable $e) {
return $this->failServerError('Exception : ' . $e->getMessage());
}

View File

@ -51,11 +51,15 @@ class OccupationController extends BaseController {
}
}
public function update() {
$input = $this->request->getJSON(true);
try {
$this->model->update($input['OccupationID'], $input);
return $this->respondCreated([ 'status' => 'success', 'message' => 'Data updated successfully', 'data' => $input['OccupationID'] ], 201);
public function update($OccupationID = null) {
$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 {
$this->model->update($input['OccupationID'], $input);
return $this->respondCreated([ 'status' => 'success', 'message' => 'Data updated successfully', 'data' => $input['OccupationID'] ], 201);
} catch (\Throwable $e) {
return $this->failServerError('Exception : ' . $e->getMessage());
}

View File

@ -43,11 +43,15 @@ class CounterController extends BaseController {
}
}
public function update() {
$input = $this->request->getJSON(true);
try {
$this->model->update($input['CounterID'], $input);
return $this->respondCreated([ 'status' => 'success', 'message' => 'Data updated successfully', 'data' => $input['CounterID'] ], 201);
public function update($CounterID = null) {
$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 {
$this->model->update($input['CounterID'], $input);
return $this->respondCreated([ 'status' => 'success', 'message' => 'Data updated successfully', 'data' => $input['CounterID'] ], 201);
} catch (\Throwable $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}

View File

@ -19,7 +19,7 @@ class EdgeController extends Controller
}
/**
* POST /api/edge/results
* POST /api/edge/result
* Receive results from tiny-edge
*/
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
*/
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
*/
public function ack($orderId = null)
@ -131,7 +131,7 @@ class EdgeController extends Controller
}
/**
* POST /api/edge/status
* POST /api/edge/status
* Log instrument status
*/
public function status()

View File

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

View File

@ -50,11 +50,19 @@ class LocationController extends BaseController {
}
}
public function update() {
$input = $this->request->getJSON(true);
try {
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors( $this->validator->getErrors()); }
$result = $this->model->saveLocation($input, true);
public function update($LocationID = null) {
$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 {
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors( $this->validator->getErrors()); }
$result = $this->model->saveLocation($input, true);
return $this->respondCreated([ 'status' => 'success', 'message' => 'data updated successfully', 'data' => $result ], 201);
} catch (\Throwable $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());

View File

@ -191,18 +191,23 @@ class OrderTestController extends Controller {
}
}
public function update() {
$input = $this->request->getJSON(true);
if (empty($input['OrderID'])) {
return $this->failValidationErrors(['OrderID' => 'OrderID is required']);
}
try {
$order = $this->model->getOrder($input['OrderID']);
if (!$order) {
return $this->failNotFound('Order not found');
}
public function update($OrderID = null) {
$input = $this->request->getJSON(true);
if ($OrderID === null || $OrderID === '') {
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 {
$input['OrderID'] = $OrderID;
$order = $this->model->getOrder($OrderID);
if (!$order) {
return $this->failNotFound('Order not found');
}
$updateData = [];
if (isset($input['Priority'])) $updateData['Priority'] = $input['Priority'];
@ -215,9 +220,9 @@ class OrderTestController extends Controller {
$this->model->update($order['InternalOID'], $updateData);
}
$updatedOrder = $this->model->getOrder($input['OrderID']);
$updatedOrder['Specimens'] = $this->getOrderSpecimens($updatedOrder['InternalOID']);
$updatedOrder['Tests'] = $this->getOrderTests($updatedOrder['InternalOID']);
$updatedOrder = $this->model->getOrder($OrderID);
$updatedOrder['Specimens'] = $this->getOrderSpecimens($updatedOrder['InternalOID']);
$updatedOrder['Tests'] = $this->getOrderTests($updatedOrder['InternalOID']);
return $this->respond([
'status' => 'success',

View File

@ -65,11 +65,15 @@ class AccountController extends BaseController {
}
}
public function update() {
$input = $this->request->getJSON(true);
try {
$id = $input['AccountID'];
if (!$id) { return $this->failValidationErrors('ID is required.'); }
public function update($AccountID = null) {
$input = $this->request->getJSON(true);
if (!$AccountID || !ctype_digit((string) $AccountID)) {
return $this->failValidationErrors('ID is required.');
}
$input['AccountID'] = (int) $AccountID;
try {
$id = $input['AccountID'];
if (!$id) { return $this->failValidationErrors('ID is required.'); }
$this->model->update($id, $input);
return $this->respondCreated([ 'status' => 'success', 'message' => 'data updated successfully', 'data' => $id ], 201);
} catch (\Throwable $e) {

View File

@ -78,16 +78,21 @@ class CodingSysController extends BaseController {
}
}
public function update() {
public function update($CodingSysID = null) {
$input = $this->request->getJSON(true);
try {
$id = $input['CodingSysID'] ?? null;
if (!$id) {
if (!$CodingSysID || !ctype_digit((string) $CodingSysID)) {
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);
return $this->respondCreated(['status' => 'success', 'message' => 'data updated successfully', 'data' => $id], 201);
} catch (\Throwable $e) {

View File

@ -62,12 +62,16 @@ class DepartmentController extends BaseController {
}
}
public function update() {
$input = $this->request->getJSON(true);
try {
$id = $input['DepartmentID'];
$this->model->update($id, $input);
return $this->respondCreated([ 'status' => 'success', 'message' => 'data updated successfully', 'data' => $id ], 201);
public function update($DepartmentID = null) {
$input = $this->request->getJSON(true);
try {
if (!$DepartmentID || !ctype_digit((string) $DepartmentID)) {
return $this->failValidationErrors('ID is required.');
}
$input['DepartmentID'] = (int) $DepartmentID;
$id = $input['DepartmentID'];
$this->model->update($id, $input);
return $this->respondCreated([ 'status' => 'success', 'message' => 'data updated successfully', 'data' => $id ], 201);
} catch (\Throwable $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}

View File

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

View File

@ -82,16 +82,20 @@ class HostAppController extends BaseController {
}
}
public function update() {
public function update($HostAppID = null) {
$input = $this->request->getJSON(true);
try {
$id = $input['HostAppID'] ?? null;
if (!$id) {
if ($HostAppID === null || $HostAppID === '') {
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);
return $this->respondCreated(['status' => 'success', 'message' => 'data updated successfully', 'data' => $id], 201);
} 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);
try {
$id = $input['HostAppID'] ?? null;
if (!$id) {
if ($HostAppID === null || $HostAppID === '') {
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);
return $this->respondCreated(['status' => 'success', 'message' => 'data updated successfully', 'data' => $id], 201);
} catch (\Throwable $e) {

View File

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

View File

@ -63,12 +63,16 @@ class WorkstationController extends BaseController {
}
}
public function update() {
$input = $this->request->getJSON(true);
try {
$id = $input['WorkstationID'];
$this->model->update($id, $input);
return $this->respondCreated([ 'status' => 'success', 'message' => 'data updated successfully', 'data' => $id ], 201);
public function update($WorkstationID = null) {
$input = $this->request->getJSON(true);
try {
if (!$WorkstationID || !ctype_digit((string) $WorkstationID)) {
return $this->failValidationErrors('ID is required.');
}
$input['WorkstationID'] = (int) $WorkstationID;
$id = $input['WorkstationID'];
$this->model->update($id, $input);
return $this->respondCreated([ 'status' => 'success', 'message' => 'data updated successfully', 'data' => $id ], 201);
} catch (\Throwable $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}

View File

@ -94,16 +94,16 @@ class PatVisitController extends BaseController {
}
}
public function update() {
$input = $this->request->getJSON(true);
try {
if (!isset($input["InternalPVID"]) || !is_numeric($input["InternalPVID"])) {
return $this->respond(['status' => 'error', 'message' => 'Invalid or missing ID'], 400);
}
// Check if visit exists
$visit = $this->model->find($input["InternalPVID"]);
if (!$visit) {
public function update($InternalPVID = null) {
$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 {
// Check if visit exists
$visit = $this->model->find($input["InternalPVID"]);
if (!$visit) {
return $this->respond(['status' => 'error', 'message' => 'Visit not found'], 404);
}
@ -174,12 +174,13 @@ class PatVisitController extends BaseController {
}
}
public function updateADT() {
$input = $this->request->getJSON(true);
if (!$input["PVADTID"] || !is_numeric($input["PVADTID"])) { return $this->respond(['status' => 'error', 'message' => 'Invalid or missing ID'], 400); }
$modelPVA = new PatVisitADTModel();
try {
$data = $modelPVA->update($input['PVADTID'], $input);
public function updateADT($PVADTID = null) {
$input = $this->request->getJSON(true);
if (!$PVADTID || !is_numeric($PVADTID)) { return $this->respond(['status' => 'error', 'message' => 'Invalid or missing ID'], 400); }
$input['PVADTID'] = $PVADTID;
$modelPVA = new PatVisitADTModel();
try {
$data = $modelPVA->update($input['PVADTID'], $input);
return $this->respond(['status' => 'success', 'message' => 'Data updated successfully', 'data' => $data], 200);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());

View File

@ -115,8 +115,17 @@ class PatientController extends Controller {
}
}
public function update() {
$input = $this->request->getJSON(true);
public function update($InternalPID = null) {
$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
$type = $input['PatIdt']['IdentifierType'] ?? null;
@ -139,8 +148,8 @@ class PatientController extends Controller {
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); }
try {
$InternalPID = $this->model->updatePatient($input);
return $this->respondCreated([ 'status' => 'success', 'message' => "data $InternalPID update successfully" ]);
$InternalPID = $this->model->updatePatient($input);
return $this->respondCreated([ 'status' => 'success', 'message' => "data $InternalPID update successfully" ]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}

View File

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

View File

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

View File

@ -73,11 +73,15 @@ class ContainerDefController extends BaseController {
}
}
public function update() {
$input = $this->request->getJSON(true);
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); }
try {
$ConDefID = $this->model->update($input['ConDefID'], $input);
public function update($ConDefID = null) {
$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()); }
try {
$ConDefID = $this->model->update($input['ConDefID'], $input);
return $this->respondCreated([ 'status' => 'success', 'message' => "data $ConDefID updated successfully" ]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());

View File

@ -66,11 +66,15 @@ class SpecimenCollectionController extends BaseController {
}
}
public function update() {
$input = $this->request->getJSON(true);
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); }
try {
$id = $this->model->update($input['SpcColID'], $input);
public function update($SpcColID = null) {
$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()); }
try {
$id = $this->model->update($input['SpcColID'], $input);
return $this->respondCreated([ 'status' => 'success', 'message' => "data $id updated successfully" ]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());

View File

@ -66,11 +66,15 @@ class SpecimenController extends BaseController {
}
}
public function update() {
$input = $this->request->getJSON(true);
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); }
try {
$id = $this->model->update($input['SID'], $input);
public function update($SID = null) {
$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()); }
try {
$id = $this->model->update($input['SID'], $input);
return $this->respondCreated([ 'status' => 'success', 'message' => "data $id updated successfully" ]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());

View File

@ -51,11 +51,15 @@ class SpecimenPrepController extends BaseController {
}
}
public function update() {
$input = $this->request->getJSON(true);
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); }
try {
$id = $this->model->update($input['SpcPrpID'], $input);
public function update($SpcPrpID = null) {
$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()); }
try {
$id = $this->model->update($input['SpcPrpID'], $input);
return $this->respondCreated([ 'status' => 'success', 'message' => "data $id updated successfully" ]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());

View File

@ -64,11 +64,15 @@ class ContainerDef extends BaseController {
}
}
public function update() {
$input = $this->request->getJSON(true);
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); }
try {
$id = $this->model->update($input['SpcStaID'], $input);
public function update($SpcStaID = null) {
$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()); }
try {
$id = $this->model->update($input['SpcStaID'], $input);
return $this->respondCreated([ 'status' => 'success', 'message' => "data $id updated successfully" ]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());

View File

@ -64,13 +64,17 @@ class TestMapController extends BaseController {
}
}
public function update() {
$input = $this->request->getJSON(true);
$id = $input["TestMapID"];
if (!$id) { return $this->failValidationErrors('TestMapID is required.'); }
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors( $this->validator->getErrors() ); }
try {
$this->model->update($id,$input);
public function update($TestMapID = null) {
$input = $this->request->getJSON(true);
if (!$TestMapID || !ctype_digit((string) $TestMapID)) { 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() ); }
try {
$this->model->update($id,$input);
return $this->respondCreated([ 'status' => 'success', 'message' => "data updated successfully", 'data' => $id ]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());

View File

@ -89,13 +89,16 @@ class TestMapDetailController extends BaseController {
}
}
public function update() {
$input = $this->request->getJSON(true);
$id = $input["TestMapDetailID"] ?? null;
if (!$id) {
return $this->failValidationErrors('TestMapDetailID is required.');
}
public function update($TestMapDetailID = null) {
$input = $this->request->getJSON(true);
if (!$TestMapDetailID || !ctype_digit((string) $TestMapDetailID)) {
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)) {
return $this->failValidationErrors($this->validator->getErrors());

View File

@ -169,18 +169,27 @@ class TestsController extends BaseController
'StartDate' => $input['StartDate'] ?? date('Y-m-d H:i:s'),
];
$id = $this->model->insert($testSiteData);
if (!$id) {
throw new \Exception('Failed to insert main test definition');
}
$this->handleDetails($id, $input, 'insert');
$id = $this->model->insert($testSiteData);
if (!$id) {
$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');
$db->transComplete();
if ($db->transStatus() === false) {
return $this->failServerError('Transaction failed');
}
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->respondCreated([
'status' => 'created',

View File

@ -25,7 +25,7 @@ class UserController extends BaseController
/**
* 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()
{
@ -81,7 +81,7 @@ class UserController extends BaseController
/**
* Get single user by ID
* GET /api/users/(:num)
* GET /api/user/(:num)
*/
public function show($id)
{
@ -116,7 +116,7 @@ class UserController extends BaseController
/**
* Create new user
* POST /api/users
* POST /api/user
*/
public function create()
{
@ -173,14 +173,14 @@ class UserController extends BaseController
/**
* Update existing user
* PATCH /api/users
* PATCH /api/user/(:num)
*/
public function update()
public function update($id)
{
try {
$data = $this->request->getJSON(true);
if (empty($data['UserID'])) {
if (empty($id) || !ctype_digit((string) $id)) {
return $this->respond([
'status' => 'failed',
'message' => 'UserID is required',
@ -188,7 +188,15 @@ class UserController extends BaseController
], 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
$user = $this->model->where('UserID', $userId)
@ -243,7 +251,7 @@ class UserController extends BaseController
/**
* Delete user (soft delete)
* DELETE /api/users/(:num)
* DELETE /api/user/(:num)
*/
public function delete($id)
{
@ -290,4 +298,4 @@ class UserController extends BaseController
], 500);
}
}
}
}

View File

@ -168,13 +168,17 @@ class TestValidationService
* @param string $refType
* @return string|null Returns table name or null if no reference table needed
*/
public static function getReferenceTable(string $resultType, string $refType): ?string
{
$resultType = strtoupper($resultType);
$refType = strtoupper($refType);
return self::REFERENCE_TABLES[$resultType][$refType] ?? null;
}
public static function getReferenceTable(?string $resultType, ?string $refType): ?string
{
if ($resultType === null || $refType === null) {
return null;
}
$resultType = strtoupper($resultType);
$refType = strtoupper($refType);
return self::REFERENCE_TABLES[$resultType][$refType] ?? null;
}
/**
* Check if a test needs reference ranges
@ -182,11 +186,15 @@ class TestValidationService
* @param string $resultType
* @return bool
*/
public static function needsReferenceRanges(string $resultType): bool
{
$resultType = strtoupper($resultType);
return $resultType !== 'NORES';
}
public static function needsReferenceRanges(?string $resultType): bool
{
if ($resultType === null) {
return false;
}
$resultType = strtoupper($resultType);
return $resultType !== 'NORES';
}
/**
* Check if a test uses refnum table
@ -195,10 +203,10 @@ class TestValidationService
* @param string $refType
* @return bool
*/
public static function usesRefNum(string $resultType, string $refType): bool
{
return self::getReferenceTable($resultType, $refType) === 'refnum';
}
public static function usesRefNum(?string $resultType, ?string $refType): bool
{
return self::getReferenceTable($resultType, $refType) === 'refnum';
}
/**
* Check if a test uses reftxt table
@ -207,10 +215,10 @@ class TestValidationService
* @param string $refType
* @return bool
*/
public static function usesRefTxt(string $resultType, string $refType): bool
{
return self::getReferenceTable($resultType, $refType) === 'reftxt';
}
public static function usesRefTxt(?string $resultType, ?string $refType): bool
{
return self::getReferenceTable($resultType, $refType) === 'reftxt';
}
/**
* Check if TestType is CALC
@ -256,4 +264,4 @@ class TestValidationService
$testType = strtoupper($testType);
return in_array($testType, ['TEST', 'PARAM'], true);
}
}
}

View File

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

View File

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

View File

@ -74,7 +74,7 @@ class RuleDefModel extends BaseModel
->get()
->getResultArray();
return array_column($result, 'TestSiteID');
return array_map('intval', array_column($result, 'TestSiteID'));
}
/**
@ -89,22 +89,24 @@ class RuleDefModel extends BaseModel
$db = \Config\Database::connect();
// Check if already linked (and not soft deleted)
$existing = $db->table('testrule')
->where('RuleID', $ruleID)
->where('TestSiteID', $testSiteID)
->where('EndDate IS NULL')
->first();
$existing = $db->table('testrule')
->where('RuleID', $ruleID)
->where('TestSiteID', $testSiteID)
->where('EndDate IS NULL')
->get()
->getRowArray();
if ($existing) {
return true; // Already linked
}
// Check if soft deleted - restore it
$softDeleted = $db->table('testrule')
->where('RuleID', $ruleID)
->where('TestSiteID', $testSiteID)
->where('EndDate IS NOT NULL')
->first();
$softDeleted = $db->table('testrule')
->where('RuleID', $ruleID)
->where('TestSiteID', $testSiteID)
->where('EndDate IS NOT NULL')
->get()
->getRowArray();
if ($softDeleted) {
return $db->table('testrule')

View File

@ -11,7 +11,7 @@ class AuditService {
$this->db = \Config\Database::connect();
}
public static function logData(
public static function logData(
string $operation,
string $entityType,
string $entityId,
@ -28,8 +28,8 @@ class AuditService {
'entity_id' => $entityId,
'table_name' => $tableName,
'field_name' => $fieldName,
'previous_value' => $previousValue,
'new_value' => $newValue,
'previous_value' => self::normalizeAuditValue($previousValue),
'new_value' => self::normalizeAuditValue($newValue),
'mechanism' => 'MANUAL',
'application_id' => 'CLQMS-WEB',
'web_page' => self::getUri(),
@ -41,7 +41,7 @@ class AuditService {
'ip_address' => self::getIpAddress(),
'user_id' => self::getUserId(),
'reason' => $reason,
'context' => $context,
'context' => self::normalizeAuditValue($context),
'created_at' => date('Y-m-d H:i:s')
]);
}
@ -64,9 +64,9 @@ class AuditService {
'entity_id' => $entityId,
'service_class' => $serviceClass,
'resource_type' => $resourceType,
'resource_details' => $resourceDetails,
'previous_value' => $previousValue,
'new_value' => $newValue,
'resource_details' => self::normalizeAuditValue($resourceDetails),
'previous_value' => self::normalizeAuditValue($previousValue),
'new_value' => self::normalizeAuditValue($newValue),
'mechanism' => 'AUTOMATIC',
'application_id' => $serviceName ?? 'SYSTEM-SERVICE',
'service_name' => $serviceName,
@ -79,7 +79,7 @@ class AuditService {
'port' => $resourceDetails['port'] ?? null,
'user_id' => 'SYSTEM',
'reason' => null,
'context' => $context,
'context' => self::normalizeAuditValue($context),
'created_at' => date('Y-m-d H:i:s')
]);
}
@ -102,8 +102,8 @@ class AuditService {
'entity_id' => $entityId,
'security_class' => $securityClass,
'resource_path' => $resourcePath,
'previous_value' => $previousValue,
'new_value' => $newValue,
'previous_value' => self::normalizeAuditValue($previousValue),
'new_value' => self::normalizeAuditValue($newValue),
'mechanism' => 'MANUAL',
'application_id' => 'CLQMS-WEB',
'web_page' => self::getUri(),
@ -115,7 +115,7 @@ class AuditService {
'ip_address' => self::getIpAddress(),
'user_id' => self::getUserId() ?? 'UNKNOWN',
'reason' => $reason,
'context' => $context,
'context' => self::normalizeAuditValue($context),
'created_at' => date('Y-m-d H:i:s')
]);
}
@ -138,9 +138,9 @@ class AuditService {
'entity_id' => $entityId,
'error_code' => $errorCode,
'error_message' => $errorMessage,
'error_details' => $errorDetails,
'previous_value' => $previousValue,
'new_value' => $newValue,
'error_details' => self::normalizeAuditValue($errorDetails),
'previous_value' => self::normalizeAuditValue($previousValue),
'new_value' => self::normalizeAuditValue($newValue),
'mechanism' => 'AUTOMATIC',
'application_id' => 'CLQMS-WEB',
'web_page' => self::getUri(),
@ -152,15 +152,28 @@ class AuditService {
'ip_address' => self::getIpAddress(),
'user_id' => self::getUserId() ?? 'SYSTEM',
'reason' => $reason,
'context' => $context,
'context' => self::normalizeAuditValue($context),
'created_at' => date('Y-m-d H:i:s')
]);
}
private static function log(string $table, array $data): void {
$db = \Config\Database::connect();
$db->table($table)->insert($data);
}
private static function log(string $table, array $data): void {
$db = \Config\Database::connect();
if (!$db->tableExists($table)) {
return;
}
$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 {
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
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`.
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.
@ -34,7 +34,7 @@ Rule
└── 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
@ -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`.
```http
POST /api/rules/compile
POST /api/rule/compile
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.
```http
POST /api/rules/validate
POST /api/rule/validate
Content-Type: application/json
{
@ -179,7 +179,7 @@ Content-Type: application/json
### Create Rule (example)
```http
POST /api/rules
POST /api/rule
Content-Type: application/json
{
@ -211,7 +211,7 @@ Content-Type: application/json
## 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.
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.
@ -266,12 +266,12 @@ php spark migrate
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`).
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
1. POST the expression to `/api/rules/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.
1. POST the expression to `/api/rule/compile` to get a detailed compilation error.
2. If using `/api/rule/validate`, supply the expected `context` payload; the endpoint simply evaluates the expression without saving it.
### Runtime Errors

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="vendor/codeigniter4/framework/system/Test/bootstrap.php"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="tests/phpunit-bootstrap.php"
backupGlobals="false"
beStrictAboutOutputDuringTests="true"
colors="true"
@ -42,8 +42,8 @@
</exclude>
</source>
<php>
<!-- Enable / Disable --> <!-- WAJIB DISESUAIKAN -->
<!-- <env name="CI_ENVIRONMENT" value="testing"/> -->
<!-- Enable / Disable --> <!-- WAJIB DISESUAIKAN -->
<env name="CI_ENVIRONMENT" value="testing"/>
<!-- <server name="app.baseURL" value="http://example.com/"/> -->
<server name="app.baseURL" value="http://localhost/clqms01/"/> <!-- WAJIB DISESUAIKAN -->
@ -56,7 +56,7 @@
<const name="PUBLICPATH" value="./public/"/>
<!-- Database configuration -->
<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.password" value="adminsakti"/> <!-- WAJIB DISESUAIKAN -->
<env name="database.tests.DBDriver" value="MySQLi"/> <!-- WAJIB DISESUAIKAN -->

File diff suppressed because it is too large Load Diff

View File

@ -22,43 +22,43 @@ servers:
- url: https://clqms01-api.services-summit.my.id/
description: Production server
tags:
- name: Authentication
description: User authentication and session management
- name: Patients
description: Patient registration and management
- name: Patient Visits
description: Patient visit/encounter management
- name: Organization
description: Organization structure (accounts, sites, disciplines, departments, workstations)
- name: Specimen
description: Specimen and container management
- name: Tests
description: Test definitions and test catalog
- name: Calculations
description: Lightweight calculator endpoint for retrieving computed values by code or name
- name: Orders
description: Laboratory order management
- name: Results
description: Patient results reporting with auto-validation
- name: Reports
description: Lab report generation (HTML view)
- name: Edge API
description: Instrument integration endpoints
- name: Contacts
description: Contact management (doctors, practitioners, etc.)
- name: Locations
description: Location management (rooms, wards, buildings)
- name: ValueSets
description: Value set definitions and items
- name: Demo
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
tags:
- name: Authentication
description: User authentication and session management
- name: Patient
description: Patient registration and management
- name: Patient Visit
description: Patient visit/encounter management
- name: Organization
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
description: Specimen and container management
- name: Test
description: Test definitions and test catalog
- 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
- name: Order
description: Laboratory order management
- name: Result
description: Patient results reporting with auto-validation
- name: Report
description: Lab report generation (HTML view)
- name: Edge API
description: Instrument integration endpoints
- name: Contact
description: Contact management (doctors, practitioners, etc.)
- name: ValueSet
description: Value set definitions and items
- name: User
description: User management and administration
- name: Demo
description: Demo/test endpoints (no authentication)
components:
securitySchemes:

View File

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

View File

@ -1,6 +1,6 @@
/api/contact:
get:
tags: [Contacts]
get:
tags: [Contact]
summary: List contacts
security:
- bearerAuth: []
@ -32,8 +32,8 @@
items:
$ref: '../components/schemas/master-data.yaml#/Contact'
post:
tags: [Contacts]
post:
tags: [Contact]
summary: Create new contact
security:
- bearerAuth: []
@ -99,79 +99,9 @@
schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse'
patch:
tags: [Contacts]
summary: Update contact
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- ContactID
- NameFirst
properties:
ContactID:
type: integer
description: Contact ID to update
NameFirst:
type: string
description: First name
NameLast:
type: string
description: Last name
Title:
type: string
description: Title (e.g., Dr, Mr, Mrs)
Initial:
type: string
description: Middle initial
Birthdate:
type: string
format: date-time
description: Date of birth
EmailAddress1:
type: string
format: email
description: Primary email address
EmailAddress2:
type: string
format: email
description: Secondary email address
Phone:
type: string
description: Primary phone number
MobilePhone1:
type: string
description: Primary mobile number
MobilePhone2:
type: string
description: Secondary mobile number
Specialty:
type: string
description: Medical specialty code
SubSpecialty:
type: string
description: Sub-specialty code
responses:
'201':
description: Contact updated successfully
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/SuccessResponse'
'422':
description: Validation error
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse'
delete:
tags: [Contacts]
delete:
tags: [Contact]
summary: Delete contact
security:
- bearerAuth: []
@ -195,9 +125,9 @@
schema:
$ref: '../components/schemas/common.yaml#/SuccessResponse'
/api/contact/{id}:
get:
tags: [Contacts]
/api/contact/{id}:
get:
tags: [Contact]
summary: Get contact by ID
security:
- bearerAuth: []
@ -208,17 +138,91 @@
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'
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:
type: string
description: First name
NameLast:
type: string
description: Last name
Title:
type: string
description: Title (e.g., Dr, Mr, Mrs)
Initial:
type: string
description: Middle initial
Birthdate:
type: string
format: date-time
description: Date of birth
EmailAddress1:
type: string
format: email
description: Primary email address
EmailAddress2:
type: string
format: email
description: Secondary email address
Phone:
type: string
description: Primary phone number
MobilePhone1:
type: string
description: Primary mobile number
MobilePhone2:
type: string
description: Secondary mobile number
Specialty:
type: string
description: Medical specialty code
SubSpecialty:
type: string
description: Sub-specialty code
responses:
'201':
description: Contact updated successfully
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/SuccessResponse'
'422':
description: Validation error
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse'

View File

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

View File

@ -1,6 +1,6 @@
/api/equipmentlist:
get:
tags: [EquipmentList]
get:
tags: [Equipment]
summary: List equipment
description: Get list of equipment with optional filters
security:
@ -49,8 +49,8 @@
items:
$ref: '../components/schemas/equipmentlist.yaml#/EquipmentList'
post:
tags: [EquipmentList]
post:
tags: [Equipment]
summary: Create equipment
description: Create a new equipment entry
security:
@ -101,59 +101,9 @@
data:
type: integer
patch:
tags: [EquipmentList]
summary: Update equipment
description: Update an existing equipment entry
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- EID
properties:
EID:
type: integer
IEID:
type: string
maxLength: 50
DepartmentID:
type: integer
InstrumentID:
type: string
maxLength: 150
InstrumentName:
type: string
maxLength: 150
WorkstationID:
type: integer
Enable:
type: integer
enum: [0, 1]
EquipmentRole:
type: string
maxLength: 1
responses:
'200':
description: Equipment updated
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: integer
delete:
tags: [EquipmentList]
delete:
tags: [Equipment]
summary: Delete equipment
description: Soft delete an equipment entry
security:
@ -182,9 +132,9 @@
message:
type: string
/api/equipmentlist/{id}:
get:
tags: [EquipmentList]
/api/equipmentlist/{id}:
get:
tags: [Equipment]
summary: Get equipment by ID
description: Get a single equipment entry by its EID
security:
@ -196,17 +146,71 @@
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'
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:
type: string
maxLength: 50
DepartmentID:
type: integer
InstrumentID:
type: string
maxLength: 150
InstrumentName:
type: string
maxLength: 150
WorkstationID:
type: integer
Enable:
type: integer
enum: [0, 1]
EquipmentRole:
type: string
maxLength: 1
responses:
'200':
description: Equipment updated
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: integer

View File

@ -1,6 +1,6 @@
/api/location:
get:
tags: [Locations]
get:
tags: [Location]
summary: List locations
security:
- bearerAuth: []
@ -32,8 +32,8 @@
items:
$ref: '../components/schemas/master-data.yaml#/Location'
post:
tags: [Locations]
post:
tags: [Location]
summary: Create location
security:
- bearerAuth: []
@ -83,61 +83,9 @@
schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse'
patch:
tags: [Locations]
summary: Update location
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- LocationID
properties:
LocationID:
type: integer
description: Location ID to update
SiteID:
type: integer
description: Reference to site
LocCode:
type: string
maxLength: 6
description: Location code (short identifier)
Parent:
type: integer
nullable: true
description: Parent location ID for hierarchical locations
LocFull:
type: string
maxLength: 255
description: Full location name
Description:
type: string
maxLength: 255
description: Location description
LocType:
type: string
description: Location type code (e.g., ROOM, WARD, BUILDING)
responses:
'201':
description: Location updated successfully
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/SuccessResponse'
'422':
description: Validation error
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse'
delete:
tags: [Locations]
delete:
tags: [Location]
summary: Delete location
security:
- bearerAuth: []
@ -161,9 +109,9 @@
schema:
$ref: '../components/schemas/common.yaml#/SuccessResponse'
/api/location/{id}:
get:
tags: [Locations]
/api/location/{id}:
get:
tags: [Location]
summary: Get location by ID
security:
- bearerAuth: []
@ -174,17 +122,72 @@
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'
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:
type: integer
description: Reference to site
LocCode:
type: string
maxLength: 6
description: Location code (short identifier)
Parent:
type: integer
nullable: true
description: Parent location ID for hierarchical locations
LocFull:
type: string
maxLength: 255
description: Full location name
Description:
type: string
maxLength: 255
description: Location description
LocType:
type: string
description: Location type code (e.g., ROOM, WARD, BUILDING)
responses:
'201':
description: Location updated successfully
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/SuccessResponse'
'422':
description: Validation error
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse'

View File

@ -1,6 +1,6 @@
/api/ordertest:
get:
tags: [Orders]
get:
tags: [Order]
summary: List orders
security:
- bearerAuth: []
@ -47,8 +47,8 @@
items:
$ref: '../components/schemas/orders.yaml#/OrderTestList'
post:
tags: [Orders]
post:
tags: [Order]
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.
security:
@ -123,51 +123,9 @@
'500':
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:
tags: [Orders]
delete:
tags: [Order]
summary: Delete order
security:
- bearerAuth: []
@ -186,9 +144,9 @@
'200':
description: Order deleted
/api/ordertest/status:
post:
tags: [Orders]
/api/ordertest/status:
post:
tags: [Order]
summary: Update order status
security:
- bearerAuth: []
@ -229,9 +187,9 @@
data:
$ref: '../components/schemas/orders.yaml#/OrderTest'
/api/ordertest/{id}:
get:
tags: [Orders]
/api/ordertest/{id}:
get:
tags: [Order]
summary: Get order by ID
description: Returns order details with associated specimens and tests
security:
@ -243,17 +201,63 @@
schema:
type: string
description: Order ID (e.g., 0025030300001)
responses:
'200':
description: Order details with specimens and tests
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/orders.yaml#/OrderTest'
responses:
'200':
description: Order details with specimens and tests
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$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':
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:
tags: [Organization]
@ -89,8 +64,8 @@
'200':
description: Site deleted
/api/organization/site/{id}:
get:
/api/organization/site/{id}:
get:
tags: [Organization]
summary: Get site by ID
security:
@ -101,9 +76,37 @@
required: true
schema:
type: integer
responses:
'200':
description: Site details
responses:
'200':
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:
get:
@ -130,35 +133,6 @@
'201':
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:
tags: [Organization]
@ -180,8 +154,8 @@
'200':
description: Discipline deleted
/api/organization/discipline/{id}:
get:
/api/organization/discipline/{id}:
get:
tags: [Organization]
summary: Get discipline by ID
security:
@ -192,9 +166,41 @@
required: true
schema:
type: integer
responses:
'200':
description: Discipline details
responses:
'200':
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:
get:
@ -221,31 +227,6 @@
'201':
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:
tags: [Organization]
@ -267,8 +248,8 @@
'200':
description: Department deleted
/api/organization/department/{id}:
get:
/api/organization/department/{id}:
get:
tags: [Organization]
summary: Get department by ID
security:
@ -279,9 +260,37 @@
required: true
schema:
type: integer
responses:
'200':
description: Department details
responses:
'200':
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:
get:
@ -308,33 +317,6 @@
'201':
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:
tags: [Organization]
@ -356,8 +338,8 @@
'200':
description: Workstation deleted
/api/organization/workstation/{id}:
get:
/api/organization/workstation/{id}:
get:
tags: [Organization]
summary: Get workstation by ID
security:
@ -368,9 +350,39 @@
required: true
schema:
type: integer
responses:
'200':
description: Workstation details
responses:
'200':
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
/api/organization/hostapp:
@ -420,29 +432,6 @@
'201':
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:
tags: [Organization]
@ -464,8 +453,8 @@
'200':
description: Host application deleted
/api/organization/hostapp/{id}:
get:
/api/organization/hostapp/{id}:
get:
tags: [Organization]
summary: Get host application by ID
security:
@ -476,13 +465,39 @@
required: true
schema:
type: string
responses:
'200':
description: Host application details
content:
application/json:
schema:
$ref: '../components/schemas/organization.yaml#/HostApp'
responses:
'200':
description: Host application details
content:
application/json:
schema:
$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
/api/organization/hostcompara:
@ -532,31 +547,6 @@
'201':
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:
tags: [Organization]
@ -578,8 +568,8 @@
'200':
description: Host communication parameters deleted
/api/organization/hostcompara/{id}:
get:
/api/organization/hostcompara/{id}:
get:
tags: [Organization]
summary: Get host communication parameters by HostAppID
security:
@ -590,13 +580,41 @@
required: true
schema:
type: string
responses:
'200':
description: Host communication parameters details
content:
application/json:
schema:
$ref: '../components/schemas/organization.yaml#/HostComPara'
responses:
'200':
description: Host communication parameters details
content:
application/json:
schema:
$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
/api/organization/codingsys:
@ -646,31 +664,6 @@
'201':
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:
tags: [Organization]
@ -692,8 +685,8 @@
'200':
description: Coding system deleted
/api/organization/codingsys/{id}:
get:
/api/organization/codingsys/{id}:
get:
tags: [Organization]
summary: Get coding system by ID
security:
@ -704,10 +697,38 @@
required: true
schema:
type: integer
responses:
'200':
description: Coding system details
content:
application/json:
schema:
$ref: '../components/schemas/organization.yaml#/CodingSys'
responses:
'200':
description: Coding system details
content:
application/json:
schema:
$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:
get:
tags: [Patient Visits]
get:
tags: [Patient Visit]
summary: List patient visits
security:
- bearerAuth: []
@ -71,8 +71,8 @@
type: integer
description: Number of records per page
post:
tags: [Patient Visits]
post:
tags: [Patient Visit]
summary: Create patient visit
description: |
Creates a new patient visit. PVID is auto-generated with 'DV' prefix if not provided.
@ -145,86 +145,9 @@
InternalPVID:
type: integer
patch:
tags: [Patient Visits]
summary: Update patient visit
description: |
Updates an existing patient visit. InternalPVID is required.
Can update main visit data, PatDiag, and add new PatVisitADT records.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- InternalPVID
properties:
InternalPVID:
type: integer
description: Visit ID (required)
PVID:
type: string
InternalPID:
type: integer
EpisodeID:
type: string
SiteID:
type: integer
PatDiag:
type: object
description: Diagnosis information (will update if exists)
properties:
DiagCode:
type: string
Diagnosis:
type: string
PatVisitADT:
type: array
description: Array of ADT records to add (new records only)
items:
type: object
properties:
ADTCode:
type: string
enum: [A01, A02, A03, A04, A08]
LocationID:
type: integer
AttDoc:
type: integer
RefDoc:
type: integer
AdmDoc:
type: integer
CnsDoc:
type: integer
sequence:
type: integer
description: Used for ordering multiple ADT records
responses:
'200':
description: Visit updated successfully
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: object
properties:
PVID:
type: string
InternalPVID:
type: integer
delete:
tags: [Patient Visits]
delete:
tags: [Patient Visit]
summary: Delete patient visit
security:
- bearerAuth: []
@ -232,9 +155,9 @@
'200':
description: Visit deleted successfully
/api/patvisit/{id}:
get:
tags: [Patient Visits]
/api/patvisit/{id}:
get:
tags: [Patient Visit]
summary: Get visit by ID
security:
- bearerAuth: []
@ -245,24 +168,104 @@
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'
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:
tags: [Patient Visit]
summary: Update patient visit
description: |
Updates an existing patient visit. InternalPVID is required.
Can update main visit data, PatDiag, and add new PatVisitADT records.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Internal visit ID (InternalPVID)
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
PVID:
type: string
InternalPID:
type: integer
EpisodeID:
type: string
SiteID:
type: integer
PatDiag:
type: object
description: Diagnosis information (will update if exists)
properties:
DiagCode:
type: string
Diagnosis:
type: string
PatVisitADT:
type: array
description: Array of ADT records to add (new records only)
items:
type: object
properties:
ADTCode:
type: string
enum: [A01, A02, A03, A04, A08]
LocationID:
type: integer
AttDoc:
type: integer
RefDoc:
type: integer
AdmDoc:
type: integer
CnsDoc:
type: integer
sequence:
type: integer
description: Used for ordering multiple ADT records
responses:
'200':
description: Visit updated successfully
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: object
properties:
PVID:
type: string
InternalPVID:
type: integer
/api/patvisit/patient/{patientId}:
get:
tags: [Patient Visits]
/api/patvisit/patient/{patientId}:
get:
tags: [Patient Visit]
summary: Get visits by patient ID
security:
- bearerAuth: []
@ -288,9 +291,9 @@
items:
$ref: '../components/schemas/patient-visit.yaml#/PatientVisit'
/api/patvisitadt:
post:
tags: [Patient Visits]
/api/patvisitadt:
post:
tags: [Patient Visit]
summary: Create ADT record
description: Create a new Admission/Discharge/Transfer record
security:
@ -309,28 +312,10 @@
schema:
$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:
tags: [Patient Visits]
delete:
tags: [Patient Visit]
summary: Delete ADT visit (soft delete)
security:
- bearerAuth: []
@ -350,9 +335,9 @@
'200':
description: ADT visit deleted successfully
/api/patvisitadt/visit/{visitId}:
get:
tags: [Patient Visits]
/api/patvisitadt/visit/{visitId}:
get:
tags: [Patient Visit]
summary: Get ADT history by visit ID
description: Retrieve the complete Admission/Discharge/Transfer history for a visit, including all locations and doctors
security:
@ -424,30 +409,9 @@
EndDate:
type: string
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}:
get:
tags: [Patient Visits]
/api/patvisitadt/{id}:
get:
tags: [Patient Visit]
summary: Get ADT record by ID
description: Retrieve a single ADT record by its ID, including location and doctor details
security:
@ -514,6 +478,33 @@
CreateDate:
type: string
format: date-time
EndDate:
type: string
format: date-time
EndDate:
type: string
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:
get:
tags: [Patients]
get:
tags: [Patient]
summary: List patients
security:
- bearerAuth: []
@ -44,8 +44,8 @@
schema:
$ref: '../components/schemas/patient.yaml#/PatientListResponse'
post:
tags: [Patients]
post:
tags: [Patient]
summary: Create new patient
security:
- bearerAuth: []
@ -69,23 +69,9 @@
schema:
$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:
tags: [Patients]
delete:
tags: [Patient]
summary: Delete patient (soft delete)
security:
- bearerAuth: []
@ -105,9 +91,9 @@
'200':
description: Patient deleted successfully
/api/patient/check:
get:
tags: [Patients]
/api/patient/check:
get:
tags: [Patient]
summary: Check if patient exists
security:
- bearerAuth: []
@ -136,9 +122,9 @@
data:
$ref: '../components/schemas/patient.yaml#/Patient'
/api/patient/{id}:
get:
tags: [Patients]
/api/patient/{id}:
get:
tags: [Patient]
summary: Get patient by ID
security:
- bearerAuth: []
@ -149,15 +135,37 @@
schema:
type: integer
description: Internal patient record ID
responses:
'200':
description: Patient details
content:
application/json:
schema:
type: object
properties:
status:
type: string
data:
$ref: '../components/schemas/patient.yaml#/Patient'
responses:
'200':
description: Patient details
content:
application/json:
schema:
type: object
properties:
status:
type: string
data:
$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}:
get:
tags: [Reports]
/api/report/{orderID}:
get:
tags: [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.
security:
@ -31,4 +31,4 @@
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse'
$ref: '../components/schemas/common.yaml#/ErrorResponse'

View File

@ -1,6 +1,6 @@
/api/results:
get:
tags: [Results]
/api/result:
get:
tags: [Result]
summary: List results
description: Retrieve patient test results with optional filters by order or patient
security:
@ -94,9 +94,9 @@
type: string
nullable: true
/api/results/{id}:
get:
tags: [Results]
/api/result/{id}:
get:
tags: [Result]
summary: Get result by ID
description: Retrieve a specific result entry with all related data
security:
@ -202,8 +202,8 @@
schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse'
patch:
tags: [Results]
patch:
tags: [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.
security:
@ -273,8 +273,8 @@
schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse'
delete:
tags: [Results]
delete:
tags: [Result]
summary: Delete result
description: Soft delete a result entry by setting DelDate
security:
@ -298,4 +298,4 @@
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse'
$ref: '../components/schemas/common.yaml#/ErrorResponse'

View File

@ -1,6 +1,6 @@
/api/rules:
get:
tags: [Rules]
/api/rule:
get:
tags: [Rule]
summary: List rules
security:
- bearerAuth: []
@ -38,7 +38,7 @@
$ref: '../components/schemas/rules.yaml#/RuleDef'
post:
tags: [Rules]
tags: [Rule]
summary: Create rule
description: |
Create a new rule. Rules must be linked to at least one test via TestSiteIDs.
@ -77,15 +77,15 @@
ConditionExprCompiled:
type: string
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]
responses:
'201':
description: Rule created
/api/rules/{id}:
/api/rule/{id}:
get:
tags: [Rules]
tags: [Rule]
summary: Get rule with linked tests
security:
- bearerAuth: []
@ -113,8 +113,8 @@
'404':
description: Rule not found
patch:
tags: [Rules]
patch:
tags: [Rule]
summary: Update rule
description: |
Update a rule. TestSiteIDs can be provided to update which tests the rule is linked to.
@ -152,8 +152,8 @@
'404':
description: Rule not found
delete:
tags: [Rules]
delete:
tags: [Rule]
summary: Soft delete rule
security:
- bearerAuth: []
@ -170,9 +170,9 @@
'404':
description: Rule not found
/api/rules/validate:
post:
tags: [Rules]
/api/rule/validate:
post:
tags: [Rule]
summary: Validate/evaluate an expression
security:
- bearerAuth: []
@ -193,14 +193,14 @@
'200':
description: Validation result
/api/rules/compile:
post:
tags: [Rules]
/api/rule/compile:
post:
tags: [Rule]
summary: Compile DSL expression to engine-compatible structure
description: |
Compile a DSL expression to the engine-compatible JSON structure.
Frontend calls this when user clicks "Compile" button.
Returns compiled structure that can be saved to ConditionExprCompiled field.
Returns compiled structure that can be saved to ConditionExprCompiled field.
security:
- bearerAuth: []
requestBody:

View File

@ -23,23 +23,9 @@
'201':
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}:
get:
/api/specimen/{id}:
get:
tags: [Specimen]
summary: Get specimen by ID
security:
@ -50,9 +36,31 @@
required: true
schema:
type: integer
responses:
'200':
description: Specimen details
responses:
'200':
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:
tags: [Specimen]
@ -141,17 +149,9 @@
'201':
description: Container definition created
patch:
tags: [Specimen]
summary: Update container definition
security:
- bearerAuth: []
responses:
'200':
description: Container definition updated
/api/specimen/container/{id}:
get:
/api/specimen/container/{id}:
get:
tags: [Specimen]
summary: Get container definition by ID
security:
@ -162,9 +162,31 @@
required: true
schema:
type: integer
responses:
'200':
description: Container definition details
responses:
'200':
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:
get:
@ -191,14 +213,29 @@
'201':
description: Container definition created
patch:
tags: [Specimen]
summary: Update container definition (alias)
security:
- bearerAuth: []
responses:
'200':
description: Container definition updated
/api/specimen/containerdef/{id}:
patch:
tags: [Specimen]
summary: Update container definition (alias)
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/prep:
get:
@ -225,17 +262,9 @@
'201':
description: Specimen preparation created
patch:
tags: [Specimen]
summary: Update specimen preparation
security:
- bearerAuth: []
responses:
'200':
description: Specimen preparation updated
/api/specimen/prep/{id}:
get:
/api/specimen/prep/{id}:
get:
tags: [Specimen]
summary: Get specimen preparation by ID
security:
@ -246,9 +275,31 @@
required: true
schema:
type: integer
responses:
'200':
description: Specimen preparation details
responses:
'200':
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:
get:
@ -275,17 +326,9 @@
'201':
description: Specimen status created
patch:
tags: [Specimen]
summary: Update specimen status
security:
- bearerAuth: []
responses:
'200':
description: Specimen status updated
/api/specimen/status/{id}:
get:
/api/specimen/status/{id}:
get:
tags: [Specimen]
summary: Get specimen status by ID
security:
@ -296,9 +339,31 @@
required: true
schema:
type: integer
responses:
'200':
description: Specimen status details
responses:
'200':
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:
get:
@ -325,17 +390,9 @@
'201':
description: Collection method created
patch:
tags: [Specimen]
summary: Update specimen collection method
security:
- bearerAuth: []
responses:
'200':
description: Collection method updated
/api/specimen/collection/{id}:
get:
/api/specimen/collection/{id}:
get:
tags: [Specimen]
summary: Get specimen collection method by ID
security:
@ -346,6 +403,28 @@
required: true
schema:
type: integer
responses:
'200':
description: Collection method details
responses:
'200':
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:
get:
tags: [Tests]
tags: [Test]
summary: List all test mappings
security:
- bearerAuth: []
@ -37,8 +37,8 @@
ClientName:
type: string
post:
tags: [Tests]
post:
tags: [Test]
summary: Create test mapping (header only)
security:
- bearerAuth: []
@ -99,53 +99,9 @@
type: integer
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:
tags: [Tests]
delete:
tags: [Test]
summary: Soft delete test mapping (cascades to details)
security:
- bearerAuth: []
@ -180,9 +136,9 @@
'404':
description: Test mapping not found or already deleted
/api/test/testmap/{id}:
get:
tags: [Tests]
/api/test/testmap/{id}:
get:
tags: [Test]
summary: Get test mapping by ID with details
security:
- bearerAuth: []
@ -193,26 +149,73 @@
schema:
type: integer
description: Test Map ID
responses:
'200':
description: Test mapping details with nested detail records
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/tests.yaml#/TestMap'
'404':
description: Test mapping not found
responses:
'200':
description: Test mapping details with nested detail records
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/tests.yaml#/TestMap'
'404':
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}:
get:
tags: [Tests]
tags: [Test]
summary: Get test mappings by test code with details
security:
- bearerAuth: []
@ -242,7 +245,7 @@
/api/test/testmap/detail:
get:
tags: [Tests]
tags: [Test]
summary: List test mapping details
security:
- bearerAuth: []
@ -270,7 +273,7 @@
$ref: '../components/schemas/tests.yaml#/TestMapDetail'
post:
tags: [Tests]
tags: [Test]
summary: Create test mapping detail
security:
- bearerAuth: []
@ -312,41 +315,9 @@
type: integer
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:
tags: [Tests]
tags: [Test]
summary: Soft delete test mapping detail
security:
- bearerAuth: []
@ -366,9 +337,9 @@
'200':
description: Test mapping detail deleted
/api/test/testmap/detail/{id}:
get:
tags: [Tests]
/api/test/testmap/detail/{id}:
get:
tags: [Test]
summary: Get test mapping detail by ID
security:
- bearerAuth: []
@ -379,24 +350,59 @@
schema:
type: integer
description: Test Map Detail ID
responses:
'200':
description: Test mapping detail
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/tests.yaml#/TestMapDetail'
responses:
'200':
description: Test mapping detail
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$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}:
get:
tags: [Tests]
tags: [Test]
summary: Get test mapping details by test map ID
security:
- bearerAuth: []
@ -426,7 +432,7 @@
/api/test/testmap/detail/batch:
post:
tags: [Tests]
tags: [Test]
summary: Batch create test mapping details
security:
- bearerAuth: []
@ -456,7 +462,7 @@
description: Batch create results
patch:
tags: [Tests]
tags: [Test]
summary: Batch update test mapping details
security:
- bearerAuth: []
@ -488,7 +494,7 @@
description: Batch update results
delete:
tags: [Tests]
tags: [Test]
summary: Batch delete test mapping details
security:
- bearerAuth: []

View File

@ -1,6 +1,6 @@
/api/test:
get:
tags: [Tests]
tags: [Test]
summary: List test definitions
security:
- bearerAuth: []
@ -67,7 +67,7 @@
description: Total number of records matching the query
post:
tags: [Tests]
tags: [Test]
summary: Create test definition
security:
- bearerAuth: []
@ -205,29 +205,462 @@
- TestSiteName
- TestType
examples:
CALC_test:
summary: Create calculated test with members
TEST_no_ref:
summary: Technical test without reference or map
value:
SiteID: 1
TestSiteCode: IBIL
TestSiteName: Indirect Bilirubin
TestSiteCode: TEST_NREF
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
Description: Bilirubin Indirek
SeqScr: 210
SeqRpt: 210
SeqScr: 190
SeqRpt: 190
VisibleScr: 1
VisibleRpt: 1
CountStat: 0
details:
DisciplineID: 2
DepartmentID: 2
FormulaCode: "{TBIL} - {DBIL}"
RefType: RANGE
Unit1: mg/dL
Decimal: 2
FormulaCode: CKD_EPI(CREA,AGE,GENDER)
members:
- TestSiteID: 21
- 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:
'201':
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.'
patch:
tags: [Tests]
tags: [Test]
summary: Update test definition
security:
- bearerAuth: []
@ -426,7 +859,7 @@
/api/test/{id}:
get:
tags: [Tests]
tags: [Test]
summary: Get test definition by ID
security:
- bearerAuth: []
@ -455,7 +888,7 @@
description: Test not found
delete:
tags: [Tests]
tags: [Test]
summary: Soft delete test definition
security:
- bearerAuth: []

View File

@ -1,6 +1,6 @@
/api/users:
/api/user:
get:
tags: [Users]
tags: [User]
summary: List users with pagination and search
security:
- bearerAuth: []
@ -58,7 +58,7 @@
description: Server error
post:
tags: [Users]
tags: [User]
summary: Create new user
security:
- bearerAuth: []
@ -109,11 +109,44 @@
'500':
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:
tags: [Users]
tags: [User]
summary: Update existing user
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: User ID
requestBody:
required: true
content:
@ -150,33 +183,8 @@
'500':
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:
tags: [Users]
tags: [User]
summary: Delete user (soft delete)
security:
- bearerAuth: []
@ -209,4 +217,4 @@
'404':
description: User not found
'500':
description: Server error
description: Server error

View File

@ -1,6 +1,6 @@
/api/valueset:
get:
tags: [ValueSets]
get:
tags: [ValueSet]
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.
security:
@ -37,9 +37,9 @@
label: Order Status
count: 6
/api/valueset/{key}:
get:
tags: [ValueSets]
/api/valueset/{key}:
get:
tags: [ValueSet]
summary: Get lib value set by key
description: |
Get a specific library/system value set from JSON files.
@ -117,9 +117,9 @@
items:
$ref: '../components/schemas/valuesets.yaml#/ValueSetLibItem'
/api/valueset/refresh:
post:
tags: [ValueSets]
/api/valueset/refresh:
post:
tags: [ValueSet]
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/.
security:
@ -139,9 +139,9 @@
type: string
example: Cache cleared
/api/valueset/user/items:
get:
tags: [ValueSets]
/api/valueset/user/items:
get:
tags: [ValueSet]
summary: List user value set items
description: List value set items from database (user-defined)
security:
@ -177,8 +177,8 @@
items:
$ref: '../components/schemas/valuesets.yaml#/ValueSetItem'
post:
tags: [ValueSets]
post:
tags: [ValueSet]
summary: Create user value set item
description: Create value set item in database (user-defined)
security:
@ -222,9 +222,9 @@
data:
$ref: '../components/schemas/valuesets.yaml#/ValueSetItem'
/api/valueset/user/items/{id}:
get:
tags: [ValueSets]
/api/valueset/user/items/{id}:
get:
tags: [ValueSet]
summary: Get user value set item by ID
description: Get value set item from database (user-defined)
security:
@ -248,8 +248,8 @@
data:
$ref: '../components/schemas/valuesets.yaml#/ValueSetItem'
put:
tags: [ValueSets]
put:
tags: [ValueSet]
summary: Update user value set item
description: Update value set item in database (user-defined)
security:
@ -297,8 +297,8 @@
data:
$ref: '../components/schemas/valuesets.yaml#/ValueSetItem'
delete:
tags: [ValueSets]
delete:
tags: [ValueSet]
summary: Delete user value set item
description: Delete value set item from database (user-defined)
security:
@ -322,9 +322,9 @@
message:
type: string
/api/valueset/user/def:
get:
tags: [ValueSets]
/api/valueset/user/def:
get:
tags: [ValueSet]
summary: List user value set definitions
description: List value set definitions from database (user-defined)
security:
@ -371,8 +371,8 @@
limit:
type: integer
post:
tags: [ValueSets]
post:
tags: [ValueSet]
summary: Create user value set definition
description: Create value set definition in database (user-defined)
security:
@ -408,9 +408,9 @@
data:
$ref: '../components/schemas/valuesets.yaml#/ValueSetDef'
/api/valueset/user/def/{id}:
get:
tags: [ValueSets]
/api/valueset/user/def/{id}:
get:
tags: [ValueSet]
summary: Get user value set definition by ID
description: Get value set definition from database (user-defined)
security:
@ -434,8 +434,8 @@
data:
$ref: '../components/schemas/valuesets.yaml#/ValueSetDef'
put:
tags: [ValueSets]
put:
tags: [ValueSet]
summary: Update user value set definition
description: Update value set definition in database (user-defined)
security:
@ -477,8 +477,8 @@
data:
$ref: '../components/schemas/valuesets.yaml#/ValueSetDef'
delete:
tags: [ValueSets]
delete:
tags: [ValueSet]
summary: Delete user value set definition
description: Delete value set definition from database (user-defined)
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

@ -15,8 +15,8 @@ class ContactControllerTest extends CIUnitTestCase
protected function setUp(): void
{
parent::setUp();
// Generate JWT Token
// Generate JWT Token
$key = getenv('JWT_SECRET') ?: 'my-secret-key';
$payload = [
'iss' => 'localhost',
@ -27,8 +27,8 @@ class ContactControllerTest extends CIUnitTestCase
'uid' => 1,
'email' => 'admin@admin.com'
];
$this->token = JWT::encode($payload, $key, 'HS256');
}
$this->token = JWT::encode($payload, $key, 'HS256');
}
protected function callProtected($method, $path, $params = [])
{

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()
{
$payload = [
'CodingSysAbb' => 'ICD10',
'FullText' => 'International Classification of Diseases 10',
'CodingSysAbb' => 'ICD' . substr(time(), -3),
'FullText' => 'International Classification of Diseases 10 ' . time(),
'Description' => 'Medical diagnosis coding system'
];

View File

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

View File

@ -15,8 +15,8 @@ class OrganizationControllerTest extends CIUnitTestCase
protected function setUp(): void
{
parent::setUp();
// Generate JWT Token
// Generate JWT Token
$key = getenv('JWT_SECRET') ?: 'my-secret-key';
$payload = [
'iss' => 'localhost',
@ -27,8 +27,8 @@ class OrganizationControllerTest extends CIUnitTestCase
'uid' => 1,
'email' => 'admin@admin.com'
];
$this->token = JWT::encode($payload, $key, 'HS256');
}
$this->token = JWT::encode($payload, $key, 'HS256');
}
protected function callProtected($method, $path, $params = [])
{

View File

@ -5,11 +5,16 @@ namespace Tests\Feature\PatVisit;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
class PatVisitByPatientTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api/patvisit/patient';
class PatVisitByPatientTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api/patvisit/patient';
protected function setUp(): void
{
parent::setUp();
}
/**
* Test: Show all visits by valid InternalPID

View File

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

View File

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

View File

@ -5,11 +5,16 @@ namespace Tests\Feature\PatVisit;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
class PatVisitShowTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api/patvisit';
class PatVisitShowTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api/patvisit';
protected function setUp(): void
{
parent::setUp();
}
public function testShowPatientVisitSuccess()
{

View File

@ -2,13 +2,20 @@
namespace Tests\Feature\PatVisit;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
use Tests\Support\Traits\CreatesPatients;
class PatVisitUpdateTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api/patvisit';
class PatVisitUpdateTest extends CIUnitTestCase
{
use FeatureTestTrait;
use CreatesPatients;
protected $endpoint = 'api/patvisit';
protected function setUp(): void
{
parent::setUp();
}
/**
* Test: Update patient visit successfully
@ -16,8 +23,8 @@ class PatVisitUpdateTest extends CIUnitTestCase
public function testUpdatePatientVisitSuccess()
{
// First create a visit to update
$createPayload = [
"InternalPID"=> "1",
$createPayload = [
"InternalPID"=> $this->createTestPatient(),
"EpisodeID"=> "TEST001",
"PatVisitADT"=> [
"ADTCode"=> "A01",
@ -33,12 +40,11 @@ class PatVisitUpdateTest extends CIUnitTestCase
$pvid = $createJson['data']['PVID'];
// Now update it
$payload = [
'InternalPVID' => $internalPVID,
'PVID' => $pvid,
'EpisodeID' => 'EPI001',
'PatDiag' => [
'DiagCode' => 'A02',
$payload = [
'PVID' => $pvid,
'EpisodeID' => 'EPI001',
'PatDiag' => [
'DiagCode' => 'A02',
'DiagName' => 'Dysentery'
],
'PatVisitADT' => [
@ -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)
$response->assertStatus(200);
@ -67,22 +73,22 @@ class PatVisitUpdateTest extends CIUnitTestCase
/**
* Test: Update patient visit with missing ID
*/
public function testUpdatePatientVisitMissingId()
{
// InternalPVID tidak ada
$payload = [
'EpisodeID' => 'EPI002',
'PatDiag' => ['DiagCode' => 'B01', 'DiagName' => 'Flu']
];
$response = $this->withBodyFormat('json')->call('patch', $this->endpoint, $payload);
// Karena ID tidak ada → 400 Bad Request
$response->assertStatus(400);
$response->assertJSONFragment([
'status' => 'error',
'message' => 'Invalid or missing ID'
]);
public function testUpdatePatientVisitMissingId()
{
// InternalPVID tidak ada
$payload = [
'EpisodeID' => 'EPI002',
'PatDiag' => ['DiagCode' => 'B01', 'DiagName' => 'Flu']
];
$response = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/0', $payload);
// Karena ID tidak valid → 400 Bad Request
$response->assertStatus(400);
$response->assertJSONFragment([
'status' => 'error',
'message' => 'Invalid or missing ID'
]);
}
/**
@ -90,21 +96,20 @@ class PatVisitUpdateTest extends CIUnitTestCase
*/
public function testUpdatePatientVisitNotFound()
{
$payload = [
'InternalPVID' => 999999, // Non-existent visit
'EpisodeID' => 'EPI001',
'PatDiag' => [
'DiagCode' => 'A02',
'DiagName' => 'Dysentery'
]
];
$response = $this->withBodyFormat('json')->call('patch', $this->endpoint, $payload);
$response->assertStatus(404);
$response->assertJSONFragment([
'status' => 'error',
'message' => 'Visit not found'
]);
$payload = [
'EpisodeID' => 'EPI001',
'PatDiag' => [
'DiagCode' => 'A02',
'DiagName' => 'Dysentery'
]
];
$response = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/999999', $payload);
$response->assertStatus(404);
$response->assertJSONFragment([
'status' => 'error',
'message' => 'Visit not found'
]);
}
/**
@ -112,12 +117,11 @@ class PatVisitUpdateTest extends CIUnitTestCase
*/
public function testUpdatePatientVisitInvalidId()
{
$payload = [
'InternalPVID' => 'invalid',
'PVID' => 'DV0001',
'EpisodeID' => 'EPI001',
'PatDiag' => [
'DiagCode' => 'A02',
$payload = [
'PVID' => 'DV0001',
'EpisodeID' => 'EPI001',
'PatDiag' => [
'DiagCode' => 'A02',
'DiagName' => 'Dysentery'
],
'PatVisitADT' => [
@ -126,27 +130,27 @@ class PatVisitUpdateTest extends CIUnitTestCase
]
];
$response = $this->withBodyFormat('json')->call('patch', $this->endpoint, $payload);
$response->assertStatus(400);
$response->assertJSONFragment([
'status' => 'error',
'message' => 'Invalid or missing ID'
]);
$response = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/invalid', $payload);
$response->assertStatus(400);
$response->assertJSONFragment([
'status' => 'error',
'message' => 'Invalid or missing ID'
]);
}
/**
* Test: Update patient visit with empty payload
*/
public function testUpdatePatientVisitInvalidInput()
{
$payload = [];
$response = $this->withBodyFormat('json')->call('patch', $this->endpoint, $payload);
$response->assertStatus(400);
$response->assertJSONFragment([
'status' => 'error',
'message' => 'Invalid or missing ID'
]);
}
public function testUpdatePatientVisitInvalidInput()
{
$payload = [];
$response = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/0', $payload);
$response->assertStatus(400);
$response->assertJSONFragment([
'status' => 'error',
'message' => 'Invalid or missing ID'
]);
}
}

View File

@ -23,11 +23,16 @@ use Faker\Factory;
* 5. testCheckWithoutParams - Check without any parameters
* 6. testCheckWithBothParams - Check with both parameters (PatientID takes priority)
*/
class PatientCheckTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api/patient/check';
class PatientCheckTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api/patient/check';
protected function setUp(): void
{
parent::setUp();
}
/**
* Test Case 1: Check existing PatientID

View File

@ -6,10 +6,15 @@ use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
use Faker\Factory;
class PatientCreateTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api/patient';
class PatientCreateTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api/patient';
protected function setUp(): void
{
parent::setUp();
}
// 400 - Passed
// Validation Gagal - Array Tidak Complete

View File

@ -22,11 +22,16 @@ use Faker\Factory;
* 7. testDeleteSQLInjectionAttempt - 400 error when SQL injection attempted
* 8. testDeleteSuccess - 200 success when valid delete (commented - requires DB setup)
*/
class PatientDeleteTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api/patient';
class PatientDeleteTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api/patient';
protected function setUp(): void
{
parent::setUp();
}
/**
* Test Case 1: Delete without InternalPID key

View File

@ -5,11 +5,16 @@ namespace Tests\Feature\Patients;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
class PatientIndexTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api/patient';
class PatientIndexTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api/patient';
protected function setUp(): void
{
parent::setUp();
}
/**
* Case 1: tanpa parameter, harus 200 dan status success - Passed

View File

@ -5,11 +5,16 @@ namespace Tests\Feature\Patients;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
class PatientShowTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api/patient';
class PatientShowTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api/patient';
protected function setUp(): void
{
parent::setUp();
}
// 200 ok found - Passed
public function testShowSingleRow() {
@ -81,4 +86,4 @@ class PatientShowTest extends CIUnitTestCase
$this->assertGreaterThanOrEqual(1, count($data['data']['PatAtt']));
}
}
}

View File

@ -6,18 +6,23 @@ use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
use Faker\Factory;
class PatientUpdateTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api/patient';
class PatientUpdateTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api/patient';
protected function setUp(): void
{
parent::setUp();
}
/**
* 400 - Validation Fail
* Coba update tanpa field wajib harus gagal validasi.
*/
public function testUpdatePatientValidationFail()
{
$payload = [ 'InternalPID' => null, 'NameFirst' => '' ]; // Tidak valid
$result = $this->withBodyFormat('json')->call('patch', $this->endpoint, $payload);
$payload = [ 'InternalPID' => null, 'NameFirst' => '' ]; // Tidak valid
$result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload);
$result->assertStatus(400);
$json = $result->getJSON();
@ -33,13 +38,12 @@ class PatientUpdateTest extends CIUnitTestCase
{
$faker = Factory::create('id_ID');
$payload = [
'InternalPID' => 999999, // Asumsi tidak ada di DB
"PatientID" => "SMAJ1",
"EmailAddress1" => 'asaas7890@gmail.com',
"Phone" => $faker->numerify('08##########'),
"MobilePhone" => $faker->numerify('08##########'),
'NameFirst' => $faker->firstName,
$payload = [
"PatientID" => "SMAJ1",
"EmailAddress1" => 'asaas7890@gmail.com',
"Phone" => $faker->numerify('08##########'),
"MobilePhone" => $faker->numerify('08##########'),
'NameFirst' => $faker->firstName,
'NameLast' => $faker->lastName,
'Sex' => '1',
'Birthdate' => $faker->date('Y-m-d'),
@ -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)
}
@ -65,12 +69,11 @@ class PatientUpdateTest extends CIUnitTestCase
// NOTE: Sebaiknya ambil InternalPID yang sudah ada (mock atau dari DB fixture)
// Untuk contoh ini kita asumsikan ada ID 1
$payload = [
'InternalPID' => 1,
"PatientID" => "SMAJ1",
'NameFirst' => $faker->firstName,
'NameMiddle' => $faker->firstName,
'NameLast' => $faker->lastName,
$payload = [
"PatientID" => "SMAJ1",
'NameFirst' => $faker->firstName,
'NameMiddle' => $faker->firstName,
'NameLast' => $faker->lastName,
'Sex' => '1',
'Birthdate' => $faker->date('Y-m-d'),
'EmailAddress1' => 'update_' . $faker->numberBetween(1,999) . '@gmail.com',
@ -95,7 +98,7 @@ class PatientUpdateTest extends CIUnitTestCase
$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);
$json = $result->getJSON();
$data = json_decode($json, true);
@ -109,12 +112,11 @@ class PatientUpdateTest extends CIUnitTestCase
{
$faker = Factory::create('id_ID');
$payload = [
'InternalPID' => 1,
"PatientID" => "SMAJ1",
'NameFirst' => $faker->firstName,
'NameMiddle' => $faker->firstName,
'NameLast' => $faker->lastName,
$payload = [
"PatientID" => "SMAJ1",
'NameFirst' => $faker->firstName,
'NameMiddle' => $faker->firstName,
'NameLast' => $faker->lastName,
'Sex' => '1',
'Birthdate' => $faker->date('Y-m-d'),
'EmailAddress1' => 'update_' . $faker->numberBetween(1,999) . '@gmail.com',
@ -138,7 +140,7 @@ class PatientUpdateTest extends CIUnitTestCase
$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);
}
@ -149,12 +151,11 @@ class PatientUpdateTest extends CIUnitTestCase
{
$faker = Factory::create('id_ID');
$payload = [
'InternalPID' => 1,
"PatientID" => "SMAJ1",
'NameFirst' => $faker->firstName,
'NameMiddle' => $faker->firstName,
'NameLast' => $faker->lastName,
$payload = [
"PatientID" => "SMAJ1",
'NameFirst' => $faker->firstName,
'NameMiddle' => $faker->firstName,
'NameLast' => $faker->lastName,
'Sex' => '1',
'Birthdate' => $faker->date('Y-m-d'),
'EmailAddress1' => 'update_' . $faker->numberBetween(1,999) . '@gmail.com',
@ -176,7 +177,7 @@ class PatientUpdateTest extends CIUnitTestCase
$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);
}
@ -187,12 +188,11 @@ class PatientUpdateTest extends CIUnitTestCase
{
$faker = Factory::create('id_ID');
$payload = [
'InternalPID' => 1,
"PatientID" => "SMAJ1",
'NameFirst' => $faker->firstName,
'NameMiddle' => $faker->firstName,
'NameLast' => $faker->lastName,
$payload = [
"PatientID" => "SMAJ1",
'NameFirst' => $faker->firstName,
'NameMiddle' => $faker->firstName,
'NameLast' => $faker->lastName,
'Sex' => '1',
'Birthdate' => $faker->date('Y-m-d'),
'EmailAddress1' => 'update_' . $faker->numberBetween(1,999) . '@gmail.com',
@ -216,7 +216,7 @@ class PatientUpdateTest extends CIUnitTestCase
$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);
}
@ -227,12 +227,11 @@ class PatientUpdateTest extends CIUnitTestCase
{
$faker = Factory::create('id_ID');
$payload = [
'InternalPID' => 1,
"PatientID" => "SMAJ1",
'NameFirst' => $faker->firstName,
'NameMiddle' => $faker->firstName,
'NameLast' => $faker->lastName,
$payload = [
"PatientID" => "SMAJ1",
'NameFirst' => $faker->firstName,
'NameMiddle' => $faker->firstName,
'NameLast' => $faker->lastName,
'Sex' => '1',
'Birthdate' => $faker->date('Y-m-d'),
'EmailAddress1' => 'update_' . $faker->numberBetween(1,999) . '@gmail.com',
@ -256,7 +255,7 @@ class PatientUpdateTest extends CIUnitTestCase
$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);
$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');
$labels = array_column($data['data'], 'label');
$this->assertContains('1', $values);
$this->assertContains('2', $values);
$this->assertContains('F', $values);
$this->assertContains('M', $values);
$this->assertContains('Female', $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

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