mirror of
https://github.com/opensourcepos/opensourcepos.git
synced 2026-05-25 08:44:42 -04:00
Compare commits
2 Commits
feature/in
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12146275f4 | ||
|
|
e45af91e2e |
144
.github/workflows/integration-tests.yml
vendored
144
.github/workflows/integration-tests.yml
vendored
@@ -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
|
||||
10
.github/workflows/phpunit.yml
vendored
10
.github/workflows/phpunit.yml
vendored
@@ -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()
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -12,18 +12,10 @@ use CodeIgniter\Filters\InvalidChars;
|
||||
use CodeIgniter\Filters\PageCache;
|
||||
use CodeIgniter\Filters\PerformanceMetrics;
|
||||
use CodeIgniter\Filters\SecureHeaders;
|
||||
use App\Filters\ApiAuth;
|
||||
|
||||
class Filters extends BaseFilters
|
||||
{
|
||||
/**
|
||||
* Configures aliases for Filter classes to
|
||||
* make reading things nicer and simpler.
|
||||
*
|
||||
* @var array<string, class-string|list<class-string>>
|
||||
*
|
||||
* [filter_name => classname]
|
||||
* or [filter_name => [classname1, classname2, ...]]
|
||||
*/
|
||||
public array $aliases = [
|
||||
'csrf' => CSRF::class,
|
||||
'toolbar' => DebugToolbar::class,
|
||||
@@ -34,6 +26,7 @@ class Filters extends BaseFilters
|
||||
'forcehttps' => ForceHTTPS::class,
|
||||
'pagecache' => PageCache::class,
|
||||
'performance' => PerformanceMetrics::class,
|
||||
'apiauth' => ApiAuth::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -70,7 +63,7 @@ class Filters extends BaseFilters
|
||||
public array $globals = [
|
||||
'before' => [
|
||||
'honeypot',
|
||||
'csrf' => ['except' => 'login'],
|
||||
'csrf' => ['except' => ['login', 'api/*']],
|
||||
'invalidchars',
|
||||
],
|
||||
'after' => [
|
||||
|
||||
@@ -39,3 +39,50 @@ $routes->add('reports/specific_customers', 'Reports::specific_customer_input');
|
||||
$routes->add('reports/specific_employees', 'Reports::specific_employee_input');
|
||||
$routes->add('reports/specific_discounts', 'Reports::specific_discount_input');
|
||||
$routes->add('reports/specific_suppliers', 'Reports::specific_supplier_input');
|
||||
|
||||
$routes->group('office/api-keys', ['filter' => 'session'], static function(RouteCollection $routes): void {
|
||||
$routes->get('/', 'ApiKeys::index');
|
||||
$routes->post('generate', 'ApiKeys::generate');
|
||||
$routes->post('revoke/(:num)', 'ApiKeys::revoke/$1');
|
||||
$routes->post('regenerate/(:num)', 'ApiKeys::regenerate/$1');
|
||||
});
|
||||
|
||||
$routes->group('api/v1', ['filter' => 'apiauth'], static function(RouteCollection $routes): void {
|
||||
$routes->get('customers', 'Api\Customers::index');
|
||||
$routes->get('customers/(:num)', 'Api\Customers::show/$1');
|
||||
$routes->post('customers', 'Api\Customers::create');
|
||||
$routes->put('customers/(:num)', 'Api\Customers::update/$1');
|
||||
$routes->delete('customers/(:num)', 'Api\Customers::delete/$1');
|
||||
$routes->post('customers/batch-delete', 'Api\Customers::batchDelete');
|
||||
$routes->get('customers/suggest', 'Api\Customers::suggest');
|
||||
|
||||
$routes->get('suppliers', 'Api\Suppliers::index');
|
||||
$routes->get('suppliers/(:num)', 'Api\Suppliers::show/$1');
|
||||
$routes->post('suppliers', 'Api\Suppliers::create');
|
||||
$routes->put('suppliers/(:num)', 'Api\Suppliers::update/$1');
|
||||
$routes->delete('suppliers/(:num)', 'Api\Suppliers::delete/$1');
|
||||
$routes->post('suppliers/batch-delete', 'Api\Suppliers::batchDelete');
|
||||
$routes->get('suppliers/suggest', 'Api\Suppliers::suggest');
|
||||
|
||||
$routes->get('items', 'Api\Items::index');
|
||||
$routes->get('items/(:num)', 'Api\Items::show/$1');
|
||||
$routes->post('items', 'Api\Items::create');
|
||||
$routes->put('items/(:num)', 'Api\Items::update/$1');
|
||||
$routes->delete('items/(:num)', 'Api\Items::delete/$1');
|
||||
$routes->post('items/batch-delete', 'Api\Items::batchDelete');
|
||||
$routes->get('items/suggest', 'Api\Items::suggest');
|
||||
$routes->get('items/(:num)/quantities', 'Api\Items::quantities/$1');
|
||||
|
||||
$routes->get('inventory', 'Api\Inventory::index');
|
||||
$routes->post('inventory', 'Api\Inventory::create');
|
||||
$routes->post('inventory/bulk', 'Api\Inventory::create');
|
||||
|
||||
$routes->get('sales', 'Api\Sales::index');
|
||||
$routes->get('sales/(:num)', 'Api\Sales::show/$1');
|
||||
$routes->get('sales/(:num)/items', 'Api\Sales::items/$1');
|
||||
$routes->get('sales/(:num)/payments', 'Api\Sales::payments/$1');
|
||||
|
||||
$routes->get('receivings', 'Api\Receivings::index');
|
||||
$routes->get('receivings/(:num)', 'Api\Receivings::show/$1');
|
||||
$routes->get('receivings/(:num)/items', 'Api\Receivings::items/$1');
|
||||
});
|
||||
|
||||
129
app/Controllers/Api/BaseController.php
Normal file
129
app/Controllers/Api/BaseController.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api;
|
||||
|
||||
use App\Models\Employee;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use CodeIgniter\RESTful\ResourceController;
|
||||
|
||||
class BaseController extends ResourceController
|
||||
{
|
||||
protected Employee $employee;
|
||||
protected int $employeeId = 0;
|
||||
protected $format = 'json';
|
||||
|
||||
public function initController(\CodeIgniter\HTTP\RequestInterface $request, \CodeIgniter\HTTP\ResponseInterface $response, \Psr\Log\LoggerInterface $logger): void
|
||||
{
|
||||
parent::initController($request, $response, $logger);
|
||||
|
||||
$this->employee = model(Employee::class);
|
||||
$this->employeeId = $request->employeeId ?? 0;
|
||||
}
|
||||
|
||||
protected function hasPermission(string $moduleId): bool
|
||||
{
|
||||
return $this->employee->has_grant($moduleId, $this->employeeId);
|
||||
}
|
||||
|
||||
protected function respondSuccess(array $data = [], int $code = 200, string $message = 'Success'): ResponseInterface
|
||||
{
|
||||
$response = ['success' => true];
|
||||
|
||||
if ($message) {
|
||||
$response['message'] = $message;
|
||||
}
|
||||
|
||||
$response = array_merge($response, $data);
|
||||
|
||||
return $this->respond($response, $code);
|
||||
}
|
||||
|
||||
protected function respondCreated(array $data = [], string $message = 'Resource created'): ResponseInterface
|
||||
{
|
||||
return $this->respondSuccess($data, 201, $message);
|
||||
}
|
||||
|
||||
protected function respondError(string $message, int $code = 400): ResponseInterface
|
||||
{
|
||||
return $this->respond([
|
||||
'success' => false,
|
||||
'message' => $message
|
||||
], $code);
|
||||
}
|
||||
|
||||
protected function respondNotFound(string $message = 'Resource not found'): ResponseInterface
|
||||
{
|
||||
return $this->respondError($message, 404);
|
||||
}
|
||||
|
||||
protected function respondUnauthorized(string $message = 'Unauthorized'): ResponseInterface
|
||||
{
|
||||
return $this->respondError($message, 403);
|
||||
}
|
||||
|
||||
protected function respondValidationError(array $errors): ResponseInterface
|
||||
{
|
||||
return $this->respond([
|
||||
'success' => false,
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $errors
|
||||
], 422);
|
||||
}
|
||||
|
||||
protected function getPagination(): array
|
||||
{
|
||||
$offset = (int) ($this->request->getGet('offset') ?? 0);
|
||||
$limit = (int) ($this->request->getGet('limit') ?? 25);
|
||||
$limit = min(max($limit, 1), 100);
|
||||
$offset = max($offset, 0);
|
||||
|
||||
return ['offset' => $offset, 'limit' => $limit];
|
||||
}
|
||||
|
||||
protected function getSort(array $allowedFields, string $default = 'id', string $defaultOrder = 'asc'): array
|
||||
{
|
||||
$sort = $this->request->getGet('sort') ?? $default;
|
||||
$order = strtolower($this->request->getGet('order') ?? $defaultOrder);
|
||||
|
||||
if (!in_array($sort, $allowedFields)) {
|
||||
$sort = $default;
|
||||
}
|
||||
|
||||
if (!in_array($order, ['asc', 'desc'])) {
|
||||
$order = $defaultOrder;
|
||||
}
|
||||
|
||||
return ['sort' => $sort, 'order' => $order];
|
||||
}
|
||||
|
||||
protected function toCamelCase(array $data): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($data as $key => $value) {
|
||||
$camelKey = lcfirst(str_replace('_', '', ucwords($key, '_')));
|
||||
$result[$camelKey] = $value;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected function toSnakeCase(array $data): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($data as $key => $value) {
|
||||
$snakeKey = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
|
||||
$result[$snakeKey] = $value;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected function transformItem(object|array $item, array $additional = []): array
|
||||
{
|
||||
$item = is_object($item) ? (array) $item : $item;
|
||||
return $this->toCamelCase(array_merge($item, $additional));
|
||||
}
|
||||
|
||||
protected function transformCollection(array $items): array
|
||||
{
|
||||
return array_map([$this, 'transformItem'], $items);
|
||||
}
|
||||
}
|
||||
251
app/Controllers/Api/Customers.php
Normal file
251
app/Controllers/Api/Customers.php
Normal file
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Person;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
class Customers extends BaseController
|
||||
{
|
||||
protected Customer $customerModel;
|
||||
protected Person $personModel;
|
||||
|
||||
protected array $allowedSortFields = ['person_id', 'last_name', 'first_name', 'email', 'company_name'];
|
||||
|
||||
public function initController(\CodeIgniter\HTTP\RequestInterface $request, \CodeIgniter\HTTP\ResponseInterface $response, \Psr\Log\LoggerInterface $logger): void
|
||||
{
|
||||
parent::initController($request, $response, $logger);
|
||||
$this->customerModel = model(Customer::class);
|
||||
$this->personModel = model(Person::class);
|
||||
}
|
||||
|
||||
public function index(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('customers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$search = $this->request->getGet('search');
|
||||
$pagination = $this->getPagination();
|
||||
$sort = $this->getSort($this->allowedSortFields, 'last_name');
|
||||
|
||||
$builder = $this->customerModel->builder();
|
||||
$builder->select('customers.*, people.*');
|
||||
$builder->join('people', 'people.person_id = customers.person_id');
|
||||
$builder->where('customers.deleted', 0);
|
||||
|
||||
if ($search) {
|
||||
$builder->groupStart();
|
||||
$builder->like('people.first_name', $search);
|
||||
$builder->orLike('people.last_name', $search);
|
||||
$builder->orLike('people.email', $search);
|
||||
$builder->orLike('customers.account_number', $search);
|
||||
$builder->orLike('customers.company_name', $search);
|
||||
$builder->groupEnd();
|
||||
}
|
||||
|
||||
$total = $builder->countAllResults(false);
|
||||
|
||||
$dbSort = $this->mapSortField($sort['sort']);
|
||||
$builder->orderBy($dbSort, $sort['order']);
|
||||
$builder->limit($pagination['limit'], $pagination['offset']);
|
||||
|
||||
$customers = $builder->get()->getResultArray();
|
||||
|
||||
return $this->respondSuccess([
|
||||
'total' => $total,
|
||||
'offset' => $pagination['offset'],
|
||||
'limit' => $pagination['limit'],
|
||||
'rows' => $this->transformCollection($customers)
|
||||
]);
|
||||
}
|
||||
|
||||
public function show($id = null): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('customers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$customer = $this->customerModel->get_info($id);
|
||||
|
||||
if (empty($customer) || $customer->deleted) {
|
||||
return $this->respondNotFound('Customer not found');
|
||||
}
|
||||
|
||||
$person = (array) $this->personModel->get_info($id);
|
||||
$customer = (array) $customer;
|
||||
$data = array_merge($person, $customer);
|
||||
|
||||
return $this->respondSuccess($this->transformItem($data));
|
||||
}
|
||||
|
||||
public function create(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('customers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$data = $this->request->getJSON(true);
|
||||
if (empty($data)) {
|
||||
$data = $this->request->getPost();
|
||||
}
|
||||
|
||||
$data = $this->toSnakeCase($data);
|
||||
|
||||
$rules = [
|
||||
'first_name' => 'required|max_length[255]',
|
||||
'last_name' => 'required|max_length[255]',
|
||||
];
|
||||
|
||||
$snakeData = [];
|
||||
foreach ($data as $key => $value) {
|
||||
$snakeKey = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
|
||||
$snakeData[$snakeKey] = $value;
|
||||
}
|
||||
|
||||
$personData = array_intersect_key($snakeData, array_flip([
|
||||
'first_name', 'last_name', 'gender', 'phone_number', 'email',
|
||||
'address_1', 'address_2', 'city', 'state', 'zip', 'country', 'comments'
|
||||
]));
|
||||
|
||||
$customerData = array_intersect_key($snakeData, array_flip([
|
||||
'account_number', 'taxable', 'tax_id', 'sales_tax_code_id',
|
||||
'discount', 'discount_type', 'company_name', 'package_id', 'consent'
|
||||
]));
|
||||
$customerData['employee_id'] = $this->employeeId;
|
||||
|
||||
$personId = false;
|
||||
$success = $this->personModel->save_value($personData);
|
||||
|
||||
if ($success && isset($personData['person_id'])) {
|
||||
$personId = $personData['person_id'];
|
||||
$customerData['person_id'] = $personId;
|
||||
$success = $this->customerModel->save_value($customerData);
|
||||
}
|
||||
|
||||
if ($success) {
|
||||
return $this->respondCreated(['id' => $personId], 'Customer created successfully');
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to create customer');
|
||||
}
|
||||
|
||||
public function update($id = null): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('customers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$customer = $this->customerModel->get_info($id);
|
||||
|
||||
if (empty($customer) || $customer->deleted) {
|
||||
return $this->respondNotFound('Customer not found');
|
||||
}
|
||||
|
||||
$data = $this->request->getJSON(true);
|
||||
if (empty($data)) {
|
||||
$data = $this->request->getRawInput();
|
||||
}
|
||||
|
||||
$snakeData = [];
|
||||
foreach ($data as $key => $value) {
|
||||
$snakeKey = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
|
||||
$snakeData[$snakeKey] = $value;
|
||||
}
|
||||
|
||||
$personData = array_intersect_key($snakeData, array_flip([
|
||||
'first_name', 'last_name', 'gender', 'phone_number', 'email',
|
||||
'address_1', 'address_2', 'city', 'state', 'zip', 'country', 'comments'
|
||||
]));
|
||||
|
||||
$customerData = array_intersect_key($snakeData, array_flip([
|
||||
'account_number', 'taxable', 'tax_id', 'sales_tax_code_id',
|
||||
'discount', 'discount_type', 'company_name', 'package_id', 'consent'
|
||||
]));
|
||||
|
||||
if (!empty($personData)) {
|
||||
$this->personModel->save_value($personData, $id);
|
||||
}
|
||||
|
||||
if (!empty($customerData)) {
|
||||
$this->customerModel->save_value($customerData, $id);
|
||||
}
|
||||
|
||||
return $this->respondSuccess([], 200, 'Customer updated successfully');
|
||||
}
|
||||
|
||||
public function delete($id = null): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('customers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$customer = $this->customerModel->get_info($id);
|
||||
|
||||
if (empty($customer) || $customer->deleted) {
|
||||
return $this->respondNotFound('Customer not found');
|
||||
}
|
||||
|
||||
$success = $this->customerModel->delete($id);
|
||||
|
||||
if ($success) {
|
||||
return $this->respondSuccess([], 200, 'Customer deleted successfully');
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to delete customer');
|
||||
}
|
||||
|
||||
public function batchDelete(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('customers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$data = $this->request->getJSON(true);
|
||||
$ids = $data['ids'] ?? [];
|
||||
|
||||
if (empty($ids)) {
|
||||
return $this->respondError('No customer IDs provided');
|
||||
}
|
||||
|
||||
$success = $this->customerModel->delete_list($ids);
|
||||
|
||||
if ($success) {
|
||||
return $this->respondSuccess([], 200, 'Customers deleted successfully');
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to delete customers');
|
||||
}
|
||||
|
||||
public function suggest(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('customers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$term = $this->request->getGet('term');
|
||||
$limit = (int) ($this->request->getGet('limit') ?? 25);
|
||||
|
||||
if (empty($term)) {
|
||||
return $this->respondSuccess(['suggestions' => []]);
|
||||
}
|
||||
|
||||
$suggestions = $this->customerModel->get_search_suggestions($term, $limit);
|
||||
|
||||
return $this->respondSuccess(['suggestions' => $suggestions]);
|
||||
}
|
||||
|
||||
private function mapSortField(string $field): string
|
||||
{
|
||||
$map = [
|
||||
'personId' => 'people.person_id',
|
||||
'lastName' => 'people.last_name',
|
||||
'firstName' => 'people.first_name',
|
||||
'email' => 'people.email',
|
||||
'companyName' => 'customers.company_name'
|
||||
];
|
||||
|
||||
return $map[$field] ?? 'people.last_name';
|
||||
}
|
||||
}
|
||||
212
app/Controllers/Api/Inventory.php
Normal file
212
app/Controllers/Api/Inventory.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api;
|
||||
|
||||
use App\Models\Inventory as InventoryModel;
|
||||
use App\Models\Item;
|
||||
use App\Models\Item_quantity;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
class Inventory extends BaseController
|
||||
{
|
||||
protected InventoryModel $inventory;
|
||||
protected Item $item;
|
||||
protected Item_quantity $itemQuantity;
|
||||
|
||||
protected array $allowedSortFields = ['trans_id', 'trans_date', 'trans_items'];
|
||||
|
||||
public function initController(\CodeIgniter\HTTP\RequestInterface $request, \CodeIgniter\HTTP\ResponseInterface $response, \Psr\Log\LoggerInterface $logger): void
|
||||
{
|
||||
parent::initController($request, $response, $logger);
|
||||
$this->inventory = model(InventoryModel::class);
|
||||
$this->item = model(Item::class);
|
||||
$this->itemQuantity = model(Item_quantity::class);
|
||||
}
|
||||
|
||||
public function index(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('items')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$itemId = $this->request->getGet('itemId');
|
||||
$locationId = $this->request->getGet('locationId');
|
||||
$pagination = $this->getPagination();
|
||||
$sort = $this->getSort($this->allowedSortFields, 'trans_date');
|
||||
|
||||
$builder = $this->inventory->builder();
|
||||
|
||||
if ($itemId) {
|
||||
$builder->where('trans_items', $itemId);
|
||||
}
|
||||
|
||||
if ($locationId) {
|
||||
$builder->where('trans_location', $locationId);
|
||||
}
|
||||
|
||||
$total = $builder->countAllResults(false);
|
||||
|
||||
$builder->orderBy($sort['sort'], $sort['order']);
|
||||
$builder->limit($pagination['limit'], $pagination['offset']);
|
||||
|
||||
$transactions = $builder->get()->getResultArray();
|
||||
|
||||
return $this->respondSuccess([
|
||||
'total' => $total,
|
||||
'offset' => $pagination['offset'],
|
||||
'limit' => $pagination['limit'],
|
||||
'rows' => $this->transformCollection($transactions)
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('items')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$data = $this->request->getJSON(true);
|
||||
|
||||
if (isset($data['adjustments']) && is_array($data['adjustments'])) {
|
||||
return $this->bulkAdjust($data['adjustments']);
|
||||
}
|
||||
|
||||
return $this->singleAdjust($data);
|
||||
}
|
||||
|
||||
private function singleAdjust(array $data): ResponseInterface
|
||||
{
|
||||
if (empty($data['itemId'])) {
|
||||
return $this->respondError('itemId is required');
|
||||
}
|
||||
|
||||
if (!isset($data['quantity'])) {
|
||||
return $this->respondError('quantity is required');
|
||||
}
|
||||
|
||||
$mode = $data['mode'] ?? 'adjust';
|
||||
|
||||
if (!in_array($mode, ['adjust', 'set'])) {
|
||||
return $this->respondError('mode must be "adjust" or "set"');
|
||||
}
|
||||
|
||||
$item = $this->item->find($data['itemId']);
|
||||
if (!$item || $item->deleted) {
|
||||
return $this->respondNotFound('Item not found');
|
||||
}
|
||||
|
||||
$locationId = $data['locationId'] ?? 1;
|
||||
$comment = $data['comment'] ?? 'API inventory adjustment';
|
||||
$quantity = (float) $data['quantity'];
|
||||
|
||||
if ($mode === 'set') {
|
||||
$currentQty = $this->itemQuantity->get_item_quantity($data['itemId'], $locationId);
|
||||
$currentQty = $currentQty ? (float) $currentQty->quantity : 0;
|
||||
$adjustment = $quantity - $currentQty;
|
||||
|
||||
if ($adjustment == 0) {
|
||||
return $this->respondSuccess([
|
||||
'itemId' => (int) $data['itemId'],
|
||||
'locationId' => (int) $locationId,
|
||||
'newQuantity' => $quantity,
|
||||
'mode' => $mode
|
||||
], 200, 'Quantity already at requested level');
|
||||
}
|
||||
} else {
|
||||
$adjustment = $quantity;
|
||||
}
|
||||
|
||||
$invData = [
|
||||
'trans_date' => date('Y-m-d H:i:s'),
|
||||
'trans_items' => $data['itemId'],
|
||||
'trans_user' => $this->employeeId,
|
||||
'trans_location' => $locationId,
|
||||
'trans_comment' => $comment,
|
||||
'trans_inventory' => $adjustment
|
||||
];
|
||||
|
||||
$this->inventory->insert($invData);
|
||||
$this->itemQuantity->change_quantity($data['itemId'], $locationId, $adjustment);
|
||||
|
||||
$newQty = $this->itemQuantity->get_item_quantity($data['itemId'], $locationId);
|
||||
|
||||
return $this->respondSuccess([
|
||||
'itemId' => (int) $data['itemId'],
|
||||
'locationId' => (int) $locationId,
|
||||
'adjustment' => $adjustment,
|
||||
'newQuantity' => $newQty ? (float) $newQty->quantity : 0,
|
||||
'mode' => $mode
|
||||
], 200, 'Inventory adjusted successfully');
|
||||
}
|
||||
|
||||
private function bulkAdjust(array $adjustments): ResponseInterface
|
||||
{
|
||||
$results = [];
|
||||
$processed = 0;
|
||||
$errors = [];
|
||||
|
||||
$this->inventory->db->transStart();
|
||||
|
||||
foreach ($adjustments as $adjustment) {
|
||||
$itemId = $adjustment['itemId'] ?? $adjustment['item_id'] ?? null;
|
||||
|
||||
if (!$itemId) {
|
||||
$errors[] = ['itemId' => null, 'success' => false, 'error' => 'itemId is required'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$item = $this->item->find($itemId);
|
||||
if (!$item || $item->deleted) {
|
||||
$errors[] = ['itemId' => $itemId, 'success' => false, 'error' => 'Item not found'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$mode = $adjustment['mode'] ?? 'adjust';
|
||||
$locationId = $adjustment['locationId'] ?? $adjustment['location_id'] ?? 1;
|
||||
$quantity = (float) ($adjustment['quantity'] ?? 0);
|
||||
$comment = $adjustment['comment'] ?? 'Bulk API inventory adjustment';
|
||||
|
||||
if ($mode === 'set') {
|
||||
$currentQty = $this->itemQuantity->get_item_quantity($itemId, $locationId);
|
||||
$currentQty = $currentQty ? (float) $currentQty->quantity : 0;
|
||||
$adjustmentQty = $quantity - $currentQty;
|
||||
} else {
|
||||
$adjustmentQty = $quantity;
|
||||
}
|
||||
|
||||
$invData = [
|
||||
'trans_date' => date('Y-m-d H:i:s'),
|
||||
'trans_items' => $itemId,
|
||||
'trans_user' => $this->employeeId,
|
||||
'trans_location' => $locationId,
|
||||
'trans_comment' => $comment,
|
||||
'trans_inventory' => $adjustmentQty
|
||||
];
|
||||
|
||||
$this->inventory->insert($invData);
|
||||
$this->itemQuantity->change_quantity($itemId, $locationId, $adjustmentQty);
|
||||
|
||||
$results[] = ['itemId' => $itemId, 'success' => true];
|
||||
$processed++;
|
||||
}
|
||||
|
||||
$this->inventory->db->transComplete();
|
||||
|
||||
$response = [
|
||||
'processed' => $processed,
|
||||
'total' => count($adjustments),
|
||||
'results' => $results
|
||||
];
|
||||
|
||||
if (!empty($errors)) {
|
||||
$response['errors'] = $errors;
|
||||
$response['success'] = false;
|
||||
$response['message'] = 'Some adjustments failed';
|
||||
} else {
|
||||
$response['success'] = true;
|
||||
$response['message'] = 'All adjustments processed successfully';
|
||||
}
|
||||
|
||||
return $this->respondSuccess($response);
|
||||
}
|
||||
}
|
||||
237
app/Controllers/Api/Items.php
Normal file
237
app/Controllers/Api/Items.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api;
|
||||
|
||||
use App\Models\Item;
|
||||
use App\Models\Item_quantity;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
class Items extends BaseController
|
||||
{
|
||||
protected Item $itemModel;
|
||||
protected Item_quantity $itemQuantityModel;
|
||||
|
||||
protected array $allowedSortFields = ['item_id', 'name', 'category', 'cost_price', 'unit_price'];
|
||||
|
||||
public function initController(\CodeIgniter\HTTP\RequestInterface $request, \CodeIgniter\HTTP\ResponseInterface $response, \Psr\Log\LoggerInterface $logger): void
|
||||
{
|
||||
parent::initController($request, $response, $logger);
|
||||
$this->itemModel = model(Item::class);
|
||||
$this->itemQuantityModel = model(Item_quantity::class);
|
||||
}
|
||||
|
||||
public function index(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('items')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$search = $this->request->getGet('search');
|
||||
$pagination = $this->getPagination();
|
||||
$sort = $this->getSort($this->allowedSortFields, 'name');
|
||||
$stockLocation = $this->request->getGet('stockLocation');
|
||||
|
||||
$builder = $this->itemModel->builder();
|
||||
$builder->where('deleted', 0);
|
||||
|
||||
if ($search) {
|
||||
$builder->groupStart();
|
||||
$builder->like('name', $search);
|
||||
$builder->orLike('item_number', $search);
|
||||
$builder->orLike('category', $search);
|
||||
$builder->orLike('description', $search);
|
||||
$builder->groupEnd();
|
||||
}
|
||||
|
||||
$total = $builder->countAllResults(false);
|
||||
|
||||
$dbSort = $this->mapSortField($sort['sort']);
|
||||
$builder->orderBy($dbSort, $sort['order']);
|
||||
$builder->limit($pagination['limit'], $pagination['offset']);
|
||||
|
||||
$items = $builder->get()->getResultArray();
|
||||
|
||||
return $this->respondSuccess([
|
||||
'total' => $total,
|
||||
'offset' => $pagination['offset'],
|
||||
'limit' => $pagination['limit'],
|
||||
'rows' => $this->transformCollection($items)
|
||||
]);
|
||||
}
|
||||
|
||||
public function show($id = null): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('items')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$item = $this->itemModel->find($id);
|
||||
|
||||
if (!$item || $item->deleted) {
|
||||
return $this->respondNotFound('Item not found');
|
||||
}
|
||||
|
||||
return $this->respondSuccess($this->transformItem($item));
|
||||
}
|
||||
|
||||
public function create(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('items')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$data = $this->request->getJSON(true);
|
||||
if (empty($data)) {
|
||||
$data = $this->request->getPost();
|
||||
}
|
||||
|
||||
$snakeData = $this->toSnakeCase($data);
|
||||
|
||||
if (!empty($snakeData['item_number'])) {
|
||||
if ($this->itemModel->item_number_exists($snakeData['item_number'])) {
|
||||
return $this->respondError('Item number already exists', 409);
|
||||
}
|
||||
}
|
||||
|
||||
$itemId = $this->itemModel->save_value($snakeData);
|
||||
|
||||
if ($itemId) {
|
||||
return $this->respondCreated(['id' => $itemId], 'Item created successfully');
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to create item');
|
||||
}
|
||||
|
||||
public function update($id = null): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('items')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$item = $this->itemModel->find($id);
|
||||
|
||||
if (!$item || $item->deleted) {
|
||||
return $this->respondNotFound('Item not found');
|
||||
}
|
||||
|
||||
$data = $this->request->getJSON(true);
|
||||
if (empty($data)) {
|
||||
$data = $this->request->getRawInput();
|
||||
}
|
||||
|
||||
$snakeData = $this->toSnakeCase($data);
|
||||
$snakeData['item_id'] = $id;
|
||||
|
||||
$success = $this->itemModel->save_value($snakeData);
|
||||
|
||||
if ($success) {
|
||||
return $this->respondSuccess([], 200, 'Item updated successfully');
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to update item');
|
||||
}
|
||||
|
||||
public function delete($id = null): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('items')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$item = $this->itemModel->find($id);
|
||||
|
||||
if (!$item || $item->deleted) {
|
||||
return $this->respondNotFound('Item not found');
|
||||
}
|
||||
|
||||
$success = $this->itemModel->delete($id);
|
||||
|
||||
if ($success) {
|
||||
return $this->respondSuccess([], 200, 'Item deleted successfully');
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to delete item');
|
||||
}
|
||||
|
||||
public function batchDelete(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('items')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$data = $this->request->getJSON(true);
|
||||
$ids = $data['ids'] ?? [];
|
||||
|
||||
if (empty($ids)) {
|
||||
return $this->respondError('No item IDs provided');
|
||||
}
|
||||
|
||||
$success = $this->itemModel->delete_list($ids);
|
||||
|
||||
if ($success) {
|
||||
return $this->respondSuccess([], 200, 'Items deleted successfully');
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to delete items');
|
||||
}
|
||||
|
||||
public function quantities($id = null): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('items')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$item = $this->itemModel->find($id);
|
||||
|
||||
if (!$item || $item->deleted) {
|
||||
return $this->respondNotFound('Item not found');
|
||||
}
|
||||
|
||||
$locations = model('App\Models\Stock_location')->get_all();
|
||||
$quantities = [];
|
||||
|
||||
foreach ($locations as $location) {
|
||||
$qty = $this->itemQuantityModel->get_item_quantity($id, $location->location_id);
|
||||
$quantities[] = [
|
||||
'locationId' => (int) $location->location_id,
|
||||
'locationName' => $location->location_name,
|
||||
'quantity' => $qty ? (float) $qty->quantity : 0
|
||||
];
|
||||
}
|
||||
|
||||
return $this->respondSuccess([
|
||||
'itemId' => (int) $id,
|
||||
'quantities' => $quantities
|
||||
]);
|
||||
}
|
||||
|
||||
public function suggest(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('items')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$term = $this->request->getGet('term');
|
||||
$limit = (int) ($this->request->getGet('limit') ?? 25);
|
||||
|
||||
if (empty($term)) {
|
||||
return $this->respondSuccess(['suggestions' => []]);
|
||||
}
|
||||
|
||||
$suggestions = $this->itemModel->get_search_suggestions($term, $limit);
|
||||
|
||||
return $this->respondSuccess(['suggestions' => $suggestions]);
|
||||
}
|
||||
|
||||
private function mapSortField(string $field): string
|
||||
{
|
||||
$map = [
|
||||
'itemId' => 'item_id',
|
||||
'name' => 'name',
|
||||
'category' => 'category',
|
||||
'costPrice' => 'cost_price',
|
||||
'unitPrice' => 'unit_price'
|
||||
];
|
||||
|
||||
return $map[$field] ?? 'name';
|
||||
}
|
||||
}
|
||||
239
app/Controllers/Api/Suppliers.php
Normal file
239
app/Controllers/Api/Suppliers.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api;
|
||||
|
||||
use App\Models\Supplier;
|
||||
use App\Models\Person;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
class Suppliers extends BaseController
|
||||
{
|
||||
protected Supplier $supplierModel;
|
||||
protected Person $personModel;
|
||||
|
||||
protected array $allowedSortFields = ['person_id', 'last_name', 'company_name'];
|
||||
|
||||
public function initController(\CodeIgniter\HTTP\RequestInterface $request, \CodeIgniter\HTTP\ResponseInterface $response, \Psr\Log\LoggerInterface $logger): void
|
||||
{
|
||||
parent::initController($request, $response, $logger);
|
||||
$this->supplierModel = model(Supplier::class);
|
||||
$this->personModel = model(Person::class);
|
||||
}
|
||||
|
||||
public function index(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('suppliers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$search = $this->request->getGet('search');
|
||||
$pagination = $this->getPagination();
|
||||
$sort = $this->getSort($this->allowedSortFields, 'companyName');
|
||||
|
||||
$builder = $this->supplierModel->builder();
|
||||
$builder->select('suppliers.*, people.*');
|
||||
$builder->join('people', 'people.person_id = suppliers.person_id');
|
||||
$builder->where('suppliers.deleted', 0);
|
||||
|
||||
if ($search) {
|
||||
$builder->groupStart();
|
||||
$builder->like('people.first_name', $search);
|
||||
$builder->orLike('people.last_name', $search);
|
||||
$builder->orLike('people.email', $search);
|
||||
$builder->orLike('suppliers.account_number', $search);
|
||||
$builder->orLike('suppliers.company_name', $search);
|
||||
$builder->groupEnd();
|
||||
}
|
||||
|
||||
$total = $builder->countAllResults(false);
|
||||
|
||||
$dbSort = $this->mapSortField($sort['sort']);
|
||||
$builder->orderBy($dbSort, $sort['order']);
|
||||
$builder->limit($pagination['limit'], $pagination['offset']);
|
||||
|
||||
$suppliers = $builder->get()->getResultArray();
|
||||
|
||||
return $this->respondSuccess([
|
||||
'total' => $total,
|
||||
'offset' => $pagination['offset'],
|
||||
'limit' => $pagination['limit'],
|
||||
'rows' => $this->transformCollection($suppliers)
|
||||
]);
|
||||
}
|
||||
|
||||
public function show($id = null): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('suppliers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$supplier = $this->supplierModel->get_info($id);
|
||||
|
||||
if (empty($supplier) || $supplier->deleted) {
|
||||
return $this->respondNotFound('Supplier not found');
|
||||
}
|
||||
|
||||
$person = (array) $this->personModel->get_info($id);
|
||||
$supplier = (array) $supplier;
|
||||
$data = array_merge($person, $supplier);
|
||||
|
||||
return $this->respondSuccess($this->transformItem($data));
|
||||
}
|
||||
|
||||
public function create(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('suppliers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$data = $this->request->getJSON(true);
|
||||
if (empty($data)) {
|
||||
$data = $this->request->getPost();
|
||||
}
|
||||
|
||||
$snakeData = [];
|
||||
foreach ($data as $key => $value) {
|
||||
$snakeKey = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
|
||||
$snakeData[$snakeKey] = $value;
|
||||
}
|
||||
|
||||
$personData = array_intersect_key($snakeData, array_flip([
|
||||
'first_name', 'last_name', 'gender', 'phone_number', 'email',
|
||||
'address_1', 'address_2', 'city', 'state', 'zip', 'country', 'comments'
|
||||
]));
|
||||
|
||||
$supplierData = array_intersect_key($snakeData, array_flip([
|
||||
'company_name', 'account_number', 'tax_id', 'agency_name', 'category'
|
||||
]));
|
||||
|
||||
$personId = false;
|
||||
$success = $this->personModel->save_value($personData);
|
||||
|
||||
if ($success && isset($personData['person_id'])) {
|
||||
$personId = $personData['person_id'];
|
||||
$supplierData['person_id'] = $personId;
|
||||
$success = $this->supplierModel->save_value($supplierData);
|
||||
}
|
||||
|
||||
if ($success) {
|
||||
return $this->respondCreated(['id' => $personId], 'Supplier created successfully');
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to create supplier');
|
||||
}
|
||||
|
||||
public function update($id = null): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('suppliers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$supplier = $this->supplierModel->get_info($id);
|
||||
|
||||
if (empty($supplier) || $supplier->deleted) {
|
||||
return $this->respondNotFound('Supplier not found');
|
||||
}
|
||||
|
||||
$data = $this->request->getJSON(true);
|
||||
if (empty($data)) {
|
||||
$data = $this->request->getRawInput();
|
||||
}
|
||||
|
||||
$snakeData = [];
|
||||
foreach ($data as $key => $value) {
|
||||
$snakeKey = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
|
||||
$snakeData[$snakeKey] = $value;
|
||||
}
|
||||
|
||||
$personData = array_intersect_key($snakeData, array_flip([
|
||||
'first_name', 'last_name', 'gender', 'phone_number', 'email',
|
||||
'address_1', 'address_2', 'city', 'state', 'zip', 'country', 'comments'
|
||||
]));
|
||||
|
||||
$supplierData = array_intersect_key($snakeData, array_flip([
|
||||
'company_name', 'account_number', 'tax_id', 'agency_name', 'category'
|
||||
]));
|
||||
|
||||
if (!empty($personData)) {
|
||||
$this->personModel->save_value($personData, $id);
|
||||
}
|
||||
|
||||
if (!empty($supplierData)) {
|
||||
$this->supplierModel->save_value($supplierData, $id);
|
||||
}
|
||||
|
||||
return $this->respondSuccess([], 200, 'Supplier updated successfully');
|
||||
}
|
||||
|
||||
public function delete($id = null): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('suppliers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$supplier = $this->supplierModel->get_info($id);
|
||||
|
||||
if (empty($supplier) || $supplier->deleted) {
|
||||
return $this->respondNotFound('Supplier not found');
|
||||
}
|
||||
|
||||
$success = $this->supplierModel->delete($id);
|
||||
|
||||
if ($success) {
|
||||
return $this->respondSuccess([], 200, 'Supplier deleted successfully');
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to delete supplier');
|
||||
}
|
||||
|
||||
public function batchDelete(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('suppliers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$data = $this->request->getJSON(true);
|
||||
$ids = $data['ids'] ?? [];
|
||||
|
||||
if (empty($ids)) {
|
||||
return $this->respondError('No supplier IDs provided');
|
||||
}
|
||||
|
||||
$success = $this->supplierModel->delete_list($ids);
|
||||
|
||||
if ($success) {
|
||||
return $this->respondSuccess([], 200, 'Suppliers deleted successfully');
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to delete suppliers');
|
||||
}
|
||||
|
||||
public function suggest(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('suppliers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$term = $this->request->getGet('term');
|
||||
$limit = (int) ($this->request->getGet('limit') ?? 25);
|
||||
|
||||
if (empty($term)) {
|
||||
return $this->respondSuccess(['suggestions' => []]);
|
||||
}
|
||||
|
||||
$suggestions = $this->supplierModel->get_search_suggestions($term, $limit);
|
||||
|
||||
return $this->respondSuccess(['suggestions' => $suggestions]);
|
||||
}
|
||||
|
||||
private function mapSortField(string $field): string
|
||||
{
|
||||
$map = [
|
||||
'personId' => 'people.person_id',
|
||||
'lastName' => 'people.last_name',
|
||||
'companyName' => 'suppliers.company_name'
|
||||
];
|
||||
|
||||
return $map[$field] ?? 'suppliers.company_name';
|
||||
}
|
||||
}
|
||||
84
app/Controllers/ApiKeys.php
Normal file
84
app/Controllers/ApiKeys.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\ApiKey;
|
||||
use App\Models\Employee;
|
||||
|
||||
class ApiKeys extends Secure_Controller
|
||||
{
|
||||
protected ApiKey $apiKeyModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('api_keys');
|
||||
$this->apiKeyModel = model(ApiKey::class);
|
||||
}
|
||||
|
||||
public function index(): void
|
||||
{
|
||||
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
|
||||
$keys = $this->apiKeyModel->getKeysForEmployee($employeeId);
|
||||
|
||||
echo view('api_keys/manage', [
|
||||
'keys' => $keys,
|
||||
'employee_info' => $this->employee->get_logged_in_employee_info()
|
||||
]);
|
||||
}
|
||||
|
||||
public function generate(): void
|
||||
{
|
||||
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
|
||||
$name = $this->request->getPost('name');
|
||||
$expiresAt = $this->request->getPost('expires_at') ?: null;
|
||||
|
||||
$apiKey = $this->apiKeyModel->generateKey($employeeId, $name, $expiresAt);
|
||||
|
||||
if ($apiKey) {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => lang('Api_keys.key_generated'),
|
||||
'apiKey' => $apiKey,
|
||||
'keyPrefix' => substr($apiKey, 0, 12) . '...'
|
||||
]);
|
||||
} else {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => lang('Api_keys.key_generation_failed')
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function revoke(int $apiKeyId): void
|
||||
{
|
||||
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
|
||||
|
||||
$success = $this->apiKeyModel->revokeKey($apiKeyId, $employeeId);
|
||||
|
||||
echo json_encode([
|
||||
'success' => $success,
|
||||
'message' => $success ? lang('Api_keys.key_revoked') : lang('Api_keys.key_revoke_failed')
|
||||
]);
|
||||
}
|
||||
|
||||
public function regenerate(int $apiKeyId): void
|
||||
{
|
||||
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
|
||||
|
||||
$newKey = $this->apiKeyModel->regenerateKey($apiKeyId, $employeeId);
|
||||
|
||||
if ($newKey) {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => lang('Api_keys.key_regenerated'),
|
||||
'apiKey' => $newKey,
|
||||
'keyPrefix' => substr($newKey, 0, 12) . '...'
|
||||
]);
|
||||
} else {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => lang('Api_keys.key_regeneration_failed')
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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')]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
];
|
||||
|
||||
|
||||
96
app/Database/Migrations/20250310000000_ApiKeys.php
Normal file
96
app/Database/Migrations/20250310000000_ApiKeys.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class ApiKeys extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->forge->addField([
|
||||
'api_key_id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'unsigned' => true,
|
||||
'auto_increment' => true
|
||||
],
|
||||
'employee_id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 10
|
||||
],
|
||||
'key_hash' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 64
|
||||
],
|
||||
'key_prefix' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 12
|
||||
],
|
||||
'name' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 255,
|
||||
'null' => true
|
||||
],
|
||||
'last_used' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true
|
||||
],
|
||||
'created' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true
|
||||
],
|
||||
'expires_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true
|
||||
],
|
||||
'disabled' => [
|
||||
'type' => 'TINYINT',
|
||||
'constraint' => 1,
|
||||
'default' => 0
|
||||
]
|
||||
]);
|
||||
|
||||
$this->forge->addKey('api_key_id', true);
|
||||
$this->forge->addKey('employee_id');
|
||||
$this->forge->addKey('key_hash');
|
||||
|
||||
$this->forge->createTable('api_keys', true);
|
||||
|
||||
$this->db->query(
|
||||
'ALTER TABLE ' . $this->db->prefixTable('api_keys') .
|
||||
' ADD CONSTRAINT ' . $this->db->prefixTable('api_keys') . '_employee_id_foreign' .
|
||||
' FOREIGN KEY (employee_id) REFERENCES ' . $this->db->prefixTable('employees') .
|
||||
' (person_id) ON DELETE CASCADE ON UPDATE CASCADE'
|
||||
);
|
||||
|
||||
$this->db->query(
|
||||
'INSERT INTO ' . $this->db->prefixTable('permissions') . ' (permission_id, module_id)' .
|
||||
" VALUES ('api_keys', 'office') ON DUPLICATE KEY UPDATE permission_id = 'api_keys'"
|
||||
);
|
||||
|
||||
$this->db->query(
|
||||
'INSERT INTO ' . $this->db->prefixTable('modules') . ' (module_id, name_lang_key, desc_lang_key, sort)' .
|
||||
" VALUES ('api_keys', 'module_api_keys', 'module_desc_api_keys', 25)" .
|
||||
" ON DUPLICATE KEY UPDATE module_id = 'module_id'"
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->db->query(
|
||||
'ALTER TABLE ' . $this->db->prefixTable('api_keys') .
|
||||
' DROP FOREIGN KEY ' . $this->db->prefixTable('api_keys') . '_employee_id_foreign'
|
||||
);
|
||||
|
||||
$this->db->query(
|
||||
'DELETE FROM ' . $this->db->prefixTable('permissions') . " WHERE permission_id = 'api_keys'"
|
||||
);
|
||||
|
||||
$this->db->query(
|
||||
'DELETE FROM ' . $this->db->prefixTable('modules') . " WHERE module_id = 'api_keys'"
|
||||
);
|
||||
|
||||
$this->forge->dropTable('api_keys', true);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
48
app/Filters/ApiAuth.php
Normal file
48
app/Filters/ApiAuth.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use App\Models\ApiKey;
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Config\Services;
|
||||
|
||||
class ApiAuth implements FilterInterface
|
||||
{
|
||||
public function before(RequestInterface $request, $arguments = null): mixed
|
||||
{
|
||||
$apiKey = $request->getHeaderLine('X-API-Key');
|
||||
|
||||
if (empty($apiKey)) {
|
||||
return $this->unauthorized('API key required');
|
||||
}
|
||||
|
||||
$apiKeyModel = model(ApiKey::class);
|
||||
$employeeId = $apiKeyModel->validateKey($apiKey);
|
||||
|
||||
if (!$employeeId) {
|
||||
return $this->unauthorized('Invalid or expired API key');
|
||||
}
|
||||
|
||||
$request->employeeId = $employeeId;
|
||||
Services::set('apiEmployeeId', $employeeId);
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): mixed
|
||||
{
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function unauthorized(string $message): ResponseInterface
|
||||
{
|
||||
return Services::response()
|
||||
->setStatusCode(401)
|
||||
->setJSON([
|
||||
'success' => false,
|
||||
'message' => $message
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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('(^$| )', 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('(^$| )', 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>';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
29
app/Language/en/Api_keys.php
Normal file
29
app/Language/en/Api_keys.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'module_api_keys' => 'API Keys',
|
||||
'module_desc_api_keys' => 'Manage API access keys for integrations.',
|
||||
'api_keys' => 'API Keys',
|
||||
'api_key' => 'API Key',
|
||||
'generate_key' => 'Generate API Key',
|
||||
'regenerate_key' => 'Regenerate',
|
||||
'revoke_key' => 'Revoke',
|
||||
'key_name' => 'Key Name',
|
||||
'key_prefix' => 'Key Prefix',
|
||||
'last_used' => 'Last Used',
|
||||
'created' => 'Created',
|
||||
'expires' => 'Expires',
|
||||
'never' => 'Never',
|
||||
'disabled' => 'Disabled',
|
||||
'key_generated' => 'API key generated successfully',
|
||||
'key_generation_failed' => 'Failed to generate API key',
|
||||
'key_revoked' => 'API key revoked successfully',
|
||||
'key_revoke_failed' => 'Failed to revoke API key',
|
||||
'key_regenerated' => 'API key regenerated successfully',
|
||||
'key_regeneration_failed' => 'Failed to regenerate API key',
|
||||
'copy_warning' => 'Copy this key now! It will not be shown again.',
|
||||
'no_keys' => 'No API keys have been generated yet.',
|
||||
'confirm_revoke' => 'Are you sure you want to revoke this API key? This action cannot be undone.',
|
||||
'confirm_regenerate' => 'Are you sure you want to regenerate this API key? The old key will immediately stop working.',
|
||||
'key_description' => 'API keys allow external applications to access your OSPOS data. Keep your keys secure and never share them publicly.',
|
||||
];
|
||||
145
app/Models/ApiKey.php
Normal file
145
app/Models/ApiKey.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class ApiKey extends Model
|
||||
{
|
||||
protected $table = 'api_keys';
|
||||
protected $primaryKey = 'api_key_id';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $useSoftDeletes = false;
|
||||
protected $allowedFields = [
|
||||
'employee_id',
|
||||
'key_hash',
|
||||
'key_prefix',
|
||||
'name',
|
||||
'last_used',
|
||||
'expires_at',
|
||||
'disabled'
|
||||
];
|
||||
|
||||
protected $useTimestamps = false;
|
||||
protected $createdField = 'created';
|
||||
|
||||
private const KEY_PREFIX = 'ospos_';
|
||||
private const KEY_BYTES = 32;
|
||||
|
||||
public function generateKey(int $employeeId, ?string $name = null, ?string $expiresAt = null): string|false
|
||||
{
|
||||
$rawKey = bin2hex(random_bytes(self::KEY_BYTES));
|
||||
$apiKey = self::KEY_PREFIX . $rawKey;
|
||||
|
||||
$keyHash = hash('sha256', $apiKey);
|
||||
$keyPrefix = substr($apiKey, 0, 12);
|
||||
|
||||
$data = [
|
||||
'employee_id' => $employeeId,
|
||||
'key_hash' => $keyHash,
|
||||
'key_prefix' => $keyPrefix,
|
||||
'name' => $name,
|
||||
'expires_at' => $expiresAt
|
||||
];
|
||||
|
||||
if ($this->insert($data)) {
|
||||
return $apiKey;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function validateKey(string $apiKey): int|false
|
||||
{
|
||||
if (!str_starts_with($apiKey, self::KEY_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (strlen($apiKey) !== strlen(self::KEY_PREFIX) + (self::KEY_BYTES * 2)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$keyHash = hash('sha256', $apiKey);
|
||||
|
||||
$builder = $this->builder();
|
||||
$builder->where('key_hash', $keyHash);
|
||||
$builder->where('disabled', 0);
|
||||
$builder->groupStart();
|
||||
$builder->where('expires_at IS NULL');
|
||||
$builder->orWhere('expires_at >', date('Y-m-d H:i:s'));
|
||||
$builder->groupEnd();
|
||||
|
||||
$result = $builder->get()->getRow();
|
||||
|
||||
if ($result) {
|
||||
$this->update($result->api_key_id, ['last_used' => date('Y-m-d H:i:s')]);
|
||||
return (int) $result->employee_id;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getKeysForEmployee(int $employeeId): array
|
||||
{
|
||||
$builder = $this->builder();
|
||||
$builder->where('employee_id', $employeeId);
|
||||
$builder->orderBy('created', 'DESC');
|
||||
|
||||
return $builder->get()->getResultArray();
|
||||
}
|
||||
|
||||
public function revokeKey(int $apiKeyId, int $employeeId): bool
|
||||
{
|
||||
$builder = $this->builder();
|
||||
$builder->where('api_key_id', $apiKeyId);
|
||||
$builder->where('employee_id', $employeeId);
|
||||
|
||||
return $builder->update(['disabled' => 1]) !== false;
|
||||
}
|
||||
|
||||
public function regenerateKey(int $apiKeyId, int $employeeId): string|false
|
||||
{
|
||||
$existingKey = $this->builder()
|
||||
->getWhere([
|
||||
'api_key_id' => $apiKeyId,
|
||||
'employee_id' => $employeeId
|
||||
])
|
||||
->getRow();
|
||||
|
||||
if (!$existingKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$newKey = $this->generateKey(
|
||||
$employeeId,
|
||||
$existingKey->name,
|
||||
$existingKey->expires_at
|
||||
);
|
||||
|
||||
if ($newKey) {
|
||||
$this->delete($apiKeyId);
|
||||
return $newKey;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function cleanupExpired(): int
|
||||
{
|
||||
$builder = $this->builder();
|
||||
$builder->where('disabled', 0);
|
||||
$builder->where('expires_at <', date('Y-m-d H:i:s'));
|
||||
$builder->where('expires_at IS NOT NULL');
|
||||
|
||||
$expiredKeys = $builder->get()->getResultArray();
|
||||
$count = 0;
|
||||
|
||||
foreach ($expiredKeys as $key) {
|
||||
if ($this->update($key['api_key_id'], ['disabled' => 1])) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
65
package-lock.json
generated
65
package-lock.json
generated
@@ -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",
|
||||
@@ -4466,53 +4448,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",
|
||||
|
||||
@@ -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": {
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
91
tests/Filters/ApiAuthTest.php
Normal file
91
tests/Filters/ApiAuthTest.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Filters;
|
||||
|
||||
use App\Filters\ApiAuth;
|
||||
use App\Models\ApiKey;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
use CodeIgniter\HTTP\IncomingRequest;
|
||||
use CodeIgniter\HTTP\Response;
|
||||
use Config\Services;
|
||||
|
||||
class ApiAuthTest extends CIUnitTestCase
|
||||
{
|
||||
protected ApiAuth $filter;
|
||||
protected ApiKey $apiKeyModel;
|
||||
protected int $testEmployeeId = 1;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->filter = new ApiAuth();
|
||||
$this->apiKeyModel = model(ApiKey::class);
|
||||
}
|
||||
|
||||
public function testBeforeWithNoApiKey(): void
|
||||
{
|
||||
$request = new IncomingRequest(Services::config(), Services::uri(), '');
|
||||
$request->setHeader('X-API-Key', '');
|
||||
|
||||
$result = $this->filter->before($request);
|
||||
|
||||
$this->assertInstanceOf(Response::class, $result);
|
||||
$this->assertEquals(401, $result->getStatusCode());
|
||||
|
||||
$body = json_decode($result->getBody(), true);
|
||||
$this->assertFalse($body['success']);
|
||||
$this->assertEquals('API key required', $body['message']);
|
||||
}
|
||||
|
||||
public function testBeforeWithInvalidApiKey(): void
|
||||
{
|
||||
$request = new IncomingRequest(Services::config(), Services::uri(), '');
|
||||
$request->setHeader('X-API-Key', 'ospos_invalidkey12345678901234567890123456789012345678');
|
||||
|
||||
$result = $this->filter->before($request);
|
||||
|
||||
$this->assertInstanceOf(Response::class, $result);
|
||||
$this->assertEquals(401, $result->getStatusCode());
|
||||
|
||||
$body = json_decode($result->getBody(), true);
|
||||
$this->assertFalse($body['success']);
|
||||
$this->assertEquals('Invalid or expired API key', $body['message']);
|
||||
}
|
||||
|
||||
public function testBeforeWithValidApiKey(): void
|
||||
{
|
||||
$employeeId = $this->testEmployeeId;
|
||||
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
|
||||
|
||||
$request = new IncomingRequest(Services::config(), Services::uri(), '');
|
||||
$request->setHeader('X-API-Key', $rawKey);
|
||||
|
||||
$result = $this->filter->before($request);
|
||||
|
||||
$this->assertInstanceOf(IncomingRequest::class, $result);
|
||||
$this->assertEquals($employeeId, $result->employeeId);
|
||||
}
|
||||
|
||||
public function testAfterReturnsResponse(): void
|
||||
{
|
||||
$request = new IncomingRequest(Services::config(), Services::uri(), '');
|
||||
$response = new Response(Services::config());
|
||||
|
||||
$result = $this->filter->after($request, $response);
|
||||
|
||||
$this->assertInstanceOf(Response::class, $result);
|
||||
}
|
||||
|
||||
public function testEmployeeIdSetInService(): void
|
||||
{
|
||||
$employeeId = $this->testEmployeeId;
|
||||
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
|
||||
|
||||
$request = new IncomingRequest(Services::config(), Services::uri(), '');
|
||||
$request->setHeader('X-API-Key', $rawKey);
|
||||
|
||||
$this->filter->before($request);
|
||||
|
||||
$this->assertEquals($employeeId, Services::get('apiEmployeeId'));
|
||||
}
|
||||
}
|
||||
191
tests/Models/ApiKeyTest.php
Normal file
191
tests/Models/ApiKeyTest.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Models;
|
||||
|
||||
use App\Models\ApiKey;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
use CodeIgniter\Test\DatabaseTestTrait;
|
||||
|
||||
class ApiKeyTest extends CIUnitTestCase
|
||||
{
|
||||
use DatabaseTestTrait;
|
||||
|
||||
protected $migrate = true;
|
||||
protected $migrateOnly = ['api_keys'];
|
||||
protected $refresh = true;
|
||||
|
||||
protected ApiKey $apiKeyModel;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->apiKeyModel = new ApiKey();
|
||||
}
|
||||
|
||||
public function testGenerateKey(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
$name = 'Test API Key';
|
||||
|
||||
$rawKey = $this->apiKeyModel->generateKey($employeeId, $name);
|
||||
|
||||
$this->assertNotFalse($rawKey);
|
||||
$this->assertStringStartsWith('ospos_', $rawKey);
|
||||
$this->assertEquals(70, strlen($rawKey)); // ospos_ prefix (6 chars) + 64 hex chars
|
||||
|
||||
$keyInDb = $this->apiKeyModel->where('employee_id', $employeeId)
|
||||
->where('name', $name)
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($keyInDb);
|
||||
$this->assertEquals(substr($rawKey, 0, 12), $keyInDb->key_prefix);
|
||||
$this->assertEquals(hash('sha256', $rawKey), $keyInDb->key_hash);
|
||||
}
|
||||
|
||||
public function testValidateKeySuccess(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
|
||||
|
||||
$validatedEmployeeId = $this->apiKeyModel->validateKey($rawKey);
|
||||
|
||||
$this->assertEquals($employeeId, $validatedEmployeeId);
|
||||
}
|
||||
|
||||
public function testValidateKeyInvalidFormat(): void
|
||||
{
|
||||
$result = $this->apiKeyModel->validateKey('invalid_key');
|
||||
$this->assertFalse($result);
|
||||
|
||||
$result = $this->apiKeyModel->validateKey('ospos_short');
|
||||
$this->assertFalse($result);
|
||||
|
||||
$result = $this->apiKeyModel->validateKey('otherprefix_' . str_repeat('a', 64));
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function testValidateKeyDisabled(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
|
||||
|
||||
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
|
||||
$this->apiKeyModel->update($keyRecord->api_key_id, ['disabled' => 1]);
|
||||
|
||||
$result = $this->apiKeyModel->validateKey($rawKey);
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function testValidateKeyExpired(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
$expiresAt = date('Y-m-d H:i:s', strtotime('-1 day'));
|
||||
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key', $expiresAt);
|
||||
|
||||
$result = $this->apiKeyModel->validateKey($rawKey);
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function testValidateKeyNotExpired(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
$expiresAt = date('Y-m-d H:i:s', strtotime('+1 day'));
|
||||
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key', $expiresAt);
|
||||
|
||||
$result = $this->apiKeyModel->validateKey($rawKey);
|
||||
$this->assertEquals($employeeId, $result);
|
||||
}
|
||||
|
||||
public function testGetKeysForEmployee(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
|
||||
$this->apiKeyModel->generateKey($employeeId, 'Key 1');
|
||||
$this->apiKeyModel->generateKey($employeeId, 'Key 2');
|
||||
$this->apiKeyModel->generateKey($employeeId, 'Key 3');
|
||||
|
||||
$keys = $this->apiKeyModel->getKeysForEmployee($employeeId);
|
||||
|
||||
$this->assertCount(3, $keys);
|
||||
}
|
||||
|
||||
public function testRevokeKey(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
|
||||
|
||||
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
|
||||
|
||||
$result = $this->apiKeyModel->revokeKey($keyRecord->api_key_id, $employeeId);
|
||||
$this->assertTrue($result);
|
||||
|
||||
$updatedKey = $this->apiKeyModel->find($keyRecord->api_key_id);
|
||||
$this->assertEquals(1, $updatedKey->disabled);
|
||||
|
||||
$validateResult = $this->apiKeyModel->validateKey($rawKey);
|
||||
$this->assertFalse($validateResult);
|
||||
}
|
||||
|
||||
public function testRevokeKeyWrongEmployee(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
$this->apiKeyModel->generateKey($employeeId, 'Test Key');
|
||||
|
||||
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
|
||||
|
||||
$result = $this->apiKeyModel->revokeKey($keyRecord->api_key_id, 999);
|
||||
|
||||
$updatedKey = $this->apiKeyModel->find($keyRecord->api_key_id);
|
||||
$this->assertEquals(0, $updatedKey->disabled);
|
||||
}
|
||||
|
||||
public function testRegenerateKey(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
$oldKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
|
||||
|
||||
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
|
||||
$oldKeyId = $keyRecord->api_key_id;
|
||||
|
||||
$newKey = $this->apiKeyModel->regenerateKey($oldKeyId, $employeeId);
|
||||
|
||||
$this->assertNotFalse($newKey);
|
||||
$this->assertNotEquals($oldKey, $newKey);
|
||||
|
||||
$oldKeyValid = $this->apiKeyModel->validateKey($oldKey);
|
||||
$this->assertFalse($oldKeyValid);
|
||||
|
||||
$newKeyValid = $this->apiKeyModel->validateKey($newKey);
|
||||
$this->assertEquals($employeeId, $newKeyValid);
|
||||
|
||||
$oldKeyExists = $this->apiKeyModel->find($oldKeyId);
|
||||
$this->assertNull($oldKeyExists);
|
||||
}
|
||||
|
||||
public function testKeyHashNotReversible(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
|
||||
|
||||
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
|
||||
|
||||
$this->assertNotEquals($rawKey, $keyRecord->key_hash);
|
||||
$this->assertEquals(64, strlen($keyRecord->key_hash));
|
||||
}
|
||||
|
||||
public function testLastUsedUpdatesOnValidation(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
|
||||
|
||||
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
|
||||
$this->assertNull($keyRecord->last_used);
|
||||
|
||||
sleep(1);
|
||||
|
||||
$this->apiKeyModel->validateKey($rawKey);
|
||||
|
||||
$keyRecord = $this->apiKeyModel->find($keyRecord->api_key_id);
|
||||
$this->assertNotNull($keyRecord->last_used);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 . "'";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user