feat: require id params for update endpoints

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

406
AGENTS.md
View File

@ -1,316 +1,152 @@
# AGENTS.md - Code Guidelines for CLQMS
> **CLQMS (Clinical Laboratory Quality Management System)** - A headless REST API backend for clinical laboratory workflows built with CodeIgniter 4.
> **CLQMS (Clinical Laboratory Quality Management System)** headless REST API backend built on CodeIgniter 4 with a focus on laboratory workflows, JWT authentication, and synchronized OpenAPI documentation.
---
## Build, Test & Lint Commands
## Repository Snapshot
- `app/` holds controllers, models, filters, and traits wired through PSR-4 `App\` namespace.
- `tests/` relies on CodeIgniter's testing helpers plus Faker for deterministic fixtures.
- Shared response helpers and ValueSet lookups live under `app/Libraries` and `app/Traits` and should be reused before introducing new helpers.
- Environment values, secrets, and database credentials live in `.env` but are never committed; treat the file as a reference for defaults.
---
## Build, Lint & Test
All commands run from the repository root.
```bash
# Run all tests
# Run the entire PHPUnit suite
./vendor/bin/phpunit
# Run a specific test file
# Target a single test file (fast verification)
./vendor/bin/phpunit tests/feature/Patients/PatientCreateTest.php
# Run a specific test method
# Run one test case by method
./vendor/bin/phpunit --filter testCreatePatientSuccess tests/feature/Patients/PatientCreateTest.php
# Run tests with coverage
./vendor/bin/phpunit --coverage-html build/logs/html
# Run tests by suite
./vendor/bin/phpunit --testsuite App
# Generate scaffolding
# Generate scaffolding (model, controller, migration)
php spark make:model <Name>
php spark make:controller <Name>
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
# After OpenAPI edits
node public/bundle-api-docs.js
```
This produces `public/api-docs.bundled.yaml` which is used by Swagger UI/Redoc.
Use `php spark test --filter <Class>::<method>` when filtering more than one test file is cumbersome.
### Controller-to-YAML Mapping
| Controller | YAML Path File | YAML Schema File |
|-----------|----------------|------------------|
| `PatientController` | `paths/patients.yaml` | `components/schemas/patient.yaml` |
| `PatVisitController` | `paths/patient-visits.yaml` | `components/schemas/patient-visit.yaml` |
| `OrderTestController` | `paths/orders.yaml` | `components/schemas/orders.yaml` |
| `SpecimenController` | `paths/specimen.yaml` | `components/schemas/specimen.yaml` |
| `TestsController` | `paths/tests.yaml` | `components/schemas/tests.yaml` |
| `AuthController` | `paths/authentication.yaml` | `components/schemas/authentication.yaml` |
| `ResultController` | `paths/results.yaml` | `components/schemas/*.yaml` |
| `EdgeController` | `paths/edge-api.yaml` | `components/schemas/edge-api.yaml` |
| `LocationController` | `paths/locations.yaml` | `components/schemas/master-data.yaml` |
| `ValueSetController` | `paths/valuesets.yaml` | `components/schemas/valuesets.yaml` |
| `ContactController` | `paths/contact.yaml` | (inline schemas) |
---
### Legacy Field Naming
Database uses PascalCase columns: `PatientID`, `NameFirst`, `Birthdate`, `CreatedAt`, `UpdatedAt`
## 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.
### 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']);
```
## 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 with nested data (PatIdt, PatCom, PatAtt):
- Extract nested arrays before filtering
- Use transactions for multi-table operations
- Handle empty/null arrays appropriately
- 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.
---
## 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.*
## 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,
@ -204,6 +204,9 @@ class Database extends Config
// we are currently running an automated test suite, so that
// we don't overwrite live data on accident.
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

@ -18,7 +18,7 @@ $routes->group('api', ['filter' => 'auth'], function ($routes) {
$routes->get('sample', 'SampleController::index');
// Results CRUD
$routes->group('results', function ($routes) {
$routes->group('result', function ($routes) {
$routes->get('/', 'ResultController::index');
$routes->get('(:num)', 'ResultController::show/$1');
$routes->patch('(:num)', 'ResultController::update/$1');
@ -26,7 +26,7 @@ $routes->group('api', ['filter' => 'auth'], function ($routes) {
});
// Reports
$routes->get('reports/(:num)', 'ReportController::view/$1');
$routes->get('report/(:num)', 'ReportController::view/$1');
});
@ -58,7 +58,7 @@ $routes->group('api', function ($routes) {
$routes->post('/', 'Patient\PatientController::create');
$routes->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,16 +250,16 @@ $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->group('user', 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->patch('(:num)', 'User\UserController::update/$1');
$routes->delete('(:num)', 'User\UserController::delete/$1');
});
@ -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,13 +343,13 @@ $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->group('rule', function ($routes) {
$routes->get('/', 'Rule\RuleController::index');
$routes->get('(:num)', 'Rule\RuleController::show/$1');
$routes->post('/', 'Rule\RuleController::create');
@ -362,14 +362,14 @@ $routes->group('api', function ($routes) {
// Demo/Test Routes (No Auth)
$routes->group('api/demo', function ($routes) {
$routes->post('order', 'Test\DemoOrderController::createDemoOrder');
$routes->get('orders', 'Test\DemoOrderController::listDemoOrders');
$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('result', 'EdgeController::results');
$routes->get('order', 'EdgeController::orders');
$routes->post('order/(:num)/ack', 'EdgeController::ack/$1');
$routes->post('status', 'EdgeController::status');
});
});

View File

@ -75,8 +75,16 @@ class ContactController extends BaseController {
}
}
public function update() {
public function update($ContactID = null) {
$input = $this->request->getJSON(true);
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);

View File

@ -51,8 +51,12 @@ class MedicalSpecialtyController extends BaseController {
}
}
public function update() {
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);

View File

@ -51,8 +51,12 @@ class OccupationController extends BaseController {
}
}
public function update() {
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);

View File

@ -43,8 +43,12 @@ class CounterController extends BaseController {
}
}
public function update() {
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);

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)

View File

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

View File

@ -50,8 +50,16 @@ class LocationController extends BaseController {
}
}
public function update() {
public function update($LocationID = null) {
$input = $this->request->getJSON(true);
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);

View File

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

View File

@ -65,8 +65,12 @@ class AccountController extends BaseController {
}
}
public function update() {
public function update($AccountID = null) {
$input = $this->request->getJSON(true);
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.'); }

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,9 +62,13 @@ class DepartmentController extends BaseController {
}
}
public function update() {
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);

View File

@ -63,8 +63,10 @@ class DisciplineController extends BaseController {
}
}
public function update() {
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);

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

@ -59,7 +59,7 @@ class SiteController extends BaseController {
$validation = service('validation');
$validation->setRules([
'SiteCode' => 'required|regex_match[/^[A-Z0-9]{2}$/]',
'SiteCode' => 'required|regex_match[/^[A-Z0-9]{2,6}$/]',
'SiteName' => 'required',
]);
@ -75,16 +75,18 @@ class SiteController extends BaseController {
}
}
public function update() {
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.'); }
if (!empty($input['SiteCode'])) {
$validation = service('validation');
$validation->setRules([
'SiteCode' => 'regex_match[/^[A-Z0-9]{2}$/]',
'SiteCode' => 'regex_match[/^[A-Z0-9]{2,6}$/]',
]);
if (!$validation->run($input)) {

View File

@ -63,9 +63,13 @@ class WorkstationController extends BaseController {
}
}
public function update() {
public function update($WorkstationID = null) {
$input = $this->request->getJSON(true);
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);

View File

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

View File

@ -115,9 +115,18 @@ class PatientController extends Controller {
}
}
public function update() {
public function update($InternalPID = null) {
$input = $this->request->getJSON(true);
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;
$identifierRulesMap = [

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 {

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,8 +73,12 @@ class ContainerDefController extends BaseController {
}
}
public function update() {
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);

View File

@ -66,8 +66,12 @@ class SpecimenCollectionController extends BaseController {
}
}
public function update() {
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);

View File

@ -66,8 +66,12 @@ class SpecimenController extends BaseController {
}
}
public function update() {
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);

View File

@ -51,8 +51,12 @@ class SpecimenPrepController extends BaseController {
}
}
public function update() {
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);

View File

@ -64,8 +64,12 @@ class ContainerDef extends BaseController {
}
}
public function update() {
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);

View File

@ -64,10 +64,14 @@ class TestMapController extends BaseController {
}
}
public function update() {
public function update($TestMapID = null) {
$input = $this->request->getJSON(true);
$id = $input["TestMapID"];
if (!$id) { return $this->failValidationErrors('TestMapID is required.'); }
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);

View File

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

@ -171,7 +171,10 @@ class TestsController extends BaseController
$id = $this->model->insert($testSiteData);
if (!$id) {
throw new \Exception('Failed to insert main test definition');
$dbError = $db->error();
log_message('error', 'Test insert failed: ' . json_encode($dbError, JSON_UNESCAPED_SLASHES));
$message = $dbError['message'] ?? 'Failed to insert main test definition';
throw new \Exception('Failed to insert main test definition: ' . $message);
}
$this->handleDetails($id, $input, 'insert');
@ -179,6 +182,12 @@ class TestsController extends BaseController
$db->transComplete();
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');
}

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)
{

View File

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

View File

@ -214,20 +214,21 @@ class PatientModel extends BaseModel {
try {
$InternalPID = $input['InternalPID'];
$previousData = $this->find($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,
$previousData,
(array) $previousData,
$input,
'Patient data updated',
['changed_fields' => array_keys(array_diff_assoc($previousData, $input))]
['changed_fields' => $changedFields]
);
if (!empty($input['PatIdt'])) {

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'));
}
/**
@ -93,7 +93,8 @@ class RuleDefModel extends BaseModel
->where('RuleID', $ruleID)
->where('TestSiteID', $testSiteID)
->where('EndDate IS NULL')
->first();
->get()
->getRowArray();
if ($existing) {
return true; // Already linked
@ -104,7 +105,8 @@ class RuleDefModel extends BaseModel
->where('RuleID', $ruleID)
->where('TestSiteID', $testSiteID)
->where('EndDate IS NOT NULL')
->first();
->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,16 +152,29 @@ 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();
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

@ -2,7 +2,7 @@
<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"
bootstrap="tests/phpunit-bootstrap.php"
backupGlobals="false"
beStrictAboutOutputDuringTests="true"
colors="true"
@ -43,7 +43,7 @@
</source>
<php>
<!-- Enable / Disable --> <!-- WAJIB DISESUAIKAN -->
<!-- <env name="CI_ENVIRONMENT" value="testing"/> -->
<env name="CI_ENVIRONMENT" value="testing"/>
<!-- <server name="app.baseURL" value="http://example.com/"/> -->
<server name="app.baseURL" value="http://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

@ -25,40 +25,40 @@ servers:
tags:
- name: Authentication
description: User authentication and session management
- name: Patients
- name: Patient
description: Patient registration and management
- name: Patient Visits
- 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: Tests
- name: Test
description: Test definitions and test catalog
- name: Calculations
- name: Rule
description: Rule engine - rules can be linked to multiple tests via testrule mapping table
- name: Calculation
description: Lightweight calculator endpoint for retrieving computed values by code or name
- name: Orders
- name: Order
description: Laboratory order management
- name: Results
- name: Result
description: Patient results reporting with auto-validation
- name: Reports
- name: Report
description: Lab report generation (HTML view)
- name: Edge API
description: Instrument integration endpoints
- name: Contacts
- name: Contact
description: Contact management (doctors, practitioners, etc.)
- name: Locations
description: Location management (rooms, wards, buildings)
- name: ValueSets
- name: ValueSet
description: Value set definitions and items
- name: User
description: User management and administration
- 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
components:
securitySchemes:

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
/api/ordertest:
get:
tags: [Orders]
tags: [Order]
summary: List orders
security:
- bearerAuth: []
@ -48,7 +48,7 @@
$ref: '../components/schemas/orders.yaml#/OrderTestList'
post:
tags: [Orders]
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]
tags: [Order]
summary: Delete order
security:
- bearerAuth: []
@ -188,7 +146,7 @@
/api/ordertest/status:
post:
tags: [Orders]
tags: [Order]
summary: Update order status
security:
- bearerAuth: []
@ -231,7 +189,7 @@
/api/ordertest/{id}:
get:
tags: [Orders]
tags: [Order]
summary: Get order by ID
description: Returns order details with associated specimens and tests
security:
@ -257,3 +215,49 @@
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]
@ -105,6 +80,34 @@
'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:
tags: [Organization]
@ -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]
@ -196,6 +170,38 @@
'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:
tags: [Organization]
@ -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]
@ -283,6 +264,34 @@
'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:
tags: [Organization]
@ -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]
@ -372,6 +354,36 @@
'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:
get:
@ -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]
@ -484,6 +473,32 @@
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:
get:
@ -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]
@ -598,6 +588,34 @@
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:
get:
@ -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]
@ -711,3 +704,31 @@
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]
tags: [Patient Visit]
summary: List patient visits
security:
- bearerAuth: []
@ -72,7 +72,7 @@
description: Number of records per page
post:
tags: [Patient Visits]
tags: [Patient Visit]
summary: Create patient visit
description: |
Creates a new patient visit. PVID is auto-generated with 'DV' prefix if not provided.
@ -145,26 +145,66 @@
InternalPVID:
type: integer
delete:
tags: [Patient Visit]
summary: Delete patient visit
security:
- bearerAuth: []
responses:
'200':
description: Visit deleted successfully
/api/patvisit/{id}:
get:
tags: [Patient Visit]
summary: Get visit by ID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
description: PVID (visit identifier like DV00001)
responses:
'200':
description: Visit details
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/patient-visit.yaml#/PatientVisit'
patch:
tags: [Patient Visits]
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
required:
- InternalPVID
properties:
InternalPVID:
type: integer
description: Visit ID (required)
PVID:
type: string
InternalPID:
@ -223,46 +263,9 @@
InternalPVID:
type: integer
delete:
tags: [Patient Visits]
summary: Delete patient visit
security:
- bearerAuth: []
responses:
'200':
description: Visit deleted successfully
/api/patvisit/{id}:
get:
tags: [Patient Visits]
summary: Get visit by ID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
description: PVID (visit identifier like DV00001)
responses:
'200':
description: Visit details
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/patient-visit.yaml#/PatientVisit'
/api/patvisit/patient/{patientId}:
get:
tags: [Patient Visits]
tags: [Patient Visit]
summary: Get visits by patient ID
security:
- bearerAuth: []
@ -290,7 +293,7 @@
/api/patvisitadt:
post:
tags: [Patient Visits]
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]
tags: [Patient Visit]
summary: Delete ADT visit (soft delete)
security:
- bearerAuth: []
@ -352,7 +337,7 @@
/api/patvisitadt/visit/{visitId}:
get:
tags: [Patient Visits]
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]
tags: [Patient Visit]
summary: Get ADT record by ID
description: Retrieve a single ADT record by its ID, including location and doctor details
security:
@ -517,3 +481,30 @@
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]
tags: [Patient]
summary: List patients
security:
- bearerAuth: []
@ -45,7 +45,7 @@
$ref: '../components/schemas/patient.yaml#/PatientListResponse'
post:
tags: [Patients]
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]
tags: [Patient]
summary: Delete patient (soft delete)
security:
- bearerAuth: []
@ -107,7 +93,7 @@
/api/patient/check:
get:
tags: [Patients]
tags: [Patient]
summary: Check if patient exists
security:
- bearerAuth: []
@ -138,7 +124,7 @@
/api/patient/{id}:
get:
tags: [Patients]
tags: [Patient]
summary: Get patient by ID
security:
- bearerAuth: []
@ -161,3 +147,25 @@
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}:
/api/report/{orderID}:
get:
tags: [Reports]
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:

View File

@ -1,6 +1,6 @@
/api/results:
/api/result:
get:
tags: [Results]
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}:
/api/result/{id}:
get:
tags: [Results]
tags: [Result]
summary: Get result by ID
description: Retrieve a specific result entry with all related data
security:
@ -203,7 +203,7 @@
$ref: '../components/schemas/common.yaml#/ErrorResponse'
patch:
tags: [Results]
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:
@ -274,7 +274,7 @@
$ref: '../components/schemas/common.yaml#/ErrorResponse'
delete:
tags: [Results]
tags: [Result]
summary: Delete result
description: Soft delete a result entry by setting DelDate
security:

View File

@ -1,6 +1,6 @@
/api/rules:
/api/rule:
get:
tags: [Rules]
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: []
@ -114,7 +114,7 @@
description: Rule not found
patch:
tags: [Rules]
tags: [Rule]
summary: Update rule
description: |
Update a rule. TestSiteIDs can be provided to update which tests the rule is linked to.
@ -153,7 +153,7 @@
description: Rule not found
delete:
tags: [Rules]
tags: [Rule]
summary: Soft delete rule
security:
- bearerAuth: []
@ -170,9 +170,9 @@
'404':
description: Rule not found
/api/rules/validate:
/api/rule/validate:
post:
tags: [Rules]
tags: [Rule]
summary: Validate/evaluate an expression
security:
- bearerAuth: []
@ -193,9 +193,9 @@
'200':
description: Validation result
/api/rules/compile:
/api/rule/compile:
post:
tags: [Rules]
tags: [Rule]
summary: Compile DSL expression to engine-compatible structure
description: |
Compile a DSL expression to the engine-compatible JSON structure.

View File

@ -23,20 +23,6 @@
'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:
@ -54,6 +40,28 @@
'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]
summary: Delete specimen (soft delete)
@ -141,14 +149,6 @@
'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:
@ -166,6 +166,28 @@
'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:
tags: [Specimen]
@ -191,11 +213,26 @@
'201':
description: Container definition created
/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
@ -225,14 +262,6 @@
'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:
@ -250,6 +279,28 @@
'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:
tags: [Specimen]
@ -275,14 +326,6 @@
'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:
@ -300,6 +343,28 @@
'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:
tags: [Specimen]
@ -325,14 +390,6 @@
'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:
@ -349,3 +406,25 @@
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: []
@ -38,7 +38,7 @@
type: string
post:
tags: [Tests]
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]
tags: [Test]
summary: Soft delete test mapping (cascades to details)
security:
- bearerAuth: []
@ -182,7 +138,7 @@
/api/test/testmap/{id}:
get:
tags: [Tests]
tags: [Test]
summary: Get test mapping by ID with details
security:
- bearerAuth: []
@ -210,9 +166,56 @@
'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: []
@ -368,7 +339,7 @@
/api/test/testmap/detail/{id}:
get:
tags: [Tests]
tags: [Test]
summary: Get test mapping detail by ID
security:
- bearerAuth: []
@ -394,9 +365,44 @@
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: []

View File

@ -1,6 +1,6 @@
/api/valueset:
get:
tags: [ValueSets]
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:
@ -39,7 +39,7 @@
/api/valueset/{key}:
get:
tags: [ValueSets]
tags: [ValueSet]
summary: Get lib value set by key
description: |
Get a specific library/system value set from JSON files.
@ -119,7 +119,7 @@
/api/valueset/refresh:
post:
tags: [ValueSets]
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:
@ -141,7 +141,7 @@
/api/valueset/user/items:
get:
tags: [ValueSets]
tags: [ValueSet]
summary: List user value set items
description: List value set items from database (user-defined)
security:
@ -178,7 +178,7 @@
$ref: '../components/schemas/valuesets.yaml#/ValueSetItem'
post:
tags: [ValueSets]
tags: [ValueSet]
summary: Create user value set item
description: Create value set item in database (user-defined)
security:
@ -224,7 +224,7 @@
/api/valueset/user/items/{id}:
get:
tags: [ValueSets]
tags: [ValueSet]
summary: Get user value set item by ID
description: Get value set item from database (user-defined)
security:
@ -249,7 +249,7 @@
$ref: '../components/schemas/valuesets.yaml#/ValueSetItem'
put:
tags: [ValueSets]
tags: [ValueSet]
summary: Update user value set item
description: Update value set item in database (user-defined)
security:
@ -298,7 +298,7 @@
$ref: '../components/schemas/valuesets.yaml#/ValueSetItem'
delete:
tags: [ValueSets]
tags: [ValueSet]
summary: Delete user value set item
description: Delete value set item from database (user-defined)
security:
@ -324,7 +324,7 @@
/api/valueset/user/def:
get:
tags: [ValueSets]
tags: [ValueSet]
summary: List user value set definitions
description: List value set definitions from database (user-defined)
security:
@ -372,7 +372,7 @@
type: integer
post:
tags: [ValueSets]
tags: [ValueSet]
summary: Create user value set definition
description: Create value set definition in database (user-defined)
security:
@ -410,7 +410,7 @@
/api/valueset/user/def/{id}:
get:
tags: [ValueSets]
tags: [ValueSet]
summary: Get user value set definition by ID
description: Get value set definition from database (user-defined)
security:
@ -435,7 +435,7 @@
$ref: '../components/schemas/valuesets.yaml#/ValueSetDef'
put:
tags: [ValueSets]
tags: [ValueSet]
summary: Update user value set definition
description: Update value set definition in database (user-defined)
security:
@ -478,7 +478,7 @@
$ref: '../components/schemas/valuesets.yaml#/ValueSetDef'
delete:
tags: [ValueSets]
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

@ -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

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

View File

@ -4,20 +4,27 @@ namespace Tests\Feature\PatVisit;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
use Tests\Support\Traits\CreatesPatients;
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",
"InternalPID"=> $this->createTestPatient(),
"EpisodeID"=> null,
"PatDiag"=> [
"DiagCode"=> null,

View File

@ -4,13 +4,20 @@ namespace Tests\Feature\PatVisit;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
use Tests\Support\Traits\CreatesPatients;
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)
*/
@ -18,7 +25,7 @@ class PatVisitDeleteTest extends CIUnitTestCase
{
// Create a visit first to delete
$createPayload = [
"InternalPID"=> "1",
"InternalPID"=> $this->createTestPatient(),
"EpisodeID"=> "TEST001",
"PatVisitADT"=> [
"ADTCode"=> "A01",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,11 @@ 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.
@ -17,7 +22,7 @@ class PatientUpdateTest extends CIUnitTestCase
public function testUpdatePatientValidationFail()
{
$payload = [ 'InternalPID' => null, 'NameFirst' => '' ]; // Tidak valid
$result = $this->withBodyFormat('json')->call('patch', $this->endpoint, $payload);
$result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload);
$result->assertStatus(400);
$json = $result->getJSON();
@ -34,7 +39,6 @@ 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##########'),
@ -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)
}
@ -66,7 +70,6 @@ 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,
@ -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);
@ -110,7 +113,6 @@ class PatientUpdateTest extends CIUnitTestCase
$faker = Factory::create('id_ID');
$payload = [
'InternalPID' => 1,
"PatientID" => "SMAJ1",
'NameFirst' => $faker->firstName,
'NameMiddle' => $faker->firstName,
@ -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);
}
@ -150,7 +152,6 @@ class PatientUpdateTest extends CIUnitTestCase
$faker = Factory::create('id_ID');
$payload = [
'InternalPID' => 1,
"PatientID" => "SMAJ1",
'NameFirst' => $faker->firstName,
'NameMiddle' => $faker->firstName,
@ -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);
}
@ -188,7 +189,6 @@ class PatientUpdateTest extends CIUnitTestCase
$faker = Factory::create('id_ID');
$payload = [
'InternalPID' => 1,
"PatientID" => "SMAJ1",
'NameFirst' => $faker->firstName,
'NameMiddle' => $faker->firstName,
@ -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);
}
@ -228,7 +228,6 @@ class PatientUpdateTest extends CIUnitTestCase
$faker = Factory::create('id_ID');
$payload = [
'InternalPID' => 1,
"PatientID" => "SMAJ1",
'NameFirst' => $faker->firstName,
'NameMiddle' => $faker->firstName,
@ -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

@ -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,9 +118,9 @@ class ValueSetTest extends CIUnitTestCase
public function testGetPatientSex()
{
$result = ValueSet::get('sex');
$this->assertEquals('1', $result[0]['value']);
$this->assertEquals('F', $result[0]['value']);
$this->assertEquals('Female', $result[0]['label']);
$this->assertEquals('2', $result[1]['value']);
$this->assertEquals('M', $result[1]['value']);
$this->assertEquals('Male', $result[1]['label']);
}
@ -270,9 +270,9 @@ class ValueSetTest extends CIUnitTestCase
public function testGetReturnsFormattedValues()
{
$result = ValueSet::get('sex');
$this->assertEquals('1', $result[0]['value']);
$this->assertEquals('F', $result[0]['value']);
$this->assertEquals('Female', $result[0]['label']);
$this->assertEquals('2', $result[1]['value']);
$this->assertEquals('M', $result[1]['value']);
$this->assertEquals('Male', $result[1]['label']);
}
@ -355,8 +355,8 @@ class ValueSetTest extends CIUnitTestCase
public function testTransformLabels()
{
$data = [
['Gender' => '1', 'Country' => 'IDN'],
['Gender' => '2', 'Country' => 'USA']
['Gender' => 'F', 'Country' => 'IDN'],
['Gender' => 'M', 'Country' => 'USA']
];
$result = ValueSet::transformLabels($data, [
@ -364,9 +364,9 @@ class ValueSetTest extends CIUnitTestCase
'Country' => 'country'
]);
$this->assertEquals('1', $result[0]['Gender']);
$this->assertEquals('F', $result[0]['Gender']);
$this->assertEquals('Female', $result[0]['GenderLabel']);
$this->assertEquals('2', $result[1]['Gender']);
$this->assertEquals('M', $result[1]['Gender']);
$this->assertEquals('Male', $result[1]['GenderLabel']);
$this->assertEquals('USA', $result[1]['Country']);
$this->assertEquals('United States of America', $result[1]['CountryLabel']);