Compare commits

..

1 Commits

Author SHA1 Message Date
jekkos
09191b9af7 feat: Add OpenAPI 3.1 specification for REST API
Add comprehensive OpenAPI specification for OSPOS REST API including:

- Customers API (CRUD operations)
- Suppliers API (CRUD operations)
- Items API (CRUD operations with inventory quantities)
- Inventory API (stock adjustments)
- Sales API (read-only queries)
- Receivings API (read-only queries)

Features:
- API Key authentication via X-API-Key header
- Pagination with offset/limit parameters
- Soft delete support for customers, suppliers, items
- Batch operations for delete and update
- Search/suggest endpoints for autocomplete
- Comprehensive schema definitions based on existing models

Includes documentation with:
- Endpoint reference tables
- Schema field descriptions
- Implementation notes and discussion topics
- HTTP status codes and response formats

This is a design proposal for discussion before implementation.
2026-03-06 10:27:11 +00:00
34 changed files with 2532 additions and 2826 deletions

View File

@@ -1,144 +0,0 @@
name: Integration Tests
on:
push:
branches: [ master, main ]
paths:
- 'app/**'
- 'public/**'
- 'docker/**'
- 'docker-compose*.yml'
- 'tests/**'
- 'integration-tests/**'
- '.github/workflows/integration-tests.yml'
pull_request:
branches: [ master, main ]
paths:
- 'app/**'
- 'public/**'
- 'docker/**'
- 'docker-compose*.yml'
- 'tests/**'
- 'integration-tests/**'
- '.github/workflows/integration-tests.yml'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
integration:
name: Docker Integration Tests
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run Basic Integration Tests
run: chmod +x integration-tests/run-integration-tests.sh && cd integration-tests && ./run-integration-tests.sh
- name: View Logs on Failure
if: failure()
run: |
echo "=== Application Logs ==="
docker logs opensourcepos-integration-tests-ospos-1
echo ""
echo "=== Database Logs ==="
docker logs mysql || docker logs opensourcepos-mysql || echo "No database logs found"
- name: Stop Docker Stack
if: always()
run: docker compose down -v || true
playwright:
name: Playwright E2E Tests
runs-on: ubuntu-22.04
needs: integration
if: always()
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache node modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium firefox
- name: Start Docker Stack
run: docker compose up -d
- name: Wait for Application
run: |
echo "Waiting for application to be ready..."
timeout 90 bash -c 'until curl -s -f http://localhost/ > /dev/null; do sleep 2; done'
echo "Application is ready!"
- name: Run Playwright Tests
run: npm run test
env:
BASE_URL: http://localhost
- name: Upload Playwright Report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: integration-tests/playwright-report/
retention-days: 7
- name: Upload Test Results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-results
path: integration-tests/test-results/
retention-days: 7
- name: Upload Screenshots on Failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-screenshots
path: integration-tests/test-results/**/*.png
retention-days: 7
- name: Upload Trace Files on Failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-traces
path: integration-tests/test-results/**/*.zip
retention-days: 7
- name: View Logs on Failure
if: failure()
run: |
echo "=== Application Logs ==="
docker logs opensourcepos-integration-tests-ospos-1
echo ""
echo "=== Database Logs ==="
docker logs mysql || docker logs opensourcepos-mysql || echo "No database logs found"
- name: Stop Docker Stack
if: always()
run: docker compose down -v || true

View File

@@ -111,15 +111,7 @@ jobs:
env:
CI_ENVIRONMENT: testing
MYSQL_HOST_NAME: 127.0.0.1
run: composer test -- --log-junit test-results/junit.xml
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results-php-${{ matrix.php-version }}
path: test-results/
retention-days: 30
run: composer test
- name: Stop MariaDB
if: always()

295
API.md Normal file
View File

@@ -0,0 +1,295 @@
# OSPOS REST API Design
This document describes the proposed REST API for Open Source Point of Sale (OSPOS).
## Overview
The OSPOS REST API provides programmatic access to:
- **Customers** - Full CRUD operations
- **Suppliers** - Full CRUD operations
- **Items** - Full CRUD operations
- **Inventory** - Stock adjustments (update only)
- **Sales** - Read-only queries
- **Receivings** - Read-only queries
## Authentication
All API endpoints require authentication via an API Key passed in the `X-API-Key` header.
```
X-API-Key: your-api-key-here
```
> **Note:** API Key authentication implementation will be added in a subsequent phase. The spec documents the intended authentication mechanism.
## Base URL
All API endpoints are relative to `/api/v1`.
```
https://your-domain.com/api/v1/customers
```
## Pagination
List endpoints support pagination using `offset` and `limit` query parameters:
| Parameter | Type | Default | Maximum | Description |
|-----------|---------|---------|---------|------------------------------|
| `offset` | integer | 0 | - | Number of records to skip |
| `limit` | integer | 25 | 100 | Number of records to return |
**Example Request:**
```
GET /api/v1/customers?offset=0&limit=25
```
**Example Response:**
```json
{
"total": 150,
"offset": 0,
"limit": 25,
"rows": [
{ "person_id": 1, "first_name": "John", ... },
{ "person_id": 2, "first_name": "Jane", ... }
]
}
```
## Response Format
### Success Response
```json
{
"success": true,
"message": "Customer created successfully",
"id": 42
}
```
### Error Response
```json
{
"success": false,
"message": "Error description here"
}
```
### HTTP Status Codes
| Status Code | Description |
|-------------|--------------------------------------------------|
| 200 | Success |
| 201 | Resource created successfully |
| 400 | Bad request / Invalid input |
| 401 | Unauthorized / Invalid API key |
| 404 | Resource not found |
| 409 | Conflict (e.g., duplicate unique field) |
| 500 | Internal server error |
## Endpoints Summary
### Customers
| Method | Endpoint | Description | Access |
|--------|-------------------------------|--------------------------------|---------|
| GET | `/customers` | List customers | Read |
| POST | `/customers` | Create customer | Write |
| GET | `/customers/{id}` | Get customer by ID | Read |
| PUT | `/customers/{id}` | Update customer | Write |
| DELETE | `/customers/{id}` | Delete customer (soft delete) | Write |
| POST | `/customers/batch-delete` | Delete multiple customers | Write |
| GET | `/customers/suggest` | Autocomplete suggestions | Read |
### Suppliers
| Method | Endpoint | Description | Access |
|--------|-------------------------------|--------------------------------|---------|
| GET | `/suppliers` | List suppliers | Read |
| POST | `/suppliers` | Create supplier | Write |
| GET | `/suppliers/{id}` | Get supplier by ID | Read |
| PUT | `/suppliers/{id}` | Update supplier | Write |
| DELETE | `/suppliers/{id}` | Delete supplier (soft delete) | Write |
| POST | `/suppliers/batch-delete` | Delete multiple suppliers | Write |
| GET | `/suppliers/suggest` | Autocomplete suggestions | Read |
### Items
| Method | Endpoint | Description | Access |
|--------|-------------------------------|--------------------------------|---------|
| GET | `/items` | List items | Read |
| POST | `/items` | Create item | Write |
| GET | `/items/{id}` | Get item by ID | Read |
| PUT | `/items/{id}` | Update item | Write |
| DELETE | `/items/{id}` | Delete item (soft delete) | Write |
| POST | `/items/batch-delete` | Delete multiple items | Write |
| POST | `/items/batch-update` | Update multiple items | Write |
| GET | `/items/suggest` | Autocomplete suggestions | Read |
| GET | `/items/{id}/quantities` | Get stock quantities | Read |
### Inventory
| Method | Endpoint | Description | Access |
|--------|-------------------------------|--------------------------------|---------|
| GET | `/inventory` | List inventory transactions | Read |
| POST | `/inventory` | Create inventory adjustment | Write |
| POST | `/inventory/bulk` | Bulk inventory adjustments | Write |
### Sales (Read-Only)
| Method | Endpoint | Description | Access |
|--------|-------------------------------|--------------------------------|---------|
| GET | `/sales` | List sales | Read |
| GET | `/sales/{id}` | Get sale details | Read |
| GET | `/sales/{id}/items` | Get sale items | Read |
| GET | `/sales/{id}/payments` | Get sale payments | Read |
### Receivings (Read-Only)
| Method | Endpoint | Description | Access |
|--------|-------------------------------|--------------------------------|---------|
| GET | `/receivings` | List receivings | Read |
| GET | `/receivings/{id}` | Get receiving details | Read |
| GET | `/receivings/{id}/items` | Get receiving items | Read |
## Schema Reference
### Common Fields
#### Person Fields (base for Customer, Supplier)
| Field | Type | Description |
|---------------|-----------|------------------------------|
| `first_name` | string | First name (required) |
| `last_name` | string | Last name (required) |
| `gender` | integer | Gender (0=male, 1=female) |
| `phone_number`| string | Phone number |
| `email` | string | Email address |
| `address_1` | string | Address line 1 |
| `address_2` | string | Address line 2 |
| `city` | string | City |
| `state` | string | State/Province |
| `zip` | string | Postal/ZIP code |
| `country` | string | Country |
| `comments` | string | Additional notes |
### Customer Fields
Extends Person fields with:
| Field | Type | Description |
|--------------------|-----------|------------------------------------|
| `person_id` | integer | Unique identifier (read-only) |
| `account_number` | string | Customer account number |
| `taxable` | integer | Taxable status (0/1) |
| `tax_id` | string | Tax identification number |
| `sales_tax_code_id`| integer | Sales tax code ID |
| `discount` | decimal | Discount percentage/amount |
| `discount_type` | integer | Discount type (0=percent, 1=fixed) |
| `company_name` | string | Company name |
| `package_id` | integer | Rewards package ID |
| `points` | integer | Rewards points balance |
| `consent` | integer | Consent status (0/1) |
### Supplier Fields
Extends Person fields with:
| Field | Type | Description |
|-----------------|-----------|-------------------------------------|
| `person_id` | integer | Unique identifier (read-only) |
| `company_name` | string | Company name |
| `account_number`| string | Supplier account number |
| `tax_id` | string | Tax identification number |
| `agency_name` | string | Agency name |
| `category` | integer | Category (0=goods, 1=cost) |
### Item Fields
| Field | Type | Required | Description |
|----------------------|-----------|----------|--------------------------------------|
| `item_id` | integer | auto | Unique identifier (read-only) |
| `name` | string | yes | Item name |
| `category` | string | yes | Item category |
| `supplier_id` | integer | no | Supplier ID |
| `item_number` | string | no | Barcode/SKU |
| `description` | string | no | Item description |
| `cost_price` | decimal | no | Cost price |
| `unit_price` | decimal | yes | Selling price |
| `reorder_level` | decimal | no | Reorder threshold |
| `receiving_quantity` | decimal | no | Receiving quantity (default 1) |
| `allow_alt_description`| integer | no | Allow alt description (0/1) |
| `is_serialized` | integer | no | Has serial number (0/1) |
| `stock_type` | integer | no | Stock type (0=stocked, 1=non-stocked)|
| `item_type` | integer | no | Item type (0=standard, 1=kit, 2=temp)|
| `tax_category_id` | integer | no | Tax category ID |
| `qty_per_pack` | decimal | no | Quantity per pack |
| `pack_name` | string | no | Pack name |
| `hsn_code` | string | no | HSN code |
### Inventory Adjustment
| Field | Type | Required | Description |
|------------------|-----------|----------|--------------------------------------|
| `item_id` | integer | yes | Item ID to adjust |
| `trans_inventory`| decimal | yes | Quantity change (+ add, - remove) |
| `trans_location` | integer | no | Stock location ID |
| `trans_comment` | string | no | Reason for adjustment |
## OpenAPI Specification
The complete OpenAPI 3.1.0 specification is available at:
- **YAML format:** `/public/api/openapi.yaml`
This specification can be used with:
- [Swagger UI](https://swagger.io/tools/swagger-ui/) for interactive documentation
- [Swagger Codegen](https://swagger.io/tools/swagger-codegen/) to generate client SDKs
- [OpenAPI Generator](https://openapi-generator.tech/) for code generation
- API testing tools like Postman or Insomnia
## Implementation Notes
### Phase 1: Core Endpoints (Proposed)
1. Customers API (full CRUD)
2. Suppliers API (full CRUD)
3. Items API (full CRUD)
4. Inventory adjustments API (create only)
### Phase 2: Read-Only Endpoints (Proposed)
1. Sales API (read-only)
2. Receivings API (read-only)
### Phase 3: Extended Features (Future)
1. Batch operations for all endpoints
2. Search/filter capabilities
3. Authorization/permissions integration
4. Rate limiting
5. API key management interface
## Discussion Topics
The following aspects of the API design are open for discussion:
1. **Field naming conventions**: Currently following existing database column names. Should we use camelCase for JSON?
2. **Batch operations**: Current design separates batch-delete and batch-update. Should we consolidate?
3. **Date formats**: Using ISO 8601 (date-time). Is timezone handling needed?
4. **Error response structure**: Current format uses `{success, message}`. Should we include error codes?
5. **Relationship representations**: Should nested resources (e.g., sale items) always be included?
6. **Inventory adjustments**: Should we support setting absolute quantities vs. relative changes?
7. **Authorization integration**: How should API access integrate with existing employee permissions?
8. **Stock locations**: Multiple locations per item - do we need location-specific endpoints?

View File

@@ -169,8 +169,3 @@ const MAX_PRECISION = 1e14;
const DEFAULT_PRECISION = 2;
const DEFAULT_LANGUAGE = 'english';
const DEFAULT_LANGUAGE_CODE = 'en';
/**
* Admin modules - list of modules required for admin privileges
*/
const ADMIN_MODULES = ['customers', 'employees', 'giftcards', 'items', 'item_kits', 'messages', 'receivings', 'reports', 'sales', 'config', 'suppliers'];

View File

@@ -461,9 +461,8 @@ class Config extends Secure_Controller
public function postSaveLocale(): ResponseInterface
{
$exploded = explode(":", $this->request->getPost('language'));
$currency_symbol = $this->request->getPost('currency_symbol');
$batch_save_data = [
'currency_symbol' => htmlspecialchars($currency_symbol ?? ''),
'currency_symbol' => $this->request->getPost('currency_symbol'),
'currency_code' => $this->request->getPost('currency_code'),
'language_code' => $exploded[0],
'language' => $exploded[1],
@@ -943,9 +942,7 @@ class Config extends Secure_Controller
'work_order_enable' => $this->request->getPost('work_order_enable') != null,
'work_order_format' => $this->request->getPost('work_order_format'),
'last_used_work_order_number' => $this->request->getPost('last_used_work_order_number', FILTER_SANITIZE_NUMBER_INT),
'invoice_type' => Sale_lib::isValidInvoiceType($this->request->getPost('invoice_type'))
? $this->request->getPost('invoice_type')
: 'invoice'
'invoice_type' => $this->request->getPost('invoice_type')
];
$success = $this->appconfig->batch_save($batch_save_data);

View File

@@ -78,7 +78,7 @@ class Employees extends Persons
$person_info = $this->employee->get_info($employee_id);
$current_user = $this->employee->get_logged_in_employee_info();
if ($employee_id != NEW_ENTRY && !$this->employee->canModifyEmployee($person_info->person_id, $current_user->person_id)) {
if ($employee_id != NEW_ENTRY && !$this->employee->can_modify_employee($person_info->person_id, $current_user->person_id)) {
header('Location: ' . base_url('no_access/employees/employees'));
exit();
}
@@ -120,7 +120,7 @@ class Employees extends Persons
if ($employee_id != NEW_ENTRY) {
$target_employee = $this->employee->get_info($employee_id);
if (!$this->employee->canModifyEmployee($target_employee->person_id, $current_user->person_id)) {
if (!$this->employee->can_modify_employee($target_employee->person_id, $current_user->person_id)) {
return $this->response->setJSON([
'success' => false,
'message' => lang('Employees.error_updating_admin'),
@@ -153,14 +153,14 @@ class Employees extends Persons
];
$grants_array = [];
$isAdmin = $this->employee->isAdmin($current_user->person_id);
$is_admin = $this->employee->is_admin($current_user->person_id);
foreach ($this->module->get_all_permissions()->getResult() as $permission) {
$grants = [];
$grant = $this->request->getPost('grant_' . $permission->permission_id) != null ? $this->request->getPost('grant_' . $permission->permission_id, FILTER_SANITIZE_FULL_SPECIAL_CHARS) : '';
if ($grant == $permission->permission_id) {
if (!$isAdmin && !$this->employee->has_grant($permission->permission_id, $current_user->person_id)) {
if (!$is_admin && !$this->employee->has_grant($permission->permission_id, $current_user->person_id)) {
continue;
}
$grants['permission_id'] = $permission->permission_id;
@@ -226,9 +226,9 @@ class Employees extends Persons
$employees_to_delete = $this->request->getPost('ids', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$current_user = $this->employee->get_logged_in_employee_info();
if (!$this->employee->isAdmin($current_user->person_id)) {
if (!$this->employee->is_admin($current_user->person_id)) {
foreach ($employees_to_delete as $emp_id) {
if ($this->employee->isAdmin((int)$emp_id)) {
if ($this->employee->is_admin((int)$emp_id)) {
return $this->response->setJSON(['success' => false, 'message' => lang('Employees.error_deleting_admin')]);
}
}

View File

@@ -39,19 +39,9 @@ class Home extends Secure_Controller
* @return string
* @noinspection PhpUnused
*/
public function getChangePassword(int $employeeId = NEW_ENTRY): string
public function getChangePassword(int $employee_id = -1): string // TODO: Replace -1 with a constant
{
$loggedInEmployee = $this->employee->get_logged_in_employee_info();
$currentPersonId = $loggedInEmployee->person_id;
$employeeId = $employeeId === NEW_ENTRY ? $currentPersonId : $employeeId;
if (!$this->employee->can_modify_employee($employeeId, $currentPersonId)) {
header('Location: ' . base_url('no_access/home/home'));
exit();
}
$person_info = $this->employee->get_info($employeeId);
$person_info = $this->employee->get_info($employee_id);
foreach (get_object_vars($person_info) as $property => $value) {
$person_info->$property = $value;
}
@@ -65,20 +55,9 @@ class Home extends Secure_Controller
*
* @return ResponseInterface
*/
public function postSave(int $employeeId = NEW_ENTRY): ResponseInterface
public function postSave(int $employee_id = -1): ResponseInterface // TODO: Replace -1 with a constant
{
$currentUser = $this->employee->get_logged_in_employee_info();
$employeeId = $employeeId === NEW_ENTRY ? $currentUser->person_id : $employeeId;
if (!$this->employee->can_modify_employee($employeeId, $currentUser->person_id)) {
return $this->response->setStatusCode(403)->setJSON([
'success' => false,
'message' => lang('Employees.unauthorized_modify')
]);
}
if (!empty($this->request->getPost('current_password')) && $employeeId != NEW_ENTRY) {
if (!empty($this->request->getPost('current_password')) && $employee_id != -1) {
if ($this->employee->check_password($this->request->getPost('username', FILTER_SANITIZE_FULL_SPECIAL_CHARS), $this->request->getPost('current_password'))) {
// Validate password length BEFORE hashing
$new_password = $this->request->getPost('password');
@@ -87,7 +66,7 @@ class Home extends Secure_Controller
return $this->response->setJSON([
'success' => false,
'message' => lang('Employees.password_minlength'),
'id' => NEW_ENTRY
'id' => -1
]);
}
@@ -97,32 +76,32 @@ class Home extends Secure_Controller
'hash_version' => 2
];
if ($this->employee->change_password($employee_data, $employeeId)) {
if ($this->employee->change_password($employee_data, $employee_id)) {
return $this->response->setJSON([
'success' => true,
'message' => lang('Employees.successful_change_password'),
'id' => $employeeId
'id' => $employee_id
]);
} else {
} else { // Failure // TODO: Replace -1 with constant
return $this->response->setJSON([
'success' => false,
'message' => lang('Employees.unsuccessful_change_password'),
'id' => NEW_ENTRY
'id' => -1
]);
}
} else {
} else { // TODO: Replace -1 with constant
return $this->response->setJSON([
'success' => false,
'message' => lang('Employees.current_password_invalid'),
'id' => NEW_ENTRY
'id' => -1
]);
}
} else {
} else { // TODO: Replace -1 with constant
return $this->response->setJSON([
'success' => false,
'message' => lang('Employees.current_password_invalid'),
'id' => NEW_ENTRY
'id' => -1
]);
}
}
}
}

View File

@@ -96,7 +96,7 @@ class Items extends Secure_Controller
**/
public function getSearch(): ResponseInterface
{
$search = $this->request->getGet('search', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$search = $this->request->getGet('search');
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
$offset = $this->request->getGet('offset', FILTER_SANITIZE_NUMBER_INT);
$sort = $this->sanitizeSortColumn(item_headers(), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'item_id');
@@ -148,7 +148,6 @@ class Items extends Secure_Controller
{
helper('file');
$pic_filename = rawurldecode($pic_filename);
$file_extension = pathinfo($pic_filename, PATHINFO_EXTENSION);
$images = glob("./uploads/item_pics/$pic_filename");
$base_path = './uploads/item_pics/' . pathinfo($pic_filename, PATHINFO_FILENAME);
@@ -378,7 +377,7 @@ class Items extends Secure_Controller
} else {
$images = glob("./uploads/item_pics/$item_info->pic_filename");
}
$data['image_path'] = sizeof($images) > 0 ? base_url(implode('/', array_map('rawurlencode', explode('/', ltrim($images[0], './'))))) : '';
$data['image_path'] = sizeof($images) > 0 ? base_url($images[0]) : '';
} else {
$data['image_path'] = '';
}
@@ -618,7 +617,7 @@ class Items extends Secure_Controller
// Save item data
$item_data = [
'name' => $this->request->getPost('name'),
'description' => $this->request->getPost('description', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
'description' => $this->request->getPost('description'),
'category' => $this->request->getPost('category'),
'item_type' => $item_type,
'stock_type' => $this->request->getPost('stock_type') === null ? HAS_STOCK : intval($this->request->getPost('stock_type')),
@@ -769,13 +768,10 @@ class Items extends Secure_Controller
$filename = $file->getClientName();
$info = pathinfo($filename);
// Sanitize filename to remove problematic characters like spaces
$sanitized_name = preg_replace('/[^a-zA-Z0-9_\-\.]/', '_', $info['filename']);
$file_info = [
'orig_name' => $filename,
'raw_name' => $sanitized_name,
'raw_name' => $info['filename'],
'file_ext' => $file->guessExtension()
];
@@ -1021,11 +1017,7 @@ class Items extends Secure_Controller
}
if (!$is_failed_row) {
$invalidLocations = $this->validateCSVStockLocations($row, $allowedStockLocations);
if (!empty($invalidLocations)) {
$isFailedRow = true;
log_message('error', 'CSV import: Invalid stock location(s) found: ' . implode(', ', $invalidLocations));
}
$is_failed_row = $this->data_error_check($row, $item_data, $allowed_stock_locations, $attribute_definition_names, $attribute_data);
}
// Remove false, null, '' and empty strings but keep 0
@@ -1071,30 +1063,6 @@ class Items extends Secure_Controller
}
/**
* Validates that stock location columns in CSV row are valid locations
*
* @param array $row
* @param array $allowedLocations
* @return array Returns array of invalid location names, empty if all valid
*/
private function validateCSVStockLocations(array $row, array $allowedLocations): array
{
$invalidLocations = [];
$allowedLocationNames = array_values($allowedLocations);
foreach (array_keys($row) as $key) {
if (str_starts_with($key, 'location_')) {
$locationName = substr($key, 9);
if (!in_array($locationName, $allowedLocationNames)) {
$invalidLocations[] = $locationName;
}
}
}
return $invalidLocations;
}
/**
* Checks the entire line of data in an import file for errors
*

View File

@@ -755,11 +755,8 @@ class Sales extends Secure_Controller
$data['sale_status'] = COMPLETED;
$sale_type = SALE_TYPE_INVOICE;
$invoice_type = $this->config['invoice_type'];
if (!Sale_lib::isValidInvoiceType($invoice_type)) {
$invoice_type = 'invoice';
}
$invoice_view = $invoice_type;
// The PHP file name is the same as the invoice_type key
$invoice_view = $this->config['invoice_type'];
// Save the data to the sales table
$data['sale_id_num'] = $this->sale->save_value($sale_id, $data['sale_status'], $data['cart'], $customer_id, $employee_id, $data['comments'], $invoice_number, $work_order_number, $quote_number, $sale_type, $data['payments'], $data['dinner_table'], $tax_details);
@@ -1110,9 +1107,6 @@ class Sales extends Secure_Controller
}
$invoice_type = $this->config['invoice_type'];
if (!Sale_lib::isValidInvoiceType($invoice_type)) {
$invoice_type = 'invoice';
}
$data['invoice_view'] = $invoice_type;
return $data;

View File

@@ -267,8 +267,6 @@ class Migration_Sales_Tax_Data extends Migration
*/
public function round_number(int $rounding_mode, string $amount, int $decimals): float
{
$amount = (float)$amount;
if ($rounding_mode == Migration_Sales_Tax_Data::ROUND_UP) {
$fig = pow(10, $decimals);
$rounded_total = (ceil($fig * $amount) + ceil($fig * $amount - ceil($fig * $amount))) / $fig;
@@ -378,7 +376,7 @@ class Migration_Sales_Tax_Data extends Migration
$decimals = totals_decimals();
foreach ($sales_taxes as $row_number => $sales_tax) {
$sale_tax_amount = (float)$sales_tax['sale_tax_amount'];
$sale_tax_amount = $sales_tax['sale_tax_amount'];
$rounding_code = $sales_tax['rounding_code'];
$rounded_sale_tax_amount = $sale_tax_amount;

View File

@@ -243,8 +243,6 @@ class Migration_TaxAmount extends Migration
*/
public function round_number(int $rounding_mode, string $amount, int $decimals): float // TODO: is this currency safe?
{ // TODO: This needs to be converted to a switch
$amount = (float)$amount;
if ($rounding_mode == Migration_TaxAmount::ROUND_UP) { // TODO: === ?
$fig = pow(10, $decimals);
$rounded_total = (ceil($fig * $amount) + ceil($fig * $amount - ceil($fig * $amount))) / $fig;
@@ -356,7 +354,7 @@ class Migration_TaxAmount extends Migration
$decimals = totals_decimals();
foreach ($sales_taxes as $row_number => $sales_tax) {
$sale_tax_amount = (float)$sales_tax['sale_tax_amount'];
$sale_tax_amount = $sales_tax['sale_tax_amount'];
$rounding_code = $sales_tax['rounding_code'];
$rounded_sale_tax_amount = $sale_tax_amount;

View File

@@ -1,65 +0,0 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
/**
* Migration to sanitize existing image filenames by replacing spaces with underscores
* This fixes issue #4372 where thumbnails failed to load for images with spaces in filenames
*/
class FixImageFilenameSpaces extends Migration
{
/**
* Perform a migration.
*/
public function up(): void
{
$db = \Config\Database::connect();
$builder = $db->table('ospos_items');
// Get all items with pic_filename containing spaces
$query = $builder->like('pic_filename', ' ', 'both')->get();
$items = $query->getResult();
foreach ($items as $item) {
$old_filename = $item->pic_filename;
$ext = pathinfo($old_filename, PATHINFO_EXTENSION);
$base_name = pathinfo($old_filename, PATHINFO_FILENAME);
// Sanitize the filename by replacing spaces and special characters
$sanitized_name = preg_replace('/[^a-zA-Z0-9_\-\.]/', '_', $base_name);
$new_filename = $sanitized_name . '.' . $ext;
// Rename the file on the filesystem
$old_path = FCPATH . 'uploads/item_pics/' . $old_filename;
$new_path = FCPATH . 'uploads/item_pics/' . $new_filename;
if (file_exists($old_path)) {
// Rename the original file
if (rename($old_path, $new_path)) {
// Check if thumbnail exists and rename it too
$old_thumb = FCPATH . 'uploads/item_pics/' . $base_name . '_thumb.' . $ext;
$new_thumb = FCPATH . 'uploads/item_pics/' . $sanitized_name . '_thumb.' . $ext;
if (file_exists($old_thumb)) {
rename($old_thumb, $new_thumb);
}
// Update database record
$builder->where('item_id', $item->item_id)
->update(['pic_filename' => $new_filename]);
}
}
}
}
/**
* Revert a migration.
* Note: This migration does not support rollback as the original filenames are lost
*/
public function down(): void
{
// This migration cannot be safely reversed as the original filenames are lost
// after sanitization.
}
}

View File

@@ -48,7 +48,7 @@ function transform_headers(array $headers, bool $readonly = false, bool $editabl
'field' => key($element),
'title' => current($element),
'switchable' => $element['switchable'] ?? !preg_match('(^$|&nbsp)', current($element)),
'escape' => !preg_match("/(edit|email|messages|item_pic)/", key($element)) && !(isset($element['escape']) && !$element['escape']),
'escape' => !preg_match("/(edit|email|messages|item_pic|customer_name|note)/", key($element)) && !(isset($element['escape']) && !$element['escape']),
'sortable' => $element['sortable'] ?? current($element) != '',
'checkbox' => $element['checkbox'] ?? false,
'class' => isset($element['checkbox']) || preg_match('(^$|&nbsp)', current($element)) ? 'print_hide' : '',
@@ -470,8 +470,7 @@ function get_item_data_row(object $item): array
: glob("./uploads/item_pics/$item->pic_filename");
if (sizeof($images) > 0) {
$image_path = ltrim($images[0], './');
$image .= '<a class="rollover" href="' . base_url(implode('/', array_map('rawurlencode', explode('/', $image_path)))) . '"><img alt="Image thumbnail" src="' . site_url('items/PicThumb/' . rawurlencode(pathinfo($images[0], PATHINFO_BASENAME))) . '"></a>';
$image .= '<a class="rollover" href="' . base_url($images[0]) . '"><img alt="Image thumbnail" src="' . site_url('items/PicThumb/' . pathinfo($images[0], PATHINFO_BASENAME)) . '"></a>';
}
}

View File

@@ -26,7 +26,6 @@ return [
"cost_price_required" => "Wholesale Price is a required field.",
"count" => "Update Inventory",
"csv_import_failed" => "CSV import failed",
"csv_import_invalid_location" => "Invalid stock location(s) found: {0}. Only valid stock locations are allowed.",
"csv_import_nodata_wrongformat" => "The uploaded CSV file has no data or is formatted incorrectly.",
"csv_import_partially_failed" => "There were {0} item import failure(s) on line(s): {1}. No rows were imported.",
"csv_import_success" => "Item CSV import successful.",

View File

@@ -88,13 +88,9 @@ class Sale_lib
return $register_modes;
}
private const ALLOWED_INVOICE_TYPES = [
'invoice',
'tax_invoice',
'custom_invoice',
'custom_tax_invoice'
];
/**
* @return array
*/
public function get_invoice_type_options(): array
{
$invoice_types = [];
@@ -105,11 +101,6 @@ class Sale_lib
return $invoice_types;
}
public static function isValidInvoiceType(string $invoice_type): bool
{
return in_array($invoice_type, self::ALLOWED_INVOICE_TYPES, true);
}
/**
* @return array
*/

View File

@@ -539,13 +539,15 @@ class Employee extends Person
* Checks if the employee has admin privileges (all module permissions).
* The first employee (person_id = 1) is considered admin by default.
*/
public function isAdmin(int $person_id): bool
public function is_admin(int $person_id): bool
{
if ($person_id === 1) {
return true;
}
foreach (ADMIN_MODULES as $module) {
$modules = ['customers', 'employees', 'giftcards', 'items', 'item_kits', 'messages', 'receivings', 'reports', 'sales', 'config', 'suppliers'];
foreach ($modules as $module) {
if (!$this->has_grant($module, $person_id)) {
return false;
}
@@ -559,13 +561,13 @@ class Employee extends Person
* Only admins can modify other admin accounts.
* Users cannot modify their own grants unless they are admin.
*/
public function canModifyEmployee(int $target_person_id, int $current_person_id): bool
public function can_modify_employee(int $target_person_id, int $current_person_id): bool
{
if ($target_person_id === $current_person_id) {
return !$this->isAdmin($target_person_id) || $this->isAdmin($current_person_id);
return !$this->is_admin($target_person_id) || $this->is_admin($current_person_id);
}
if ($this->isAdmin($target_person_id) && !$this->isAdmin($current_person_id)) {
if ($this->is_admin($target_person_id) && !$this->is_admin($current_person_id)) {
return false;
}

View File

@@ -199,9 +199,9 @@ class Item extends Model
if (!empty($search)) {
if ($attributes_enabled && $filters['search_custom']) {
$builder->having("attribute_values LIKE :search:", ['search' => "%$search%"]);
$builder->orHaving("attribute_dtvalues LIKE :search_dt:", ['search_dt' => "%$search%"]);
$builder->orHaving("attribute_dvalues LIKE :search_d:", ['search_d' => "%$search%"]);
$builder->having("attribute_values LIKE '%$search%'");
$builder->orHaving("attribute_dtvalues LIKE '%$search%'");
$builder->orHaving("attribute_dvalues LIKE '%$search%'");
} else {
$builder->groupStart();
$builder->like('name', $search);

View File

@@ -28,10 +28,9 @@ class Summary_discounts extends Summary_report
$builder = $this->db->table('sales_items AS sales_items');
if ($inputs['discount_type'] == FIXED) {
$currency_symbol = $this->db->escape($config['currency_symbol']);
$builder->select('SUM(sales_items.discount) AS total, MAX(CONCAT(' . $currency_symbol . ', sales_items.discount)) AS discount, count(*) AS count');
$builder->select('SUM(sales_items.discount) AS total, MAX(CONCAT("' . $config['currency_symbol'] . '",sales_items.discount)) AS discount, count(*) AS count');
$builder->where('discount_type', FIXED);
} elseif ($inputs['discount_type'] == PERCENT) {
} elseif ($inputs['discount_type'] == PERCENT) { // TODO: === ?
$builder->select('SUM(item_unit_price) * sales_items.discount / 100.0 AS total, MAX(CONCAT(sales_items.discount, "%")) AS discount, count(*) AS count');
$builder->where('discount_type', PERCENT);
}

View File

@@ -142,7 +142,7 @@
<li>
<?= form_checkbox("grant_$permission->permission_id", $permission->permission_id, $permission->grant == 1) ?>
<?= form_hidden("menu_group_$permission->permission_id", "--") ?>
<span class="medium"><?= esc($lang_line) ?></span>
<span class="medium"><?= $lang_line ?></span>
</li>
</ul>
<?php

View File

@@ -215,15 +215,15 @@ if (isset($success)) {
'class' => 'form-control input-sm',
'value' => $item['description']
]);
} else {
if ($item['description'] != '') { // TODO: !==?
echo $item['description'];
echo form_hidden('description', $item['description']);
} else {
if ($item['description'] != '') { // TODO: !==?
echo esc($item['description']);
echo form_hidden('description', $item['description']);
} else {
echo '<i>' . lang('Sales.no_description') . '</i>';
echo form_hidden('description', '');
}
echo '<i>' . lang('Sales.no_description') . '</i>';
echo form_hidden('description', '');
}
}
?>
</td>
<td colspan="7"></td>

View File

@@ -1,137 +0,0 @@
# Integration Tests for Open Source POS
This directory contains integration tests for Open Source POS using Docker Compose and Playwright.
## Test Suites
### 1. Basic Integration Tests (`run-integration-tests.sh`)
Simple HTTP-based tests that verify the application is running and accessible.
**Tests:**
- Application startup
- Login page accessibility
- HTTP status code validation
- Login form presence
- Database connectivity (indirect)
### 2. Playwright E2E Tests (`tests/`)
Full browser automation tests using Playwright.
#### Login Tests (`tests/login.spec.ts`)
- Display login page
- Login with valid credentials
- Reject invalid credentials
- Redirect protected pages to login
- Console error detection
#### Item/Inventory Tests (`tests/items.spec.ts`)
- Create new item with basic details
- Create item with category selection
- Update existing item
- Verify items appear in inventory table
#### Customer Tests (`tests/customers.spec.ts`)
- Create new customer with basic details
- Create customer with complete address information
- Search for existing customers
- Verify customer details in table format
#### Sales Tests (`tests/sales.spec.ts`)
- Create sale with item and customer
- Add payment to sale
- Complete sale transaction
- Verify receipt generation
- Multi-item sale scenarios
- Different payment methods (cash)
- Receipt validation and display
#### Combined Operations
- Create item and customer sequentially
- Verify both entities appear in their respective tables
## Prerequisites
- Docker and Docker Compose
- Node.js 18+
- npm
## Local Setup
1. Install Node.js dependencies:
```bash
npm install
```
2. Install Playwright browsers:
```bash
npx playwright install --with-deps chromium firefox
```
## Running Tests
### Basic Integration Tests
```bash
chmod +x run-integration-tests.sh
./run-integration-tests.sh
```
### Playwright Tests
```bash
npm test
```
Run with UI:
```bash
npm run test:ui
```
Run with headed browser:
```bash
npm run test:headed
```
Debug mode:
```bash
npm run test:debug
```
## GitHub Actions
The CI pipeline runs both test suites on push/PR to master:
1. **Integration Job**: Basic Docker stack tests
2. **Playwright Job**: Full browser automation tests
Artifacts uploaded on failure:
- Playwright HTML report
- Test screenshots
- Trace files
- Docker container logs
## Test Results
- Playwright HTML reports: `playwright-report/`
- Test results: `test-results/`
- Screenshots (on failure): `test-results/**/*.png`
- Traces (on failure): `test-results/**/*.zip`
## Environment Variables
- `BASE_URL`: Application base URL (default: http://localhost)
## Clean Up
Stop and clean Docker resources:
```bash
docker compose down -v
```
## Note on Local Playwright Setup
Playwright requires system dependencies to be installed. If you don't have sudo access, you can:
1. Use CI environment (GitHub Actions)
2. Run Playwright tests in Docker container with proper permissions
3. Use the basic integration tests instead

View File

@@ -1,32 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
outputDir: './test-results',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: [
['html', { outputFolder: './playwright-report' }],
['junit', { outputFile: './test-results/junit.xml' }],
['list']
],
use: {
baseURL: 'http://localhost',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
],
});

View File

@@ -1,96 +0,0 @@
#!/bin/bash
set -e
echo "=== Open Source POS Integration Tests ==="
echo ""
# Start Docker Stack
echo "1. Starting Docker Stack..."
docker compose up -d
echo "2. Waiting for application to be ready..."
timeout=60
elapsed=0
while [ $elapsed -lt $timeout ]; do
if curl -s -f http://localhost/ > /dev/null 2>&1; then
echo " ✓ Application is ready!"
break
fi
sleep 2
elapsed=$((elapsed + 2))
if [ $elapsed -eq $timeout ]; then
echo " ✗ Application not ready after ${timeout}s"
echo " === Logs ==="
docker logs opensourcepos-integration-tests-ospos-1
docker logs mysql
exit 1
fi
echo " Waiting... (${elapsed}s)"
done
# Check Login Page
echo ""
echo "3. Checking Login Page..."
response=$(curl -s http://localhost/)
if echo "$response" | grep -q "Open Source Point of Sale"; then
echo " ✓ Login page accessible"
else
echo " ✗ Login page not accessible"
exit 1
fi
if echo "$response" | grep -q "Login"; then
echo " ✓ Login form found"
else
echo " ✗ Login form not found"
exit 1
fi
if echo "$response" | grep -q "username"; then
echo " ✓ Username field found"
else
echo " ✗ Username field not found"
exit 1
fi
if echo "$response" | grep -q "password"; then
echo " ✓ Password field found"
else
echo " ✗ Password field not found"
exit 1
fi
# Check HTTP Status
echo ""
echo "4. Checking HTTP Status..."
status=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/)
if [ "$status" -eq 200 ]; then
echo " ✓ HTTP status: $status"
else
echo " ✗ HTTP status: $status"
exit 1
fi
# Database Check
echo ""
echo "5. Checking Database Connection..."
db_logs=$(docker logs opensourcepos-integration-tests-ospos-1 2>&1)
if echo "$db_logs" | grep -qi "database.*connected\|mysql.*connected\|mysqli.*connected"; then
echo " ✓ Database connected"
else
echo " ⚠ Database connection status unclear (checking if app is responding)"
if curl -s -f http://localhost/ > /dev/null; then
echo " ✓ Application responding to requests"
fi
fi
echo ""
echo "=== All Tests Passed! ✓ ==="
# Cleanup
echo ""
echo "6. Stopping Docker Stack..."
docker compose down -v
exit 0

View File

@@ -1,273 +0,0 @@
import { test, expect } from '@playwright/test';
test.describe('Open Source POS - Customers', () => {
test.beforeEach(async ({ page }) => {
// Navigate to login page
await page.goto('/');
// Login with admin credentials
await page.fill('input[name="username"]', 'admin');
await page.fill('input[name="password"]', 'pointofsale');
await page.click('button[type="submit"], input[type="submit"]');
// Wait for navigation to complete
await page.waitForLoadState('networkidle');
// Check for login errors
const errorMessage = page.locator('.alert-danger, .error, .alert[role="alert"]');
const errorCount = await errorMessage.count();
if (errorCount > 0) {
const errorText = await errorMessage.first().textContent();
console.log('Login error:', errorText);
throw new Error(`Login failed: ${errorText}`);
}
test.setTimeout(60000);
});
test('should create a customer and verify it appears in table', async ({ page }) => {
// Navigate to customers page
const customersLink = page.locator('a[href*="customers"], a:has-text("Customer")');
await customersLink.first().click();
await page.waitForLoadState('networkidle');
// Look for "Add Customer" or "New Customer" button
const addCustomerButton = page.locator('button:has-text("Add"), button:has-text("New"), a:has-text("Add")');
await addCustomerButton.first().click();
await page.waitForLoadState('networkidle');
// Fill in customer details
const firstName = 'John';
const lastName = `Test ${Date.now()}`;
const email = `test.${Date.now()}@example.com`;
const phone = `+1-555-${Math.floor(Math.random() * 9000) + 1000}`;
await page.fill('input[name="first_name"], input[name="first"], #first_name', firstName);
await page.fill('input[name="last_name"], input[name="last"], #last_name', lastName);
await page.fill('input[name="email"], #email', email);
await page.fill('input[name="phone_number"], input[name="phone"], #phone_number, #phone', phone);
// Fill address if fields exist
const addressField = page.locator('input[name*="address"]').first();
const addressCount = await addressField.count();
if (addressCount > 0) {
await addressField.fill('123 Test Street');
}
const cityField = page.locator('input[name="city"], #city');
const cityCount = await cityField.count();
if (cityCount > 0) {
await cityField.fill('Test City');
}
// Save the customer
const saveButton = page.locator('button[type="submit"], button:has-text("Save"), button:has-text("Submit")');
await saveButton.first().click();
await page.waitForLoadState('networkidle');
// Navigate back to customers list
await page.goto('/customers');
await page.waitForLoadState('networkidle');
// Search for the created customer
const searchInput = page.locator('input[name="search"], input[placeholder*="Search"], #search').first();
await searchInput.fill(lastName);
await page.keyboard.press('Enter');
await page.waitForLoadState('networkidle');
// Verify the customer appears in the table
const customerName = `${firstName} ${lastName}`;
const customerRow = page.locator('table, tbody').locator(`text=${lastName}`).first();
await expect(customerRow).toBeVisible({ timeout: 10000 });
// Also verify email appears if shown in table
const emailVisible = await page.locator(`text=${email}`).count();
if (emailVisible > 0) {
console.log('✓ Customer email also visible in table');
}
console.log('✓ Customer created and verified in table');
});
test('should create a customer with complete details', async ({ page }) => {
// Navigate to customers page
await page.goto('/customers');
await page.waitForLoadState('networkidle');
// Add new customer
await page.getByRole('button', { name: /add|new/i }).first().click();
await page.waitForLoadState('networkidle');
// Fill customer details
const customerData = {
firstName: 'Jane',
lastName: `Complete ${Date.now()}`,
email: `complete.${Date.now()}@example.com`,
phone: `+1-555-${Math.floor(Math.random() * 9000) + 1000}`,
address: '456 Complete Ave',
city: 'Complete City',
state: 'Test State',
zip: '12345',
country: 'Test Country'
};
await page.fill('input[name="first_name"], input[name="first"]', customerData.firstName);
await page.fill('input[name="last_name"], input[name="last"]', customerData.lastName);
await page.fill('input[name="email"], #email', customerData.email);
await page.fill('input[name="phone_number"], input[name="phone"]', customerData.phone);
// Fill address fields if they exist
await page.fill('input[name^="address"], input[name*="address"]', customerData.address).catch(() => {});
await page.fill('input[name="city"], #city', customerData.city).catch(() => {});
await page.fill('input[name="state"], #state', customerData.state).catch(() => {});
await page.fill('input[name="zip"], input[name="zip_code"], #zip', customerData.zip).catch(() => {});
await page.fill('input[name="country"], #country', customerData.country).catch(() => {});
// Add comments
const commentsField = page.locator('textarea[name="comments"], #comments');
const commentsCount = await commentsField.count();
if (commentsCount > 0) {
await commentsField.fill('Test customer for automation');
}
// Save customer
await page.getByRole('button', { name: /save|submit/i }).first().click();
await page.waitForLoadState('networkidle');
// Verify customer appears in list
await page.goto('/customers');
await page.waitForLoadState('networkidle');
const customerName = `${customerData.firstName} ${customerData.lastName}`;
const customerVisible = await page.locator(`text=${lastName}`).count();
expect(customerVisible).toBeGreaterThan(0);
console.log('✓ Customer with complete details created and verified');
});
test('should search for existing customer', async ({ page }) => {
// Navigate to customers page
await page.goto('/customers');
await page.waitForLoadState('networkidle');
// Search for a customer (John Doe from default database)
const searchInput = page.locator('input[name="search"], input[placeholder*="Search"], #search').first();
await searchInput.fill('Doe');
await page.keyboard.press('Enter');
await page.waitForLoadState('networkidle');
// Verify customer appears
const customerVisible = await page.locator('text=Doe').count();
expect(customerVisible).toBeGreaterThan(0);
console.log('✓ Customer search successful');
});
test('should verify customer details in table', async ({ page }) => {
// Create a customer first
await page.goto('/customers');
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: /add|new/i }).first().click();
await page.waitForLoadState('networkidle');
const customerData = {
firstName: 'Table',
lastName: `Test ${Date.now()}`,
email: `table.${Date.now()}@example.com`,
phone: '555-1234'
};
await page.fill('input[name="first_name"], input[name="first"]', customerData.firstName);
await page.fill('input[name="last_name"], input[name="last"]', customerData.lastName);
await page.fill('input[name="email"], #email', customerData.email);
await page.fill('input[name="phone_number"], input[name="phone"]', customerData.phone);
await page.getByRole('button', { name: /save|submit/i }).first().click();
await page.waitForLoadState('networkidle');
// Navigate to customer list
await page.goto('/customers');
await page.waitForLoadState('networkidle');
// Check table structure
const table = page.locator('table').first();
await expect(table).toBeVisible();
// Verify customer data appears
const tableContents = await table.textContent();
expect(tableContents).toContain(customerData.lastName);
if (tableContents.includes(customerData.email)) {
console.log('✓ Customer email visible in table');
}
if (tableContents.includes(customerData.phone)) {
console.log('✓ Customer phone visible in table');
}
console.log('✓ Customer details verified in table format');
});
});
test.describe('Open Source POS - Combined Operations', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.fill('input[name="username"]', 'admin');
await page.fill('input[name="password"]', 'pointofsale');
await page.click('button[type="submit"], input[type="submit"]');
await page.waitForLoadState('networkidle');
test.setTimeout(60000);
});
test('should create item and customer and verify both in their tables', async ({ page }) => {
// Create customer first
await page.goto('/customers');
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: /add|new/i }).first().click();
await page.waitForLoadState('networkidle');
const customerName = `Combined ${Date.now()}`;
await page.fill('input[name="first_name"], input[name="first"]', 'Combined');
await page.fill('input[name="last_name"], input[name="last"]', customerName);
await page.fill('input[name="email"], #email', `combined.${Date.now()}@example.com`);
await page.fill('input[name="phone_number"], input[name="phone"]', '555-9999');
await page.getByRole('button', { name: /save|submit/i }).first().click();
await page.waitForLoadState('networkidle');
// Create item
await page.goto('/items');
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: /add|new/i }).first().click();
await page.waitForLoadState('networkidle');
const itemName = `Combined Item ${Date.now()}`;
await page.fill('input[name="name"], input[name="item_name"]', itemName);
await page.fill('input[name="item_number"], input[name="number"]', `COMB-${Date.now()}`);
await page.fill('input[name="unit_price"], input[name="cost_price"]', '25.00');
await page.fill('input[name="quantity"]', '10');
await page.getByRole('button', { name: /save|submit/i }).first().click();
await page.waitForLoadState('networkidle');
// Verify both exist
await page.goto('/customers');
await page.waitForLoadState('networkidle');
const customerVisible = await page.locator(`text=${customerName}`).count();
expect(customerVisible).toBeGreaterThan(0);
await page.goto('/items');
await page.waitForLoadState('networkidle');
const itemVisible = await page.locator(`text=${itemName}`).count();
expect(itemVisible).toBeGreaterThan(0);
console.log('✓ Both item and customer created and verified');
});
});

View File

@@ -1,151 +0,0 @@
import { test, expect } from '@playwright/test';
test.describe('Open Source POS - Items', () => {
test.beforeEach(async ({ page }) => {
// Navigate to login page
await page.goto('/');
// Login with admin credentials
await page.fill('input[name="username"]', 'admin');
await page.fill('input[name="password"]', 'pointofsale');
await page.click('button[type="submit"], input[type="submit"]');
// Wait for navigation to complete
await page.waitForLoadState('networkidle');
// Check if we're logged in (look for navigation elements)
const nav = page.locator('nav, .navbar-nav, .sidebar');
// If we see error message, login failed
const errorMessage = page.locator('.alert-danger, .error, .alert[role="alert"]');
const errorCount = await errorMessage.count();
if (errorCount > 0) {
const errorText = await errorMessage.first().textContent();
console.log('Login error:', errorText);
throw new Error(`Login failed: ${errorText}`);
}
test.setTimeout(60000);
});
test('should create an item and verify it appears in table', async ({ page }) => {
// Navigate to items page
const navigationOption = page.locator('a[href*="items"], a:has-text("Item"), a:has-text("Inventory")');
await navigationOption.first().click();
await page.waitForLoadState('networkidle');
// Look for "Add Item" or "New Item" button
const addItemButton = page.locator('button:has-text("Add"), button:has-text("New"), a:has-text("Add")');
await addItemButton.first().click();
await page.waitForLoadState('networkidle');
// Fill in item details
const itemName = `Test Item ${Date.now()}`;
const itemNumber = `12345-${Date.now()}`;
await page.fill('input[name="name"], input[name="item_name"], #name, #item_name', itemName);
await page.fill('input[name="item_number"], input[name="number"], #item_number', itemNumber);
// Set price and cost
await page.fill('input[name="unit_price"], input[name="cost_price"], #unit_price, #cost_price', '10.00');
await page.fill('input[name="cost_price"], #cost_price', '5.00');
// Set quantity
await page.fill('input[name="quantity"], #quantity', '100');
// Save the item
const saveButton = page.locator('button[type="submit"], button:has-text("Save"), button:has-text("Submit")');
await saveButton.first().click();
await page.waitForLoadState('networkidle');
// Navigate back to items list
await page.goto('/items');
await page.waitForLoadState('networkidle');
// Search for the created item
const searchInput = page.locator('input[name="search"], input[placeholder*="Search"], #search').first();
await searchInput.fill(itemName);
await page.keyboard.press('Enter');
await page.waitForLoadState('networkidle');
// Verify the item appears in the table
const itemRow = page.locator('table, tbody').locator(`text=${itemName}`).first();
await expect(itemRow).toBeVisible({ timeout: 10000 });
console.log('✓ Item created and verified in table');
});
test('should create an item with category and verify', async ({ page }) => {
// Navigate to items page
const itemsLink = page.locator('a[href*="items"]');
await itemsLink.first().click();
await page.waitForLoadState('networkidle');
// Add new item
await page.getByRole('button', { name: /add|new/i }).first().click();
await page.waitForLoadState('networkidle');
// Fill item details
const itemName = `Category Test Item ${Date.now()}`;
await page.fill('input[name="name"], input[name="item_name"]', itemName);
await page.fill('input[name="item_number"], input[name="number"]', `CAT-${Date.now()}`);
// Set price and cost
await page.fill('input[name="unit_price"]', '15.99');
await page.fill('input[name="cost_price"]', '8.50');
// Set quantity
await page.fill('input[name="quantity"]', '50');
// Select category (if dropdown exists)
const categorySelect = page.locator('select[name*="category"], select[name*="category_id"]');
const categoryCount = await categorySelect.count();
if (categoryCount > 0) {
await categorySelect.first().selectOption({ index: 1 }); // Select first available category
}
// Save item
await page.getByRole('button', { name: /save|submit/i }).first().click();
await page.waitForLoadState('networkidle');
// Verify item appears in list
await page.goto('/items');
await page.waitForLoadState('networkidle');
const itemVisible = await page.locator(`text=${itemName}`).count();
expect(itemVisible).toBeGreaterThan(0);
console.log('✓ Item with category created and verified');
});
test('should update an existing item', async ({ page }) => {
// Navigate to items page
await page.goto('/items');
await page.waitForLoadState('networkidle');
// Find and click edit button for an item
const editButton = page.locator('button:has-text("Edit"), a:has-text("Edit"), .edit').first();
await editButton.click();
await page.waitForLoadState('networkidle');
// Update item name
const updatedName = `Updated Item ${Date.now()}`;
const nameInput = page.locator('input[name="name"], input[name="item_name"]');
await nameInput.fill(updatedName);
// Save changes
await page.getByRole('button', { name: /save|submit|update/i }).first().click();
await page.waitForLoadState('networkidle');
// Navigate back and verify update
await page.goto('/items');
await page.waitForLoadState('networkidle');
const updatedItemVisible = await page.locator(`text=${updatedName}`).count();
expect(updatedItemVisible).toBeGreaterThan(0);
console.log('✓ Item updated successfully');
});
});

View File

@@ -1,98 +0,0 @@
import { test, expect } from '@playwright/test';
test.describe('Open Source POS - Login', () => {
let baseUrl: string;
test.beforeEach(async ({ page }) => {
baseUrl = process.env.BASE_URL || 'http://localhost';
test.setTimeout(60000);
});
test('should display login page', async ({ page }) => {
await page.goto('/');
// Check page title
await expect(page).toHaveTitle(/Open Source Point of Sale/);
// Check for login form
await expect(page.locator('input[name="username"]')).toBeVisible();
await expect(page.locator('input[name="password"]')).toBeVisible();
// Check for login button
const submitButton = page.locator('button[type="submit"], input[type="submit"]');
await expect(submitButton).toBeVisible();
});
test('should login with valid credentials', async ({ page }) => {
await page.goto('/');
// Enter credentials
await page.fill('input[name="username"]', 'admin');
await page.fill('input[name="password"]', 'pointofsale');
// Submit login form
await page.click('button[type="submit"], input[type="submit"]');
// Wait for navigation
await page.waitForLoadState('networkidle');
// Check if redirected to dashboard (login successful if no error message)
const errorMessage = page.locator('.alert-danger, .error, .alert[role="alert"]');
const errorCount = await errorMessage.count();
if (errorCount === 0) {
// Login successful - check for dashboard elements
const dashboardElements = page.locator('nav, .navbar, [role="navigation"]');
await expect(dashboardElements.first()).toBeVisible({ timeout: 10000 });
} else {
// Failed login - check error message
const errorText = await errorMessage.first().textContent();
console.log('Login error:', errorText);
throw new Error(`Login failed: ${errorText}`);
}
});
test('should reject invalid credentials', async ({ page }) => {
await page.goto('/');
// Enter wrong credentials
await page.fill('input[name="username"]', 'invalid');
await page.fill('input[name="password"]', 'wrongpassword');
// Submit login form
await page.click('button[type="submit"], input[type="submit"]');
// Wait for response
await page.waitForLoadState('networkidle');
// Check for error message
const errorMessage = page.locator('.alert-danger, .error, .alert[role="alert"]');
await expect(errorMessage.first()).toBeVisible({ timeout: 10000 });
});
test('should redirect to login when accessing protected page', async ({ page }) => {
// Try to access a protected route directly
await page.goto('/home');
// Should redirect to login
await expect(page.locator('input[name="username"]')).toBeVisible({ timeout: 10000 });
});
test('should have no console errors', async ({ page }) => {
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') {
errors.push(msg.text());
}
});
await page.goto('/');
await page.waitForLoadState('networkidle');
if (errors.length > 0) {
console.error('Console errors found:', errors);
throw new Error(`Found ${errors.length} console errors`);
}
});
});

View File

@@ -1,355 +0,0 @@
import { test, expect } from '@playwright/test';
test.describe('Open Source POS - Sales', () => {
let itemName: string;
let customerName: string;
let itemNumber: string;
test.beforeEach(async ({ page }) => {
// Navigate to login page
await page.goto('/');
// Login with admin credentials
await page.fill('input[name="username"]', 'admin');
await page.fill('input[name="password"]', 'pointofsale');
await page.click('button[type="submit"], input[type="submit"]');
// Wait for navigation to complete
await page.waitForLoadState('networkidle');
// Check for login errors
const errorMessage = page.locator('.alert-danger, .error, .alert[role="alert"]');
const errorCount = await errorMessage.count();
if (errorCount > 0) {
const errorText = await errorMessage.first().textContent();
console.log('Login error:', errorText);
throw new Error(`Login failed: ${errorText}`);
}
test.setTimeout(120000);
});
test('should create sale with item and customer, add payment and complete', async ({ page }) => {
console.log('Step 1: Creating test item...');
// Create a test item first
await page.goto('/items');
await page.waitForLoadState('networkidle');
const addButton = page.locator('button:has-text("Add"), button:has-text("New"), a:has-text("Add")').first();
await addButton.click();
await page.waitForLoadState('networkidle');
itemName = `Sale Test Item ${Date.now()}`;
itemNumber = `SALE-${Date.now()}`;
await page.fill('input[name="name"], input[name="item_name"], #name, #item_name', itemName);
await page.fill('input[name="item_number"], input[name="number"], #item_number', itemNumber);
await page.fill('input[name="unit_price"], input[name="cost_price"], #unit_price, #cost_price', '25.00');
await page.fill('input[name="cost_price"], #cost_price', '10.00');
await page.fill('input[name="quantity"], #quantity', '100');
const saveButton = page.locator('button[type="submit"], button:has-text("Save"), button:has-text("Submit")').first();
await saveButton.click();
await page.waitForLoadState('networkidle');
console.log('✓ Test item created');
console.log('Step 2: Creating test customer...');
// Create a test customer
await page.goto('/customers');
await page.waitForLoadState('networkidle');
const addCustomerButton = page.locator('button:has-text("Add"), button:has-text("New"), a:has-text("Add")').first();
await addCustomerButton.click();
await page.waitForLoadState('networkidle');
const firstName = 'Sale';
const lastName = `Test ${Date.now()}`;
customerName = `${firstName} ${lastName}`;
await page.fill('input[name="first_name"], input[name="first"], #first_name', firstName);
await page.fill('input[name="last_name"], input[name="last"], #last_name', lastName);
await page.fill('input[name="email"], #email', `sale.test.${Date.now()}@example.com`);
await page.fill('input[name="phone_number"], input[name="phone"], #phone_number, #phone', '555-7777');
const saveCustomerButton = page.locator('button[type="submit"], button:has-text("Save"), button:has-text("Submit")').first();
await saveCustomerButton.click();
await page.waitForLoadState('networkidle');
console.log('✓ Test customer created');
console.log('Step 3: Starting new sale...');
// Navigate to sales page
const salesLink = page.locator('a[href*="sales"], a:has-text("Sale"), a:has-text("POS")').first();
await salesLink.click();
await page.waitForLoadState('networkidle');
// Start new sale (if needed)
const newSaleButton = page.locator('button:has-text("New"), button:has-text("New Sale"), a:has-text("New")').first();
const newSaleCount = await newSaleButton.count();
if (newSaleCount > 0) {
await newSaleButton.click();
await page.waitForLoadState('networkidle', { timeout: 5000 });
}
console.log('✓ New sale started');
console.log('Step 4: Adding item to cart...');
// Add item to cart
// Look for item search or add field
const itemSearchInput = page.locator('input[name="item"], input[placeholder*="Item"], input[placeholder*="Search"], #item_search, .item-search').first();
await itemSearchInput.fill(itemName);
// Press Enter or click add button
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
// Alternative: look for add button next to item field
const addToCartButton = page.locator('button:has-text("Add"), button:has-text("Add Item"), .add-item').first();
const addToCartCount = await addToCartButton.count();
if (addToCartCount > 0) {
await addToCartButton.click();
await page.waitForLoadState('networkidle');
}
console.log('✓ Item added to cart');
console.log('Step 5: Adding customer to sale...');
// Add customer to sale
const customerSelect = page.locator('select[name*="customer"], select[name*="customer_id"], input[name*="customer"], .customer-select').first();
const customerSelectCount = await customerSelect.count();
if (customerSelectCount > 0) {
const tagName = await customerSelect.first().evaluate(el => el.tagName.toLowerCase());
if (tagName === 'select') {
await customerSelect.selectOption({ label: new RegExp(lastName) }).catch(() => {
// If select by label doesn't work, try by value
return customerSelect.selectOption({ index: 1 });
});
} else {
await customerSelect.fill(lastName);
await page.keyboard.press('Enter');
}
}
await page.waitForTimeout(1000);
console.log('✓ Customer added to sale');
console.log('Step 6: Verifying cart contents...');
// Verify item is in cart
const cartTable = page.locator('table.cart, table tbody, .cart-items, .sales-table');
const cartVisible = await cartTable.count();
if (cartVisible > 0) {
const cartContents = await cartTable.first().textContent();
expect(cartContents).toContain(itemName || itemNumber);
console.log('✓ Item verified in cart');
}
console.log('Step 7: Adding payment...');
// Add payment
const paymentButton = page.locator('button:has-text("Payment"), button:has-text("Pay"), button:has-text("Checkout")').first();
await paymentButton.click();
await page.waitForLoadState('networkidle');
// Select payment method
const paymentMethodSelect = page.locator('select[name*="payment_method"], select[name*="payment"]').first();
const paymentMethodCount = await paymentMethodSelect.count();
if (paymentMethodCount > 0) {
await paymentMethodSelect.selectOption('Cash').catch(() => {
return paymentMethodSelect.selectOption({ index: 0 });
});
}
// Enter payment amount (should auto-fill with total)
const paymentAmountInput = page.locator('input[name*="payment_amount"], input[name*="amount"], .payment-amount').first();
const paymentAmountCount = await paymentAmountInput.count();
if (paymentAmountCount > 0) {
const currentValue = await paymentAmountInput.inputValue();
if (!currentValue || parseFloat(currentValue) === 0) {
await paymentAmountInput.fill('25.00');
}
}
console.log('✓ Payment added');
console.log('Step 8: Completing sale...');
// Complete/Confirm sale
const confirmButton = page.locator('button:has-text("Complete"), button:has-text("Confirm"), button:has-text("Submit"), button:has-text("Finish")').first();
await confirmButton.click();
await page.waitForLoadState('networkidle', { timeout: 15000 });
console.log('✓ Sale completed');
console.log('Step 9: Checking for receipt...');
// Check if receipt is generated
// Look for receipt modal, popup, or new page
const receiptModal = page.locator('.receipt, .modal, dialog, #receipt, [class*="receipt"]').first();
const receiptModalCount = await receiptModal.count();
if (receiptModalCount > 0) {
await expect(receiptModal.first()).toBeVisible({ timeout: 10000 });
console.log('✓ Receipt modal displayed');
// Verify receipt contains sale details
const receiptText = await receiptModal.first().textContent();
expect(receiptText).toBeTruthy();
if (itemName) {
expect(receiptText.toLowerCase()).toContain(itemName.toLowerCase()).catch(() => {
console.log('Note: Item name not found in receipt, but receipt is visible');
});
}
} else {
// Check for receipt in page content
const receiptContent = await page.textContent();
expect(receiptContent).toMatch(/receipt|sale|total/i);
console.log('✓ Receipt content found on page');
}
console.log('Step 10: Verifying sale details...');
// Verify success message
const successMessage = page.locator('.alert-success, .success, .success-message, .flash-success').first();
const successCount = await successMessage.count();
if (successCount > 0) {
await expect(successMessage.first()).toBeVisible({ timeout: 10000 });
console.log('✓ Success message displayed');
}
// Close receipt/modal if there's a close button
const closeButton = page.locator('button:has-text("Close"), button:has-text("OK"), .close, .modal-close, [aria-label="Close"]').first();
const closeCount = await closeButton.count();
if (closeCount > 0) {
await closeButton.click().catch(() => {
console.log('Close button click failed, but that might be OK');
});
}
console.log('\n=== Sale flow completed successfully! ===');
});
test('should create sale with multiple items', async ({ page }) => {
console.log('Creating test items for multi-item sale...');
// Create two test items
const items = [];
for (let i = 1; i <= 2; i++) {
await page.goto('/items');
await page.waitForLoadState('networkidle');
await page.locator('button:has-text("Add"), button:has-text("New")').first().click();
await page.waitForLoadState('networkidle');
const itemData = {
name: `Multi Item ${i} ${Date.now()}`,
number: `MUL-${Date.now()}-${i}`,
price: (10 * i).toFixed(2),
cost: (5 * i).toFixed(2),
quantity: '50'
};
await page.fill('input[name="name"], input[name="item_name"], #name', itemData.name);
await page.fill('input[name="item_number"], input[name="number"], #item_number', itemData.number);
await page.fill('input[name="unit_price"], #unit_price', itemData.price);
await page.fill('input[name="cost_price"], #cost_price', itemData.cost);
await page.fill('input[name="quantity"], #quantity', itemData.quantity);
await page.locator('button[type="submit"], button:has-text("Save")').first().click();
await page.waitForLoadState('networkidle');
items.push(itemData);
}
console.log('✓ Test items created');
console.log('Starting multi-item sale...');
// Navigate to sales
await page.goto('/sales');
await page.waitForLoadState('networkidle');
// Add items to cart
for (const item of items) {
const itemSearch = page.locator('input[name="item"], input[placeholder*="Item"], .item-search').first();
await itemSearch.fill(item.name);
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
}
console.log('✓ Items added to cart');
// Complete payment and sale
await page.locator('button:has-text("Payment"), button:has-text("Pay")').first().click();
await page.waitForLoadState('networkidle');
await page.locator('button:has-text("Complete"), button:has-text("Confirm")').first().click();
await page.waitForLoadState('networkidle');
// Verify receipt
const receiptModal = page.locator('.receipt, .modal, #receipt').first();
await expect(receiptModal.first()).toBeVisible({ timeout: 10000 });
console.log('✓ Multi-item sale completed with receipt');
});
test('should complete sale with cash payment', async ({ page }) => {
console.log('Testing cash payment flow...');
// Use existing item (if available) or create one
await page.goto('/items');
await page.waitForLoadState('networkidle');
// Check if there are existing items
const itemRows = page.locator('table tbody tr').count();
let itemToUse = 'Test Item';
if (itemRows === 0) {
await page.locator('button:has-text("Add"), button:has-text("New")').first().click();
await page.waitForLoadState('networkidle');
itemToUse = `Cash Test ${Date.now()}`;
await page.fill('input[name="name"], input[name="item_name"], #name', itemToUse);
await page.fill('input[name="item_number"], input[name="number"], #item_number', `CASH-${Date.now()}`);
await page.fill('input[name="unit_price"], #unit_price', '50.00');
await page.fill('input[name="cost_price"], #cost_price', '25.00');
await page.fill('input[name="quantity"], #quantity', '20');
await page.locator('button:has-text("Save")').first().click();
await page.waitForLoadState('networkidle');
}
console.log('Creating sale with cash payment...');
await page.goto('/sales');
await page.waitForLoadState('networkidle');
// Add item
const itemSearch = page.locator('input[name="item"], input[placeholder*="Item"], .item-search').first();
await itemSearch.fill(itemToUse);
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
// Select cash payment
await page.locator('button:has-text("Payment"), button:has-text("Pay")').first().click();
await page.waitForLoadState('networkidle');
const paymentMethod = page.locator('select[name*="payment_method"], select[name*="payment"]').first();
await paymentMethod.selectOption('Cash').catch(() => paymentMethod.selectOption({ index: 0 }));
// Complete sale
await page.locator('button:has-text("Complete"), button:has-text("Confirm")').first().click();
await page.waitForLoadState('networkidle');
// Verify receipt is generated
const receiptVisible = await page.locator('.receipt, .modal, #receipt').count();
expect(receiptVisible).toBeGreaterThan(0);
console.log('✓ Cash payment sale completed with receipt');
});
});

76
package-lock.json generated
View File

@@ -28,7 +28,7 @@
"chartist-plugin-tooltips": "^0.0.17",
"clipboard": "^2.0.11",
"coffeescript": "^2.7.0",
"dompurify": "^3.3.2",
"dompurify": "^3.3.0",
"elegant-circles": "github:opensourcepos/elegant-circles#minified",
"es6-promise": "^4.2.8",
"file-saver": "^2.0.5",
@@ -43,8 +43,6 @@
"tableexport.jquery.plugin": "^1.30.0"
},
"devDependencies": {
"@playwright/test": "^1.44.0",
"@types/node": "^20.11.0",
"gulp": "^5.0.0",
"gulp-clean": "^0.4.0",
"gulp-clean-css": "^4.3.0",
@@ -102,22 +100,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -1501,13 +1483,10 @@
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="
},
"node_modules/dompurify": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -4466,53 +4445,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/plugin-error": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/plugin-error/-/plugin-error-0.1.2.tgz",

View File

@@ -26,12 +26,7 @@
},
"scripts": {
"build": "gulp default",
"gulp": "gulp",
"test": "cd integration-tests && playwright test",
"test:headed": "cd integration-tests && playwright test --headed",
"test:ui": "cd integration-tests && playwright test --ui",
"test:debug": "cd integration-tests && playwright test --debug",
"test:install": "cd integration-tests && playwright install --with-deps"
"gulp": "gulp"
},
"type": "module",
"dependencies": {
@@ -54,7 +49,7 @@
"chartist-plugin-tooltips": "^0.0.17",
"clipboard": "^2.0.11",
"coffeescript": "^2.7.0",
"dompurify": "^3.3.2",
"dompurify": "^3.3.0",
"elegant-circles": "github:opensourcepos/elegant-circles#minified",
"es6-promise": "^4.2.8",
"file-saver": "^2.0.5",
@@ -69,8 +64,6 @@
"tableexport.jquery.plugin": "^1.30.0"
},
"devDependencies": {
"@playwright/test": "^1.44.0",
"@types/node": "^20.11.0",
"gulp": "^5.0.0",
"gulp-clean": "^0.4.0",
"gulp-clean-css": "^4.3.0",

2153
public/api/openapi.yaml Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -228,199 +228,4 @@ class HomeTest extends CIUnitTestCase
$session->destroy();
$session->set('person_id', 1); // Admin user
}
/**
* Create a non-admin employee for testing
*
* @return int The person_id of the created employee
*/
protected function createNonAdminEmployee(): int
{
$personData = [
'first_name' => 'NonAdmin',
'last_name' => 'User',
'email' => 'nonadmin@test.com',
'phone_number' => '555-1234'
];
$employeeData = [
'username' => 'nonadmin',
'password' => password_hash('password123', PASSWORD_DEFAULT),
'hash_version' => 2,
'language_code' => 'en',
'language' => 'english'
];
$grantsData = [
['permission_id' => 'customers', 'menu_group' => 'home'],
['permission_id' => 'sales', 'menu_group' => 'home']
];
$employeeModel = model(Employee::class);
$employeeModel->save_employee($personData, $employeeData, $grantsData, NEW_ENTRY);
return $employeeModel->get_found_rows('');
}
/**
* Login as a specific user
*
* @param int $personId
* @return void
*/
protected function loginAs(int $personId): void
{
$session = Services::session();
$session->destroy();
$session->set('person_id', $personId);
$session->set('menu_group', 'home');
}
// ========== BOLA Authorization Tests ==========
/**
* Test non-admin cannot view admin password change form
* BOLA vulnerability fix: GHSA-q58g-gg7v-f9rf
*
* @return void
*/
public function testNonAdminCannotViewAdminPasswordForm(): void
{
$nonAdminId = $this->createNonAdminEmployee();
$this->loginAs($nonAdminId);
$response = $this->get('/home/changePassword/1');
$response->assertRedirect();
$this->assertStringContainsString('no_access', $response->getRedirectUrl());
}
/**
* Test non-admin cannot change admin password
* BOLA vulnerability fix: GHSA-q58g-gg7v-f9rf
*
* @return void
*/
public function testNonAdminCannotChangeAdminPassword(): void
{
$nonAdminId = $this->createNonAdminEmployee();
$this->loginAs($nonAdminId);
$response = $this->post('/home/save/1', [
'username' => 'admin',
'current_password' => 'pointofsale',
'password' => 'hacked123'
]);
$response->assertStatus(403);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
// Verify admin password was NOT changed
$employee = model(Employee::class);
$admin = $employee->get_info(1);
$this->assertTrue(password_verify('pointofsale', $admin->password),
'Admin password should not have been changed by non-admin');
}
/**
* Test user can view their own password change form
*
* @return void
*/
public function testUserCanViewOwnPasswordForm(): void
{
$nonAdminId = $this->createNonAdminEmployee();
$this->loginAs($nonAdminId);
$response = $this->get('/home/changePassword/' . $nonAdminId);
$response->assertStatus(200);
$response->assertSee('nonadmin'); // Username should be visible
}
/**
* Test user can change their own password
*
* @return void
*/
public function testUserCanChangeOwnPassword(): void
{
$nonAdminId = $this->createNonAdminEmployee();
$this->loginAs($nonAdminId);
$response = $this->post('/home/save/' . $nonAdminId, [
'username' => 'nonadmin',
'current_password' => 'password123',
'password' => 'newpassword123'
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success']);
// Verify password was changed
$employee = model(Employee::class);
$user = $employee->get_info($nonAdminId);
$this->assertTrue(password_verify('newpassword123', $user->password));
}
/**
* Test admin can view any user's password form
*
* @return void
*/
public function testAdminCanViewAnyPasswordForm(): void
{
$nonAdminId = $this->createNonAdminEmployee();
$this->resetSession(); // Login as admin
$response = $this->get('/home/changePassword/' . $nonAdminId);
$response->assertStatus(200);
$response->assertSee('nonadmin');
}
/**
* Test admin can change any user's password
*
* @return void
*/
public function testAdminCanChangeAnyPassword(): void
{
$nonAdminId = $this->createNonAdminEmployee();
$this->resetSession(); // Login as admin
$response = $this->post('/home/save/' . $nonAdminId, [
'username' => 'nonadmin',
'current_password' => 'password123',
'password' => 'adminset123'
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success']);
// Verify password was changed
$employee = model(Employee::class);
$user = $employee->get_info($nonAdminId);
$this->assertTrue(password_verify('adminset123', $user->password));
}
/**
* Test default employee_id parameter uses current user
*
* @return void
*/
public function testDefaultEmployeeIdUsesCurrentUser(): void
{
$nonAdminId = $this->createNonAdminEmployee();
$this->loginAs($nonAdminId);
// Calling without employee_id should use current user
$response = $this->get('/home/changePassword');
$response->assertStatus(200);
$response->assertSee('nonadmin');
}
}

View File

@@ -1,981 +0,0 @@
<?php
namespace Tests\Controllers;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use App\Models\Item;
use App\Models\Item_quantity;
use App\Models\Inventory;
use App\Models\Item_taxes;
use App\Models\Attribute;
use App\Models\Stock_location;
use App\Models\Supplier;
class ItemsCsvImportTest extends CIUnitTestCase
{
use DatabaseTestTrait;
protected $migrate = true;
protected $migrateOnce = false;
protected $refresh = true;
protected $namespace = null;
protected $item;
protected $item_quantity;
protected $inventory;
protected $item_taxes;
protected $attribute;
protected $stock_location;
protected $supplier;
protected function setUp(): void
{
parent::setUp();
helper('importfile');
helper('attribute');
$this->item = model(Item::class);
$this->item_quantity = model(Item_quantity::class);
$this->inventory = model(Inventory::class);
$this->item_taxes = model(Item_taxes::class);
$this->attribute = model(Attribute::class);
$this->stock_location = model(Stock_location::class);
$this->supplier = model(Supplier::class);
}
protected function tearDown(): void
{
parent::tearDown();
}
public function testGenerateCsvHeaderBasic(): void
{
$stock_locations = ['Warehouse'];
$attributes = [];
$csv = generate_import_items_csv($stock_locations, $attributes);
$this->assertStringContainsString('Id,Barcode,"Item Name"', $csv);
$this->assertStringContainsString('Category,"Supplier ID"', $csv);
$this->assertStringContainsString('"Cost Price","Unit Price"', $csv);
$this->assertStringContainsString('"Tax 1 Name","Tax 1 Percent"', $csv);
$this->assertStringContainsString('"Tax 2 Name","Tax 2 Percent"', $csv);
$this->assertStringContainsString('"Reorder Level"', $csv);
$this->assertStringContainsString('Description,"Allow Alt Description"', $csv);
$this->assertStringContainsString('"Item has Serial Number"', $csv);
$this->assertStringContainsString('Image,HSN', $csv);
$this->assertStringContainsString('"location_Warehouse"', $csv);
$this->assertStringContainsString("\xEF\xBB\xBF", $csv);
}
public function testGenerateCsvHeaderMultipleLocations(): void
{
$stock_locations = ['Warehouse', 'Store', 'Backroom'];
$attributes = [];
$csv = generate_import_items_csv($stock_locations, $attributes);
$this->assertStringContainsString('"location_Warehouse"', $csv);
$this->assertStringContainsString('"location_Store"', $csv);
$this->assertStringContainsString('"location_Backroom"', $csv);
}
public function testGenerateCsvHeaderWithAttributes(): void
{
$stock_locations = ['Warehouse'];
$attributes = ['Color', 'Size', 'Weight'];
$csv = generate_import_items_csv($stock_locations, $attributes);
$this->assertStringContainsString('"attribute_Color"', $csv);
$this->assertStringContainsString('"attribute_Size"', $csv);
$this->assertStringContainsString('"attribute_Weight"', $csv);
}
public function testGenerateStockLocationHeaders(): void
{
$locations = ['Warehouse', 'Store'];
$headers = generate_stock_location_headers($locations);
$this->assertEquals(',"location_Warehouse","location_Store"', $headers);
}
public function testGenerateAttributeHeaders(): void
{
$attributes = ['Color', 'Size'];
$headers = generate_attribute_headers($attributes);
$this->assertEquals(',"attribute_Color","attribute_Size"', $headers);
}
public function testGenerateAttributeHeadersRemovesNegativeOneIndex(): void
{
$attributes = [-1 => 'None', 'Color' => 'Color'];
unset($attributes[-1]);
$headers = generate_attribute_headers($attributes);
$this->assertStringContainsString('"attribute_Color"', $headers);
}
public function testGetCsvFileBasic(): void
{
$csv_content = "Id,Barcode,\"Item Name\",Category,\"Supplier ID\",\"Cost Price\",\"Unit Price\",\"Tax 1 Name\",\"Tax 1 Percent\",\"Tax 2 Name\",\"Tax 2 Percent\",\"Reorder Level\",Description,\"Allow Alt Description\",\"Item has Serial Number\",Image,HSN\n";
$csv_content .= ",ITEM001,Test Item,Electronics,1,10.00,15.00,,,,,5,Test Description,0,0,,HSN001\n";
$temp_file = tempnam(sys_get_temp_dir(), 'csv_test_');
file_put_contents($temp_file, $csv_content);
$rows = get_csv_file($temp_file);
$this->assertCount(1, $rows);
$this->assertEquals('', $rows[0]['Id']);
$this->assertEquals('ITEM001', $rows[0]['Barcode']);
$this->assertEquals('Test Item', $rows[0]['Item Name']);
$this->assertEquals('Electronics', $rows[0]['Category']);
unlink($temp_file);
}
public function testGetCsvFileWithBom(): void
{
$bom = pack('CCC', 0xef, 0xbb, 0xbf);
$csv_content = $bom . "Id,\"Item Name\",Category\n";
$csv_content .= "1,Test Item,Electronics\n";
$temp_file = tempnam(sys_get_temp_dir(), 'csv_test_bom_');
file_put_contents($temp_file, $csv_content);
$rows = get_csv_file($temp_file);
$this->assertCount(1, $rows);
$this->assertEquals('1', $rows[0]['Id']);
$this->assertEquals('Test Item', $rows[0]['Item Name']);
unlink($temp_file);
}
public function testGetCsvFileMultipleRows(): void
{
$csv_content = "Id,\"Item Name\",Category\n";
$csv_content .= "1,Item One,Cat A\n";
$csv_content .= "2,Item Two,Cat B\n";
$csv_content .= "3,Item Three,Cat C\n";
$temp_file = tempnam(sys_get_temp_dir(), 'csv_test_multi_');
file_put_contents($temp_file, $csv_content);
$rows = get_csv_file($temp_file);
$this->assertCount(3, $rows);
$this->assertEquals('Item One', $rows[0]['Item Name']);
$this->assertEquals('Item Two', $rows[1]['Item Name']);
$this->assertEquals('Item Three', $rows[2]['Item Name']);
unlink($temp_file);
}
public function testBomExists(): void
{
$bom = pack('CCC', 0xef, 0xbb, 0xbf);
$content_with_bom = $bom . "test content";
$temp_file = tempnam(sys_get_temp_dir(), 'bom_test_');
file_put_contents($temp_file, $content_with_bom);
$handle = fopen($temp_file, 'r');
$result = bom_exists($handle);
fclose($handle);
$this->assertTrue($result);
unlink($temp_file);
}
public function testBomNotExists(): void
{
$content_without_bom = "test content without BOM";
$temp_file = tempnam(sys_get_temp_dir(), 'no_bom_test_');
file_put_contents($temp_file, $content_without_bom);
$handle = fopen($temp_file, 'r');
$result = bom_exists($handle);
fclose($handle);
$this->assertFalse($result);
unlink($temp_file);
}
public function testImportItemBasicFields(): void
{
$item_data = [
'item_id' => null,
'name' => 'CSV Imported Item',
'description' => 'Description from CSV',
'category' => 'Electronics',
'cost_price' => 10.50,
'unit_price' => 25.99,
'reorder_level' => 5,
'supplier_id' => null,
'item_number' => 'CSV-ITEM-001',
'allow_alt_description' => 0,
'is_serialized' => 0,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$this->assertIsInt($item_id);
$this->assertGreaterThan(0, $item_id);
$saved_item = $this->item->get_info($item_id);
$this->assertEquals('CSV Imported Item', $saved_item->name);
$this->assertEquals('Description from CSV', $saved_item->description);
$this->assertEquals('Electronics', $saved_item->category);
$this->assertEquals(10.50, (float)$saved_item->cost_price);
$this->assertEquals(25.99, (float)$saved_item->unit_price);
}
public function testImportItemWithQuantity(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item With Quantity',
'category' => 'Test Category',
'cost_price' => 5.00,
'unit_price' => 10.00,
'reorder_level' => 2,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$location_id = 1;
$quantity = 100;
$item_quantity_data = [
'item_id' => $item_id,
'location_id' => $location_id,
'quantity' => $quantity
];
$result = $this->item_quantity->save_value($item_quantity_data, $item_id, $location_id);
$this->assertTrue($result);
$saved_quantity = $this->item_quantity->get_item_quantity($item_id, $location_id);
$this->assertEquals($quantity, $saved_quantity->quantity);
}
public function testImportItemCreatesInventoryRecord(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item With Inventory',
'category' => 'Test',
'cost_price' => 5.00,
'unit_price' => 10.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$inventory_data = [
'trans_inventory' => 50,
'trans_items' => $item_id,
'trans_location' => 1,
'trans_comment' => 'CSV Import',
'trans_user' => 1
];
$trans_id = $this->inventory->insert($inventory_data);
$this->assertIsInt($trans_id);
$this->assertGreaterThan(0, $trans_id);
$inventory_records = $this->inventory->get_inventory_data_for_item($item_id, 1);
$this->assertGreaterThanOrEqual(1, $inventory_records->getNumRows());
}
public function testImportItemWithTaxes(): void
{
$item_data = [
'item_id' => null,
'name' => 'Taxable Item',
'category' => 'Test',
'cost_price' => 100.00,
'unit_price' => 150.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$taxes_data = [
['name' => 'VAT', 'percent' => 20],
['name' => 'GST', 'percent' => 10]
];
$result = $this->item_taxes->save_value($taxes_data, $item_id);
$this->assertTrue($result);
$saved_taxes = $this->item_taxes->get_info($item_id);
$tax_names = array_column($saved_taxes, 'name');
$this->assertContains('VAT', $tax_names);
$this->assertContains('GST', $tax_names);
}
public function testImportMultipleItemsFromSimulatedCsv(): void
{
$csv_data = [
[
'Id' => '',
'Barcode' => 'ITEM-A',
'Item Name' => 'First Item',
'Category' => 'Category A',
'Supplier ID' => '',
'Cost Price' => '10.00',
'Unit Price' => '20.00',
'Tax 1 Name' => '',
'Tax 1 Percent' => '',
'Tax 2 Name' => '',
'Tax 2 Percent' => '',
'Reorder Level' => '5',
'Description' => 'First item description',
'Allow Alt Description' => '0',
'Item has Serial Number' => '0',
'Image' => '',
'HSN' => '',
'location_Warehouse' => '100'
],
[
'Id' => '',
'Barcode' => 'ITEM-B',
'Item Name' => 'Second Item',
'Category' => 'Category B',
'Supplier ID' => '',
'Cost Price' => '15.00',
'Unit Price' => '30.00',
'Tax 1 Name' => '',
'Tax 1 Percent' => '',
'Tax 2 Name' => '',
'Tax 2 Percent' => '',
'Reorder Level' => '10',
'Description' => 'Second item description',
'Allow Alt Description' => '0',
'Item has Serial Number' => '0',
'Image' => '',
'HSN' => '',
'location_Warehouse' => '50'
]
];
$imported_item_ids = [];
foreach ($csv_data as $row) {
$item_data = [
'item_id' => (int)$row['Id'] ?: null,
'name' => $row['Item Name'],
'description' => $row['Description'],
'category' => $row['Category'],
'cost_price' => (float)$row['Cost Price'],
'unit_price' => (float)$row['Unit Price'],
'reorder_level' => (int)$row['Reorder Level'],
'item_number' => $row['Barcode'] ?: null,
'allow_alt_description' => empty($row['Allow Alt Description']) ? '0' : '1',
'is_serialized' => empty($row['Item has Serial Number']) ? '0' : '1',
'deleted' => false
];
$item_id = $this->item->save_value($item_data);
$imported_item_ids[] = $item_id;
}
$this->assertCount(2, $imported_item_ids);
$item1 = $this->item->get_info($imported_item_ids[0]);
$this->assertEquals('First Item', $item1->name);
$this->assertEquals(10.00, (float)$item1->cost_price);
$item2 = $this->item->get_info($imported_item_ids[1]);
$this->assertEquals('Second Item', $item2->name);
$this->assertEquals(15.00, (float)$item2->cost_price);
}
public function testImportUpdateExistingItem(): void
{
$original_data = [
'item_id' => null,
'name' => 'Original Name',
'category' => 'Original Category',
'cost_price' => 10.00,
'unit_price' => 20.00,
'deleted' => 0
];
$item_id = $this->item->save_value($original_data);
$updated_data = [
'item_id' => $item_id,
'name' => 'Updated Name',
'category' => 'Updated Category',
'cost_price' => 15.00,
'unit_price' => 30.00,
'description' => 'New description',
'reorder_level' => 10,
'deleted' => 0
];
$this->item->save_value($updated_data);
$updated_item = $this->item->get_info($item_id);
$this->assertEquals('Updated Name', $updated_item->name);
$this->assertEquals('Updated Category', $updated_item->category);
$this->assertEquals(15.00, (float)$updated_item->cost_price);
$this->assertEquals(30.00, (float)$updated_item->unit_price);
}
public function testImportItemWithAttributeText(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item With Attribute',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$definition_data = [
'definition_name' => 'Color',
'definition_type' => TEXT,
'definition_flags' => 0,
'deleted' => 0
];
$definition_id = $this->attribute->saveDefinition($definition_data);
$attribute_value = 'Red';
$attribute_id = $this->attribute->saveAttributeValue(
$attribute_value,
$definition_id,
$item_id,
false,
TEXT
);
$this->assertNotFalse($attribute_id);
$saved_value = $this->attribute->getAttributeValue($item_id, $definition_id);
$this->assertEquals('Red', $saved_value->attribute_value);
}
public function testImportItemWithAttributeDropdown(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item With Dropdown',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$definition_data = [
'definition_name' => 'Size',
'definition_type' => DROPDOWN,
'definition_flags' => 0,
'deleted' => 0
];
$definition_id = $this->attribute->saveDefinition($definition_data);
$dropdown_values = ['Small', 'Medium', 'Large'];
foreach ($dropdown_values as $i => $value) {
$this->db->table('attribute_values')->insert([
'attribute_value' => $value,
'definition_id' => $definition_id,
'definition_type' => DROPDOWN,
'attribute_group' => $i,
'deleted' => 0
]);
}
$attribute_value = 'Medium';
$attribute_id = $this->attribute->saveAttributeValue(
$attribute_value,
$definition_id,
$item_id,
false,
DROPDOWN
);
$this->assertNotFalse($attribute_id);
$saved_value = $this->attribute->getAttributeValue($item_id, $definition_id);
$this->assertEquals('Medium', $saved_value->attribute_value);
}
public function testImportItemQuantityZero(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item Zero Quantity',
'category' => 'Test',
'cost_price' => 5.00,
'unit_price' => 10.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$location_id = 1;
$item_quantity_data = [
'item_id' => $item_id,
'location_id' => $location_id,
'quantity' => 0
];
$result = $this->item_quantity->save_value($item_quantity_data, $item_id, $location_id);
$this->assertTrue($result);
$saved_quantity = $this->item_quantity->get_item_quantity($item_id, $location_id);
$this->assertEquals(0, (int)$saved_quantity->quantity);
}
public function testImportItemWithNegativeReorderLevel(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item Negative Reorder',
'category' => 'Test',
'cost_price' => 5.00,
'unit_price' => 10.00,
'reorder_level' => -1,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$saved_item = $this->item->get_info($item_id);
$this->assertEquals(-1, (int)$saved_item->reorder_level);
}
public function testImportItemWithHighPrecisionPrices(): void
{
$item_data = [
'item_id' => null,
'name' => 'High Precision Item',
'category' => 'Test',
'cost_price' => 10.123456,
'unit_price' => 25.876543,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$saved_item = $this->item->get_info($item_id);
$cost_diff = abs(10.123456 - (float)$saved_item->cost_price);
$price_diff = abs(25.876543 - (float)$saved_item->unit_price);
$this->assertLessThan(0.001, $cost_diff, 'Cost price should maintain precision');
$this->assertLessThan(0.001, $price_diff, 'Unit price should maintain precision');
}
public function testImportItemWithHsnCode(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item With HSN',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'hsn_code' => '8471',
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$saved_item = $this->item->get_info($item_id);
$this->assertEquals('8471', $saved_item->hsn_code);
}
public function testImportItemQuantityMultipleLocations(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item Multi Location',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$quantities = [
['location_id' => 1, 'quantity' => 100],
['location_id' => 2, 'quantity' => 50],
['location_id' => 3, 'quantity' => 25]
];
foreach ($quantities as $q) {
$result = $this->item_quantity->save_value(
['item_id' => $item_id, 'location_id' => $q['location_id'], 'quantity' => $q['quantity']],
$item_id,
$q['location_id']
);
$this->assertTrue($result);
}
foreach ($quantities as $q) {
$saved = $this->item_quantity->get_item_quantity($item_id, $q['location_id']);
$this->assertEquals($q['quantity'], (int)$saved->quantity, "Quantity at location {$q['location_id']} should match");
}
}
public function testCsvImportQuantityValidationNumeric(): void
{
$csv_data = [
'Id' => '',
'Barcode' => 'VALID-ITEM',
'Item Name' => 'Valid Item',
'Category' => 'Test',
'Cost Price' => '10.00',
'Unit Price' => '20.00',
'location_Warehouse' => '100'
];
$this->assertTrue(is_numeric($csv_data['location_Warehouse']));
$this->assertTrue(is_numeric($csv_data['Cost Price']));
$this->assertTrue(is_numeric($csv_data['Unit Price']));
}
public function testCsvImportEmptyBarcodeAllowed(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item Without Barcode',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'item_number' => null,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$this->assertIsInt($item_id);
$this->assertGreaterThan(0, $item_id);
$saved_item = $this->item->get_info($item_id);
$this->assertEquals('Item Without Barcode', $saved_item->name);
}
public function testCsvImportItemExistsCheck(): void
{
$item_data = [
'item_id' => null,
'name' => 'Existing Item',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$exists = $this->item->exists($item_id);
$this->assertTrue($exists);
$not_exists = $this->item->exists(999999);
$this->assertFalse($not_exists);
}
public function testFullCsvImportFlowSimulated(): void
{
$csv_row = [
'Id' => '',
'Barcode' => 'FULL-TEST-001',
'Item Name' => 'Complete Test Item',
'Category' => 'Electronics',
'Supplier ID' => '',
'Cost Price' => '50.00',
'Unit Price' => '100.00',
'Tax 1 Name' => 'VAT',
'Tax 1 Percent' => '20',
'Tax 2 Name' => '',
'Tax 2 Percent' => '',
'Reorder Level' => '10',
'Description' => 'A complete test item for CSV import',
'Allow Alt Description' => '1',
'Item has Serial Number' => '0',
'Image' => '',
'HSN' => '84713020'
];
$item_data = [
'item_id' => (int)$csv_row['Id'] ?: null,
'name' => $csv_row['Item Name'],
'description' => $csv_row['Description'],
'category' => $csv_row['Category'],
'cost_price' => (float)$csv_row['Cost Price'],
'unit_price' => (float)$csv_row['Unit Price'],
'reorder_level' => (int)$csv_row['Reorder Level'],
'item_number' => $csv_row['Barcode'] ?: null,
'allow_alt_description' => empty($csv_row['Allow Alt Description']) ? '0' : '1',
'is_serialized' => empty($csv_row['Item has Serial Number']) ? '0' : '1',
'hsn_code' => $csv_row['HSN'],
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$taxes_data = [];
if (is_numeric($csv_row['Tax 1 Percent']) && $csv_row['Tax 1 Name'] !== '') {
$taxes_data[] = ['name' => $csv_row['Tax 1 Name'], 'percent' => $csv_row['Tax 1 Percent']];
}
if (is_numeric($csv_row['Tax 2 Percent']) && $csv_row['Tax 2 Name'] !== '') {
$taxes_data[] = ['name' => $csv_row['Tax 2 Name'], 'percent' => $csv_row['Tax 2 Percent']];
}
if (!empty($taxes_data)) {
$this->item_taxes->save_value($taxes_data, $item_id);
}
$location_id = 1;
$quantity = 75;
$quantity_data = [
'item_id' => $item_id,
'location_id' => $location_id,
'quantity' => $quantity
];
$this->item_quantity->save_value($quantity_data, $item_id, $location_id);
$inventory_data = [
'trans_inventory' => $quantity,
'trans_items' => $item_id,
'trans_location' => $location_id,
'trans_comment' => 'CSV import quantity',
'trans_user' => 1
];
$this->inventory->insert($inventory_data);
$saved_item = $this->item->get_info($item_id);
$this->assertEquals('Complete Test Item', $saved_item->name);
$this->assertEquals('Electronics', $saved_item->category);
$this->assertEquals(50.00, (float)$saved_item->cost_price);
$this->assertEquals(100.00, (float)$saved_item->unit_price);
$this->assertEquals('84713020', $saved_item->hsn_code);
$saved_quantity = $this->item_quantity->get_item_quantity($item_id, $location_id);
$this->assertEquals($quantity, (int)$saved_quantity->quantity);
$saved_taxes = $this->item_taxes->get_info($item_id);
$this->assertCount(1, $saved_taxes);
$this->assertEquals('VAT', $saved_taxes[0]['name']);
$this->assertEquals(20, (float)$saved_taxes[0]['percent']);
$inventory_records = $this->inventory->get_inventory_data_for_item($item_id, $location_id);
$this->assertGreaterThanOrEqual(1, $inventory_records->getNumRows());
}
public function testImportCsvInvalidStockLocationColumn(): void
{
$csv_headers = ['Id', 'Item Name', 'Category', 'Cost Price', 'Unit Price', 'location_NonExistentLocation'];
$csv_row = [
'Id' => '',
'Item Name' => 'Test Item Invalid Location',
'Category' => 'Test',
'Cost Price' => '10.00',
'Unit Price' => '20.00',
'location_NonExistentLocation' => '100'
];
$allowed_locations = [1 => 'Warehouse'];
$location_columns_in_csv = [];
foreach (array_keys($csv_row) as $key) {
if (str_starts_with($key, 'location_')) {
$location_columns_in_csv[$key] = substr($key, 9);
}
}
$invalid_locations = [];
foreach ($location_columns_in_csv as $column => $location_name) {
if (!in_array($location_name, $allowed_locations)) {
$invalid_locations[] = $location_name;
}
}
$this->assertNotEmpty($invalid_locations, 'Should detect invalid location in CSV');
$this->assertContains('NonExistentLocation', $invalid_locations);
}
public function testImportCsvValidStockLocationColumn(): void
{
$csv_row = [
'Id' => '',
'Item Name' => 'Test Item Valid Location',
'Category' => 'Test',
'Cost Price' => '10.00',
'Unit Price' => '20.00',
'location_Warehouse' => '100'
];
$allowed_locations = [1 => 'Warehouse'];
$location_columns_in_csv = [];
foreach (array_keys($csv_row) as $key) {
if (str_starts_with($key, 'location_')) {
$location_columns_in_csv[$key] = substr($key, 9);
}
}
$invalid_locations = [];
foreach ($location_columns_in_csv as $column => $location_name) {
if (!in_array($location_name, $allowed_locations)) {
$invalid_locations[] = $location_name;
}
}
$this->assertEmpty($invalid_locations, 'Should have no invalid locations');
}
public function testImportCsvMixedValidAndInvalidLocations(): void
{
$csv_row = [
'Id' => '',
'Item Name' => 'Test Item Mixed Locations',
'Category' => 'Test',
'Cost Price' => '10.00',
'Unit Price' => '20.00',
'location_Warehouse' => '100',
'location_InvalidLocation' => '50'
];
$allowed_locations = [1 => 'Warehouse', 2 => 'Store'];
$location_columns_in_csv = [];
foreach (array_keys($csv_row) as $key) {
if (str_starts_with($key, 'location_')) {
$location_columns_in_csv[$key] = substr($key, 9);
}
}
$invalid_locations = [];
foreach ($location_columns_in_csv as $column => $location_name) {
if (!in_array($location_name, $allowed_locations)) {
$invalid_locations[] = $location_name;
}
}
$this->assertCount(1, $invalid_locations, 'Should have exactly one invalid location');
$this->assertContains('InvalidLocation', $invalid_locations);
}
public function testValidateCsvStockLocations(): void
{
$csv_content = "Id,\"Item Name\",Category,\"Cost Price\",\"Unit Price\",\"location_Warehouse\",\"location_FakeLocation\"\n";
$csv_content .= ",Test Item,Test,10.00,20.00,100,50\n";
$temp_file = tempnam(sys_get_temp_dir(), 'csv_location_test_');
file_put_contents($temp_file, $csv_content);
$rows = get_csv_file($temp_file);
$this->assertCount(1, $rows);
$row = $rows[0];
$this->assertArrayHasKey('location_Warehouse', $row);
$this->assertArrayHasKey('location_FakeLocation', $row);
unlink($temp_file);
}
public function testImportItemQuantityOnlyForValidLocations(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item Location Test',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$allowed_locations = [1 => 'Warehouse', 2 => 'Store'];
$csv_row_simulated = [
'location_Warehouse' => '100',
'location_Store' => '50',
'location_NonExistent' => '25'
];
foreach ($allowed_locations as $location_id => $location_name) {
$column_name = "location_$location_name";
if (isset($csv_row_simulated[$column_name]) || $csv_row_simulated[$column_name] === '0') {
$quantity_data = [
'item_id' => $item_id,
'location_id' => $location_id,
'quantity' => (int)$csv_row_simulated[$column_name]
];
$this->item_quantity->save_value($quantity_data, $item_id, $location_id);
}
}
$warehouse_qty = $this->item_quantity->get_item_quantity($item_id, 1);
$this->assertEquals(100, (int)$warehouse_qty->quantity);
$store_qty = $this->item_quantity->get_item_quantity($item_id, 2);
$this->assertEquals(50, (int)$store_qty->quantity);
$result = $this->item_quantity->exists($item_id, 999);
$this->assertFalse($result, 'Should not have quantity for non-existent location');
}
public function testDetectCsvLocationColumns(): void
{
$row = [
'Id' => '',
'Item Name' => 'Test',
'location_Warehouse' => '100',
'location_Store' => '50',
'attribute_Color' => 'Red'
];
$location_columns = [];
foreach (array_keys($row) as $key) {
if (str_starts_with($key, 'location_')) {
$location_columns[$key] = substr($key, 9);
}
}
$this->assertCount(2, $location_columns);
$this->assertArrayHasKey('location_Warehouse', $location_columns);
$this->assertArrayHasKey('location_Store', $location_columns);
$this->assertEquals('Warehouse', $location_columns['location_Warehouse']);
$this->assertEquals('Store', $location_columns['location_Store']);
}
public function testValidateLocationNamesCaseSensitivity(): void
{
$allowed_locations = [1 => 'Warehouse', 2 => 'Store'];
$csv_location_name = 'warehouse';
$is_valid = in_array($csv_location_name, $allowed_locations);
$this->assertFalse($is_valid, 'Location names should be case-sensitive');
$csv_location_name = 'Warehouse';
$is_valid = in_array($csv_location_name, $allowed_locations);
$this->assertTrue($is_valid, 'Valid location name should pass validation');
}
}

View File

@@ -24,7 +24,7 @@ class EmployeeTest extends CIUnitTestCase
{
$employeeModel = model(Employee::class);
$result = $employeeModel->isAdmin(1);
$result = $employeeModel->is_admin(1);
$this->assertTrue($result);
}
@@ -38,7 +38,7 @@ class EmployeeTest extends CIUnitTestCase
$employeeModel->method('has_grant')
->willReturn(true);
$result = $employeeModel->isAdmin(2);
$result = $employeeModel->is_admin(2);
$this->assertTrue($result);
}
@@ -54,7 +54,7 @@ class EmployeeTest extends CIUnitTestCase
return $permissionId !== 'config';
});
$result = $employeeModel->isAdmin(3);
$result = $employeeModel->is_admin(3);
$this->assertFalse($result);
}
@@ -62,13 +62,13 @@ class EmployeeTest extends CIUnitTestCase
public function testCanModifyEmployeeReturnsTrueForOwnAccount(): void
{
$employeeModel = $this->getMockBuilder(Employee::class)
->onlyMethods(['isAdmin'])
->onlyMethods(['is_admin'])
->getMock();
$employeeModel->method('isAdmin')
$employeeModel->method('is_admin')
->willReturn(false);
$result = $employeeModel->canModifyEmployee(1, 1);
$result = $employeeModel->can_modify_employee(1, 1);
$this->assertTrue($result);
}
@@ -76,13 +76,13 @@ class EmployeeTest extends CIUnitTestCase
public function testCanModifyEmployeeReturnsTrueForOwnAdminAccount(): void
{
$employeeModel = $this->getMockBuilder(Employee::class)
->onlyMethods(['isAdmin'])
->onlyMethods(['is_admin'])
->getMock();
$employeeModel->method('isAdmin')
$employeeModel->method('is_admin')
->willReturn(true);
$result = $employeeModel->canModifyEmployee(1, 1);
$result = $employeeModel->can_modify_employee(1, 1);
$this->assertTrue($result);
}
@@ -90,15 +90,15 @@ class EmployeeTest extends CIUnitTestCase
public function testCanModifyEmployeeReturnsFalseWhenNonAdminModifiesAdmin(): void
{
$employeeModel = $this->getMockBuilder(Employee::class)
->onlyMethods(['isAdmin'])
->onlyMethods(['is_admin'])
->getMock();
$employeeModel->method('isAdmin')
$employeeModel->method('is_admin')
->willReturnCallback(function($personId) {
return $personId === 1;
});
$result = $employeeModel->canModifyEmployee(1, 2);
$result = $employeeModel->can_modify_employee(1, 2);
$this->assertFalse($result);
}
@@ -106,15 +106,15 @@ class EmployeeTest extends CIUnitTestCase
public function testCanModifyEmployeeReturnsTrueWhenAdminModifiesNonAdmin(): void
{
$employeeModel = $this->getMockBuilder(Employee::class)
->onlyMethods(['isAdmin'])
->onlyMethods(['is_admin'])
->getMock();
$employeeModel->method('isAdmin')
$employeeModel->method('is_admin')
->willReturnCallback(function($personId) {
return $personId === 1;
});
$result = $employeeModel->canModifyEmployee(2, 1);
$result = $employeeModel->can_modify_employee(2, 1);
$this->assertTrue($result);
}
@@ -122,13 +122,13 @@ class EmployeeTest extends CIUnitTestCase
public function testCanModifyEmployeeReturnsTrueWhenNonAdminModifiesNonAdmin(): void
{
$employeeModel = $this->getMockBuilder(Employee::class)
->onlyMethods(['isAdmin'])
->onlyMethods(['is_admin'])
->getMock();
$employeeModel->method('isAdmin')
$employeeModel->method('is_admin')
->willReturn(false);
$result = $employeeModel->canModifyEmployee(2, 3);
$result = $employeeModel->can_modify_employee(2, 3);
$this->assertTrue($result);
}
@@ -136,15 +136,15 @@ class EmployeeTest extends CIUnitTestCase
public function testCanModifyEmployeeReturnsFalseForNonAdminEditingAdmin(): void
{
$employeeModel = $this->getMockBuilder(Employee::class)
->onlyMethods(['isAdmin'])
->onlyMethods(['is_admin'])
->getMock();
$employeeModel->method('isAdmin')
$employeeModel->method('is_admin')
->willReturnCallback(function($personId) {
return $personId === 1;
});
$result = $employeeModel->canModifyEmployee(1, 2);
$result = $employeeModel->can_modify_employee(1, 2);
$this->assertFalse($result);
}

View File

@@ -1,51 +0,0 @@
<?php
namespace Tests\Models\Reports;
use CodeIgniter\Test\CIUnitTestCase;
use App\Models\Reports\Summary_discounts;
class Summary_discounts_test extends CIUnitTestCase
{
public function testCurrencySymbolEscaping(): void
{
$malicious_symbols = [
'"',
"'",
'" + SLEEP(5) + "',
'", SLEEP(5), "',
"' + (SELECT * FROM (SELECT(SLEEP(5)))a) + '",
'"; DROP TABLE ospos_sales_items; --',
'" OR 1=1 --'
];
foreach ($malicious_symbols as $symbol) {
$escaped = $this->escapeCurrencySymbol($symbol);
$this->assertStringNotContainsString('SLEEP', $escaped, "SQL injection attempt should be escaped: $symbol");
$this->assertStringNotContainsString('DROP', $escaped, "SQL injection attempt should be escaped: $symbol");
$this->assertStringNotContainsString(';', $escaped, "Query termination should be escaped: $symbol");
}
}
public function testNormalCurrencySymbolHandling(): void
{
$normal_symbols = ['$', '€', '£', '¥', '₹', '₩', '₽', 'kr', 'CHF'];
foreach ($normal_symbols as $symbol) {
$escaped = $this->escapeCurrencySymbol($symbol);
$this->assertNotEmpty($escaped, "Normal currency symbol should be preserved: $symbol");
}
}
private function escapeCurrencySymbol(string $symbol): string
{
if (strlen($symbol) === 0) {
return "''";
}
$symbol = addslashes($symbol);
return "'" . $symbol . "'";
}
}