UPDATE
This commit is contained in:
24
.paul/codebase/README.md
Normal file
24
.paul/codebase/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Codebase Map — shopPRO
|
||||
|
||||
Generated: 2026-03-12
|
||||
|
||||
## Documents
|
||||
|
||||
| File | Contents |
|
||||
|------|---------|
|
||||
| [overview.md](overview.md) | Project summary, size metrics, quick reference |
|
||||
| [stack.md](stack.md) | Technology stack, libraries, external integrations |
|
||||
| [architecture.md](architecture.md) | Directory structure, routing, DI, domain modules, request lifecycle |
|
||||
| [conventions.md](conventions.md) | Naming, Medoo patterns, cache patterns, security patterns |
|
||||
| [testing.md](testing.md) | PHPUnit setup, test patterns, mocking, coverage |
|
||||
| [concerns.md](concerns.md) | Security issues, technical debt, dead code, known bugs |
|
||||
| [dependencies.md](dependencies.md) | Composer, vendored libs, PHP extensions |
|
||||
|
||||
## Quick Facts
|
||||
|
||||
- **PHP 7.4 – <8.0** — no match, union types, str_contains etc.
|
||||
- **810 tests / 2264 assertions**
|
||||
- **29 Domain modules**, all with tests
|
||||
- **Medoo pitfall**: `delete()` takes 2 args, not 3
|
||||
- **Top concerns**: tpay.txt logging, path traversal in unlink, hardcoded payment seed
|
||||
- **Largest files**: `ProductRepository.php` (3583 lines), `IntegrationsRepository.php` (875 lines)
|
||||
235
.paul/codebase/architecture.md
Normal file
235
.paul/codebase/architecture.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Architecture & Structure
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
shopPRO/
|
||||
├── autoload/ # Core application code (custom autoloader)
|
||||
│ ├── Domain/ # Business logic — 29 modules
|
||||
│ ├── Shared/ # Cross-cutting utilities
|
||||
│ │ ├── Cache/ # CacheHandler, RedisConnection
|
||||
│ │ ├── Email/ # Email (PHPMailer wrapper)
|
||||
│ │ ├── Helpers/ # Static utility methods
|
||||
│ │ ├── Html/ # HTML escaping/generation
|
||||
│ │ ├── Image/ # ImageManipulator
|
||||
│ │ └── Tpl/ # Template engine
|
||||
│ ├── admin/ # Admin panel layer
|
||||
│ │ ├── App.php # Router & DI factory
|
||||
│ │ ├── Controllers/ # 28 DI controllers
|
||||
│ │ ├── Support/ # Forms, TableListRequestFactory
|
||||
│ │ ├── Validation/ # FormValidator
|
||||
│ │ └── ViewModels/ # Forms/, Common/
|
||||
│ ├── front/ # Frontend layer
|
||||
│ │ ├── App.php # Router & DI factory
|
||||
│ │ ├── LayoutEngine.php # Placeholder-based layout engine
|
||||
│ │ ├── Controllers/ # 8 DI controllers
|
||||
│ │ └── Views/ # 11 static view classes
|
||||
│ └── api/ # REST API layer
|
||||
│ ├── ApiRouter.php # Auth + routing
|
||||
│ └── Controllers/ # 4 DI controllers
|
||||
├── admin/
|
||||
│ ├── index.php # Admin entry point
|
||||
│ ├── ajax.php # Admin AJAX handler
|
||||
│ ├── templates/ # Admin view templates
|
||||
│ └── layout/ # Admin CSS/JS/icons
|
||||
├── templates/ # Frontend view templates
|
||||
├── libraries/ # Third-party libraries
|
||||
├── tests/ # PHPUnit test suite
|
||||
├── docs/ # Technical documentation
|
||||
├── index.php # Frontend entry point
|
||||
├── ajax.php # Frontend AJAX handler
|
||||
├── api.php # REST API entry point
|
||||
├── cron.php # Background job processor
|
||||
└── config.php # DB/Redis config (NOT in repo)
|
||||
```
|
||||
|
||||
## Autoloader
|
||||
|
||||
Custom autoloader in each entry point — tries two conventions:
|
||||
1. `autoload/{namespace}/class.{ClassName}.php` (legacy)
|
||||
2. `autoload/{namespace}/{ClassName}.php` (PSR-4 style, preferred)
|
||||
|
||||
**Namespace → directory mapping (case-sensitive on Linux):**
|
||||
- `\Domain\` → `autoload/Domain/`
|
||||
- `\admin\` → `autoload/admin/` (**lowercase a** — never `\Admin\`)
|
||||
- `\front\` → `autoload/front/`
|
||||
- `\api\` → `autoload/api/`
|
||||
- `\Shared\` → `autoload/Shared/`
|
||||
|
||||
## Dependency Injection
|
||||
|
||||
Manual factory pattern in router classes. Each entry point wires dependencies once:
|
||||
|
||||
```php
|
||||
// Example from admin\App::getControllerFactories()
|
||||
'ShopProduct' => function() {
|
||||
global $mdb;
|
||||
return new \admin\Controllers\ShopProductController(
|
||||
new \Domain\Product\ProductRepository($mdb),
|
||||
new \Domain\Integrations\IntegrationsRepository($mdb),
|
||||
new \Domain\Languages\LanguagesRepository($mdb)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
DI wiring locations:
|
||||
- Admin: `autoload/admin/App.php` → `getControllerFactories()`
|
||||
- Frontend: `autoload/front/App.php` → `getControllerFactories()`
|
||||
- API: `autoload/api/ApiRouter.php` → `getControllerFactories()`
|
||||
|
||||
## Routing
|
||||
|
||||
### Admin (`\admin\App`)
|
||||
- URL: `/admin/?module=shop_product&action=view_list`
|
||||
- `module` → PascalCase (`shop_product` → `ShopProduct`) → controller lookup
|
||||
- `action` → method call on controller
|
||||
- Auth checked before routing; 2FA supported
|
||||
|
||||
### Frontend (`\front\App`)
|
||||
- Routes stored in `pp_routes` table (regex patterns, cached in Redis as `pp_routes:all`)
|
||||
- Match URI → extract destination params → merge with `$_GET`
|
||||
- Special params: `?product=ID`, `?category=ID`, `?article=ID`
|
||||
- Controller dispatch via `getControllerFactories()`
|
||||
- Unmatched → static page content
|
||||
|
||||
### API (`\api\ApiRouter`)
|
||||
- URL: `/api.php?endpoint=orders&action=getOrders`
|
||||
- Stateless — auth via `X-Api-Key` header (`hash_equals()`)
|
||||
- `endpoint` → controller, `action` → method
|
||||
|
||||
## Request Lifecycle (Frontend)
|
||||
|
||||
```
|
||||
HTTP GET /produkt/nazwa-produktu
|
||||
→ index.php (autoload, init Medoo, session, language)
|
||||
→ Fetch pp_routes from Redis (or DB)
|
||||
→ Regex match → extract ?product=123
|
||||
→ front\LayoutEngine::show()
|
||||
→ Determine layout (pp_layouts)
|
||||
→ Replace placeholders [MENU:ID], [BANER_STRONA_GLOWNA], etc.
|
||||
→ Call view classes / repositories for each placeholder
|
||||
→ Output HTML (with GTM, meta OG, WebP, lazy loading)
|
||||
```
|
||||
|
||||
## Request Lifecycle (Admin)
|
||||
|
||||
```
|
||||
HTTP GET /admin/?module=shop_order&action=view_list
|
||||
→ admin/index.php (IP check, session, auth cookie check)
|
||||
→ admin\App::update() (run pending DB migrations)
|
||||
→ admin\App::special_actions() (handle s-action=user-logon etc.)
|
||||
→ admin\App::render()
|
||||
→ Auth check → if not logged in, show login form
|
||||
→ admin\App::route()
|
||||
→ 'shop_order' → ShopOrder → factory()
|
||||
→ new ShopOrderController(OrderAdminService, ProductRepository)
|
||||
→ ShopOrderController::viewList()
|
||||
→ Tpl::view('shop-order/orders-list', [...])
|
||||
→ Tpl::render('site/main-layout', ['content' => $html])
|
||||
→ Output admin HTML
|
||||
```
|
||||
|
||||
## Domain Modules (29)
|
||||
|
||||
All in `autoload/Domain/{Module}/{Module}Repository.php`:
|
||||
|
||||
| Module | Repository | Notes |
|
||||
|--------|-----------|-------|
|
||||
| Article | ArticleRepository | Blog/news |
|
||||
| Attribute | AttributeRepository | Product attributes (color, size) |
|
||||
| Banner | BannerRepository | Promo banners |
|
||||
| Basket | (static) | Cart calculations |
|
||||
| Cache | (utilities) | Cache key constants |
|
||||
| Category | CategoryRepository | Category tree |
|
||||
| Client | ClientRepository | Customer accounts |
|
||||
| Coupon | CouponRepository | Discount codes |
|
||||
| CronJob | CronJobRepository, CronJobProcessor | Job queue |
|
||||
| Dashboard | DashboardRepository | Admin stats |
|
||||
| Dictionaries | DictionariesRepository | Units, enums |
|
||||
| Integrations | IntegrationsRepository | Apilo, Ekomi (**875 lines — too large**) |
|
||||
| Languages | LanguagesRepository | i18n translations |
|
||||
| Layouts | LayoutsRepository | Page templates |
|
||||
| Newsletter | NewsletterRepository, NewsletterPreviewRenderer | Email campaigns |
|
||||
| Order | OrderRepository, OrderAdminService | Orders, status |
|
||||
| Pages | PagesRepository | Static pages |
|
||||
| PaymentMethod | PaymentMethodRepository | Payment gateways |
|
||||
| Producer | ProducerRepository | Brands |
|
||||
| Product | ProductRepository | Core catalog (**3583 lines — too large**) |
|
||||
| ProductSet | ProductSetRepository | Bundles |
|
||||
| Promotion | PromotionRepository | Special offers |
|
||||
| Scontainers | ScontainersRepository | Content blocks |
|
||||
| Settings | SettingsRepository | Shop config |
|
||||
| ShopStatus | ShopStatusRepository | Order statuses |
|
||||
| Transport | TransportRepository | Shipping |
|
||||
| Update | UpdateRepository | DB migrations |
|
||||
| User | UserRepository | Admin users, 2FA |
|
||||
|
||||
## Admin Controllers (28)
|
||||
|
||||
All in `autoload/admin/Controllers/`:
|
||||
`ArticlesController`, `ArticlesArchiveController`, `BannerController`, `DashboardController`, `DictionariesController`, `FilemanagerController`, `IntegrationsController`, `LanguagesController`, `LayoutsController`, `NewsletterController`, `PagesController`, `ProductArchiveController`, `ScontainersController`, `SettingsController`, `ShopAttributeController`, `ShopCategoryController`, `ShopClientsController`, `ShopCouponController`, `ShopOrderController`, `ShopPaymentMethodController`, `ShopProducerController`, `ShopProductController` (1199 lines), `ShopProductSetsController`, `ShopPromotionController`, `ShopStatusesController`, `ShopTransportController`, `UpdateController`, `UsersController`
|
||||
|
||||
## Frontend Controllers (8)
|
||||
|
||||
`autoload/front/Controllers/`: `NewsletterController`, `SearchController`, `ShopBasketController`, `ShopClientController`, `ShopCouponController`, `ShopOrderController`, `ShopProducerController`, `ShopProductController`
|
||||
|
||||
## Frontend Views (11, static)
|
||||
|
||||
`autoload/front/Views/`: `Articles`, `Banners`, `Languages`, `Menu`, `Newsletter`, `Scontainers`, `ShopCategory`, `ShopClient`, `ShopPaymentMethod`, `ShopProduct`, `ShopSearch`
|
||||
|
||||
## API Controllers (4)
|
||||
|
||||
`autoload/api/Controllers/`: `OrdersApiController`, `ProductsApiController`, `CategoriesApiController`, `DictionariesApiController`
|
||||
|
||||
## Template System
|
||||
|
||||
### Tpl Engine (`\Shared\Tpl\Tpl`)
|
||||
```php
|
||||
// Controller
|
||||
return \Shared\Tpl\Tpl::view('shop-category/category-edit', [
|
||||
'category' => $data,
|
||||
'languages' => $langs,
|
||||
]);
|
||||
|
||||
// Template (templates/shop-category/category-edit.php)
|
||||
<h1><?= $this->category['name'] ?></h1>
|
||||
```
|
||||
|
||||
Search order: `templates_user/`, `templates/`, `../templates_user/`, `../templates/`
|
||||
|
||||
### Frontend Layout Engine (`\front\LayoutEngine`)
|
||||
Replaces placeholders in layout HTML loaded from `pp_layouts.html`:
|
||||
- `[MENU:ID]`, `[KONTENER:ID]`, `[LANG:key]`
|
||||
- `[PROMOWANE_PRODUKTY:limit]`, `[PRODUKTY_TOP:limit]`, `[PRODUKTY_NEW:limit]`
|
||||
- `[BANER_STRONA_GLOWNA]`, `[BANERY]`, `[COPYRIGHT]`
|
||||
- `[AKTUALNOSCI:layout_id:limit]`, `[PRODUKTY_KATEGORIA:cat_id:limit]`
|
||||
|
||||
## Admin Form System
|
||||
|
||||
Universal form system for CRUD views. Full docs: `docs/FORM_EDIT_SYSTEM.md`.
|
||||
|
||||
| Component | Class | Location |
|
||||
|-----------|-------|----------|
|
||||
| View model | `FormEditViewModel` | `autoload/admin/ViewModels/Forms/` |
|
||||
| Field definition | `FormField` | same |
|
||||
| Field type enum | `FormFieldType` | same |
|
||||
| Tab | `FormTab` | same |
|
||||
| Action | `FormAction` | same |
|
||||
| Validation | `FormValidator` | `autoload/admin/Validation/` |
|
||||
| POST parsing | `FormRequestHandler` | `autoload/admin/Support/Forms/` |
|
||||
| Rendering | `FormFieldRenderer` | `autoload/admin/Support/Forms/` |
|
||||
| Template | `form-edit.php` | `admin/templates/components/` |
|
||||
|
||||
## Authentication
|
||||
|
||||
### Admin
|
||||
- Session: `$_SESSION['user']` after successful login
|
||||
- 2FA: 6-digit code sent by email; `twofa_pending` in session during verification
|
||||
- Remember Me: 14-day HMAC-SHA256 signed cookie
|
||||
|
||||
### API
|
||||
- Stateless; `X-Api-Key` header vs `pp_settings.api_key` via `hash_equals()`
|
||||
|
||||
### Frontend
|
||||
- Customer session in `$_SESSION['client']`
|
||||
- IP validation on every request (`$_SESSION['ip']` vs `REMOTE_ADDR`)
|
||||
127
.paul/codebase/concerns.md
Normal file
127
.paul/codebase/concerns.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Concerns & Technical Debt
|
||||
|
||||
> Last updated: 2026-03-12
|
||||
|
||||
## Security Issues
|
||||
|
||||
### HIGH — Sensitive data logged to public file
|
||||
**File**: `autoload/front/Controllers/ShopOrderController.php:32`
|
||||
```php
|
||||
file_put_contents('tpay.txt', print_r($_POST, true) . print_r($_GET, true), FILE_APPEND);
|
||||
```
|
||||
- Logs entire POST/GET (including payment data) to `tpay.txt` likely in webroot
|
||||
- Possible information disclosure
|
||||
- **Fix**: Remove log or write to non-public path (e.g., `/logs/`)
|
||||
|
||||
### HIGH — Hardcoded payment seed
|
||||
**File**: `autoload/front/Controllers/ShopOrderController.php:105`
|
||||
```php
|
||||
hash("sha256", "ProjectPro1916;" . round($summary_tmp, 2) ...)
|
||||
```
|
||||
- Hardcoded secret in source — should be in `config.php`
|
||||
|
||||
### MEDIUM — SQL table name interpolated
|
||||
**File**: `autoload/Domain/Integrations/IntegrationsRepository.php:31`
|
||||
```php
|
||||
$stmt = $this->db->query("SELECT * FROM $table");
|
||||
```
|
||||
- Technically mitigated by whitelist in `settingsTable()`, but violates "no SQL string concatenation" rule
|
||||
- **Fix**: Use Medoo's native `select()` method
|
||||
|
||||
### MEDIUM — Path traversal in unlink()
|
||||
**Files**: `autoload/Domain/Product/ProductRepository.php:1605,1617,2129,2163` and `autoload/Domain/Article/ArticleRepository.php:321,340,823,840`
|
||||
```php
|
||||
if (file_exists('../' . $row['src'])) {
|
||||
unlink('../' . $row['src']);
|
||||
}
|
||||
```
|
||||
- Path from DB, no traversal check
|
||||
- A DB compromise could delete arbitrary files
|
||||
- **Fix**:
|
||||
```php
|
||||
$basePath = realpath('../upload/');
|
||||
$fullPath = realpath('../' . $row['src']);
|
||||
if ($fullPath && strpos($fullPath, $basePath) === 0) {
|
||||
unlink($fullPath);
|
||||
}
|
||||
```
|
||||
|
||||
### MEDIUM — Unsanitized output in templates
|
||||
**Files**:
|
||||
- `templates/articles/article-full.php` — article title and `$_SERVER['SERVER_NAME']` concatenated without escaping
|
||||
- `templates/articles/article-entry.php` — `$url` and article titles not escaped
|
||||
|
||||
### MEDIUM — Missing CSRF tokens
|
||||
- No evidence of CSRF tokens on admin panel forms
|
||||
- State-changing POST endpoints (create/update/delete) are potentially CSRF-vulnerable
|
||||
|
||||
---
|
||||
|
||||
## Architecture Issues
|
||||
|
||||
### IntegrationsRepository too large (875 lines)
|
||||
**File**: `autoload/Domain/Integrations/IntegrationsRepository.php`
|
||||
Does too many things: settings CRUD, logging, Apilo OAuth, product sync, webhook handling, ShopPRO import.
|
||||
**Suggested split**: `ApiloAuthManager`, `ApiloProductSyncService`, `ApiloWebhookHandler`, `IntegrationLogRepository`, `IntegrationSettingsRepository`
|
||||
|
||||
### ProductRepository too large (3583 lines)
|
||||
**File**: `autoload/Domain/Product/ProductRepository.php`
|
||||
Candidate for extraction of: pricing logic, image handling, cache management, Google feed generation.
|
||||
|
||||
### ShopProductController too large (1199 lines)
|
||||
**File**: `autoload/admin/Controllers/ShopProductController.php`
|
||||
|
||||
### Helpers.php too large (1101 lines)
|
||||
**File**: `autoload/Shared/Helpers/Helpers.php`
|
||||
Static utility god class. Extract into focused service classes.
|
||||
|
||||
### Duplicate email logic
|
||||
- `\Shared\Helpers\Helpers::send_email()` and `\Shared\Email\Email::send()` both wrap PHPMailer
|
||||
- Should be unified in `\Shared\Email\Email`
|
||||
- Documented in `docs/MEMORY.md`
|
||||
|
||||
### 47 `global $mdb` usages remain
|
||||
- DI is complete in Controllers, but some Helpers methods still use `global $mdb`
|
||||
- Should be gradually eliminated
|
||||
|
||||
---
|
||||
|
||||
## Dead Code / Unused Files
|
||||
|
||||
| File | Issue |
|
||||
|------|-------|
|
||||
| `libraries/rb.php` | RedBeanPHP — no references found in autoload, candidate for removal |
|
||||
| `cron-turstmate.php` (note: typo) | Legacy/questionable cron handler |
|
||||
| `devel.html` | Development artifact in project root |
|
||||
| `output.txt` | Artifact file |
|
||||
| `libraries/filemanager-9.14.1/` + `9.14.2/` | Duplicate versions |
|
||||
|
||||
---
|
||||
|
||||
## Missing Error Handling
|
||||
|
||||
- `IntegrationsRepository.php:163-165` — DB operations after Apilo token refresh lack try-catch
|
||||
- `ShopOrderController.php:32` — `file_put_contents()` return value not checked
|
||||
- `ProductRepository.php:1605` — `unlink()` without error handling
|
||||
- `cron.php:2` — `error_reporting(E_ALL ^ E_NOTICE ^ E_STRICT ^ E_WARNING ^ E_DEPRECATED)` silences all warnings, hiding potential bugs
|
||||
|
||||
---
|
||||
|
||||
## Known Issues (from docs/TODO.md & docs/MEMORY.md)
|
||||
|
||||
| Issue | Location | Status |
|
||||
|-------|----------|--------|
|
||||
| Newsletter save/unsubscribe needs testing | `Domain/Newsletter/` | Open |
|
||||
| Duplicate email sending logic | `Helpers.php` vs `Email.php` | Open |
|
||||
| `$mdb->delete()` 2-arg pitfall | Documented in MEMORY.md | Known pitfall |
|
||||
|
||||
---
|
||||
|
||||
## Summary by Priority
|
||||
|
||||
| Priority | Count | Key Action |
|
||||
|----------|-------|-----------|
|
||||
| **Immediate** (security) | 5 | Remove tpay.txt logging, fix path traversal, move hardcoded secret to config |
|
||||
| **High** (architecture) | 3 | Split IntegrationsRepository, unify email logic, add CSRF |
|
||||
| **Medium** (quality) | 4 | Escape template output, add try-catch, remove dead files |
|
||||
| **Low** (maintenance) | 3 | Remove rb.php, reduce Helpers.php, document helpers usage |
|
||||
198
.paul/codebase/conventions.md
Normal file
198
.paul/codebase/conventions.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# Code Conventions
|
||||
|
||||
## Naming
|
||||
|
||||
| Entity | Convention | Example |
|
||||
|--------|-----------|---------|
|
||||
| Classes | PascalCase | `ProductRepository`, `ShopCategoryController` |
|
||||
| Methods | camelCase | `getQuantity()`, `categoryDetails()` |
|
||||
| Admin action methods | snake_case | `view_list()`, `category_edit()` |
|
||||
| Variables | camelCase | `$mockDb`, `$formViewModel`, `$postData` |
|
||||
| Constants | UPPER_SNAKE_CASE | `MAX_PER_PAGE`, `SORT_TYPES` |
|
||||
| DB tables | `pp_` prefix + snake_case | `pp_shop_products` |
|
||||
| DB columns | snake_case | `price_brutto`, `parent_id`, `lang_id` |
|
||||
| File (new) | `ClassName.php` | `ProductRepository.php` |
|
||||
| File (legacy) | `class.ClassName.php` | (leave, do not rename) |
|
||||
| Templates | kebab-case | `shop-category/category-edit.php` |
|
||||
|
||||
## Medoo ORM Patterns
|
||||
|
||||
```php
|
||||
// Get single record — returns array or null
|
||||
$product = $this->db->get('pp_shop_products', '*', ['id' => $id]);
|
||||
|
||||
// Get single column value
|
||||
$qty = $this->db->get('pp_shop_products', 'quantity', ['id' => $id]);
|
||||
|
||||
// Select multiple records — always guard against false return
|
||||
$rows = $this->db->select('pp_shop_categories', '*', [
|
||||
'parent_id' => $parentId,
|
||||
'ORDER' => ['o' => 'ASC'],
|
||||
]);
|
||||
if (!is_array($rows)) { return []; }
|
||||
|
||||
// Count
|
||||
$count = $this->db->count('pp_shop_products', ['category_id' => $catId]);
|
||||
|
||||
// Update
|
||||
$this->db->update('pp_shop_products', ['quantity' => 10], ['id' => $id]);
|
||||
|
||||
// Delete — ALWAYS 2 arguments, never 3!
|
||||
$this->db->delete('pp_shop_categories', ['id' => $id]);
|
||||
|
||||
// Insert, then check ID for success
|
||||
$this->db->insert('pp_shop_products', $data);
|
||||
$newId = $this->db->id();
|
||||
```
|
||||
|
||||
**Critical pitfalls:**
|
||||
- `$mdb->delete()` takes **2 args** — passing 3 causes silent bugs
|
||||
- `$mdb->get()` returns `null` (not `false`) when no record found
|
||||
- Always check `!is_array()` on `select()` results before iterating
|
||||
|
||||
## Redis Cache Patterns
|
||||
|
||||
```php
|
||||
$cache = new \Shared\Cache\CacheHandler();
|
||||
|
||||
// Read (data is serialized)
|
||||
$raw = $cache->get('shop\\product:' . $id . ':' . $lang . ':' . $hash);
|
||||
if ($raw) {
|
||||
return unserialize($raw);
|
||||
}
|
||||
|
||||
// Write
|
||||
$cache->set(
|
||||
'shop\\product:' . $id . ':' . $lang . ':' . $hash,
|
||||
serialize($data),
|
||||
86400 // TTL in seconds
|
||||
);
|
||||
|
||||
// Delete one key
|
||||
$cache->delete($key);
|
||||
|
||||
// Delete by pattern
|
||||
$cache->deletePattern("shop\\product:$id:*");
|
||||
|
||||
// Clear all product cache variations
|
||||
\Shared\Helpers\Helpers::clear_product_cache($productId);
|
||||
```
|
||||
|
||||
## Template Rendering
|
||||
|
||||
```php
|
||||
// In controller — always return string
|
||||
return \Shared\Tpl\Tpl::view('module/template-name', [
|
||||
'varName' => $value,
|
||||
]);
|
||||
|
||||
// In template — variables available as $this->varName
|
||||
<h1><?= $this->varName ?></h1>
|
||||
|
||||
// XSS escape
|
||||
<span><?= $tpl->secureHTML($this->userInput) ?></span>
|
||||
```
|
||||
|
||||
## AJAX Response Format
|
||||
|
||||
```php
|
||||
// Standard JSON response
|
||||
echo json_encode([
|
||||
'status' => 'ok', // or 'error'
|
||||
'msg' => 'Zapisano.',
|
||||
'id' => (int)$savedId,
|
||||
]);
|
||||
exit;
|
||||
```
|
||||
|
||||
## Form Handling (Admin)
|
||||
|
||||
```php
|
||||
// Define form
|
||||
$form = new FormEditViewModel('Category', 'Edit');
|
||||
$form->addField(FormField::text('name', ['label' => 'Nazwa', 'required' => true]));
|
||||
$form->addField(FormField::select('status', ['label' => 'Status', 'options' => [...]]));
|
||||
$form->addTab('General', [$field1, $field2]);
|
||||
$form->addAction(new FormAction('save', 'Zapisz', FormAction::TYPE_SUBMIT));
|
||||
|
||||
// Validate & process POST
|
||||
$handler = new FormRequestHandler($validator);
|
||||
$result = $handler->handleSubmit($form, $_POST);
|
||||
if (!$result['success']) {
|
||||
// return form with errors
|
||||
}
|
||||
|
||||
// Render form
|
||||
return Tpl::view('components/form-edit', ['form' => $form]);
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```php
|
||||
// Wrap risky operations — especially external API calls and file operations
|
||||
try {
|
||||
$cache->deletePattern("shop\\product:$id:*");
|
||||
} catch (\Exception $e) {
|
||||
error_log("Cache clear failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// API — always return structured error
|
||||
if (!$this->authenticate()) {
|
||||
self::sendError('UNAUTHORIZED', 'Invalid API key', 401);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
### XSS
|
||||
```php
|
||||
// In templates — use secureHTML for user-sourced strings
|
||||
<?= $tpl->secureHTML($this->categoryName) ?>
|
||||
|
||||
// Or use htmlspecialchars directly
|
||||
<?= htmlspecialchars($value, ENT_QUOTES, 'UTF-8') ?>
|
||||
```
|
||||
|
||||
### SQL Injection
|
||||
- All queries via Medoo — never concatenate SQL strings
|
||||
- Use Medoo array syntax or `?` placeholders only
|
||||
|
||||
### Session Security
|
||||
```php
|
||||
// IP-binding on every request
|
||||
if ($_SESSION['ip'] !== $_SERVER['REMOTE_ADDR']) {
|
||||
session_destroy();
|
||||
header('Location: /');
|
||||
exit;
|
||||
}
|
||||
```
|
||||
|
||||
### API Auth
|
||||
```php
|
||||
// Timing-safe comparison
|
||||
return hash_equals($storedKey, $headerKey);
|
||||
```
|
||||
|
||||
## i18n / Translations
|
||||
|
||||
- Language stored in `$_SESSION['current-lang']`
|
||||
- Translations cached in `$_SESSION['lang-{lang_id}']`
|
||||
- DB table: `pp_langs`, keys fetched via `LanguagesRepository`
|
||||
- Helper: `\Shared\Helpers\Helpers::lang($key)` returns translation string
|
||||
|
||||
## PHP Version Constraints (< 8.0)
|
||||
|
||||
```php
|
||||
// ❌ FORBIDDEN
|
||||
$result = match($x) { 1 => 'a' };
|
||||
function foo(int|string $x) {}
|
||||
str_contains($s, 'needle');
|
||||
str_starts_with($s, 'pre');
|
||||
|
||||
// ✅ USE INSTEAD
|
||||
$result = $x === 1 ? 'a' : 'b';
|
||||
function foo($x) {} // + @param int|string in docblock
|
||||
strpos($s, 'needle') !== false
|
||||
strncmp($pre, $s, strlen($pre)) === 0
|
||||
```
|
||||
65
.paul/codebase/dependencies.md
Normal file
65
.paul/codebase/dependencies.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Dependencies
|
||||
|
||||
## Composer (PHP)
|
||||
|
||||
**File**: `composer.json`
|
||||
**PHP requirement**: `>=7.4` (production runs <8.0)
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `phpunit/phpunit` | ^9.5 | Testing framework |
|
||||
|
||||
## Vendored Libraries (`libraries/`)
|
||||
|
||||
These are NOT managed by Composer — bundled directly.
|
||||
|
||||
| Library | Version | Status | Purpose |
|
||||
|---------|---------|--------|---------|
|
||||
| `medoo/` | 1.7.10 | Active | Database ORM |
|
||||
| `phpmailer/` | classic | Active | Email sending |
|
||||
| `rb.php` | — | **Unused** — remove | RedBeanPHP legacy ORM |
|
||||
| `ckeditor/` | 4.x | Active | Rich text editor |
|
||||
| `apexcharts/` | — | Active | Admin charts |
|
||||
| `bootstrap/` | 4.1.3 + 4.5.2 | Active | CSS framework (two versions present) |
|
||||
| `fontawesome-5.7.0/` | 5.7.0 | Active | Icons |
|
||||
| `filemanager-9.14.1/` | 9.14.1 | Active | File manager |
|
||||
| `filemanager-9.14.2/` | 9.14.2 | Duplicate? | File manager |
|
||||
| `codemirror/` | — | Active | Code editor in admin |
|
||||
| `fancyBox/` + `fancybox3/` | 2 + 3 | Active | Lightbox |
|
||||
| `plupload/` | — | Active | File uploads |
|
||||
| `grid/` | — | Active | CSS grid system |
|
||||
|
||||
## Frontend (JS, served directly)
|
||||
|
||||
| Library | Version | Source |
|
||||
|---------|---------|--------|
|
||||
| jQuery | 2.1.3 | `libraries/` |
|
||||
| jQuery Migrate | 1.0.0 | `libraries/` |
|
||||
| jQuery UI | — | `libraries/` |
|
||||
| jQuery Autocomplete | 1.4.11 | `libraries/` |
|
||||
| jQuery Nested Sortable | — | `libraries/` |
|
||||
| jQuery-confirm | — | `libraries/` |
|
||||
| Selectize.js | — | `libraries/` |
|
||||
| Lozad.js | — | `libraries/` |
|
||||
| Swiper | — | `libraries/` |
|
||||
| taboverride.min.js | — | `libraries/` |
|
||||
| validator.js | — | `libraries/` |
|
||||
|
||||
## PHP Extensions Required
|
||||
|
||||
| Extension | Purpose |
|
||||
|-----------|---------|
|
||||
| `redis` | Redis caching |
|
||||
| `curl` | External API calls (Apilo, image downloads) |
|
||||
| `pdo` + `pdo_mysql` | Medoo ORM database access |
|
||||
| `mbstring` | String handling |
|
||||
| `gd` or `imagick` | Image manipulation (ImageManipulator) |
|
||||
| `json` | JSON encode/decode |
|
||||
| `session` | Session management |
|
||||
|
||||
## Notes
|
||||
|
||||
- **No npm/package.json** — no JS build pipeline
|
||||
- **SCSS is pre-compiled** — CSS served as static files
|
||||
- **No Composer autoload at runtime** — custom autoloader in each entry point
|
||||
- `libraries/rb.php` (RedBeanPHP, 536 KB) — confirmed unused, safe to delete
|
||||
72
.paul/codebase/overview.md
Normal file
72
.paul/codebase/overview.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# shopPRO — Codebase Overview
|
||||
|
||||
> Generated: 2026-03-12
|
||||
|
||||
## What is this project?
|
||||
|
||||
shopPRO is a PHP e-commerce platform with an admin panel, customer-facing storefront, and REST API. It uses a Domain-Driven Design architecture with Dependency Injection (migration from legacy architecture complete).
|
||||
|
||||
## Size & Health
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| PHP files (autoload/) | ~588 |
|
||||
| Lines of code (autoload/) | ~71,668 |
|
||||
| Test suite | **810 tests, 2264 assertions** |
|
||||
| Domain modules | 29 |
|
||||
| Admin controllers | 28 |
|
||||
| Frontend controllers | 8 |
|
||||
| API controllers | 4 |
|
||||
| Frontend views (static) | 11 |
|
||||
|
||||
## Tech Snapshot
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Language | PHP 7.4–7.x (production **< 8.0**) |
|
||||
| Database ORM | Medoo 1.7.10 + MySQL |
|
||||
| Caching | Redis via `CacheHandler` |
|
||||
| Email | PHPMailer (classic) |
|
||||
| Frontend JS | jQuery 2.1.3 |
|
||||
| CSS | Bootstrap 4.x (pre-compiled SCSS) |
|
||||
| HTTP Client | Native cURL |
|
||||
| Testing | PHPUnit 9.6 via `phpunit.phar` |
|
||||
| Build tools | **None** |
|
||||
|
||||
## Entry Points
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `index.php` | Frontend storefront |
|
||||
| `admin/index.php` | Admin panel |
|
||||
| `ajax.php` | Frontend AJAX |
|
||||
| `admin/ajax.php` | Admin AJAX |
|
||||
| `api.php` | REST API (ordersPRO) |
|
||||
| `cron.php` | Background job processor |
|
||||
|
||||
## External Integrations
|
||||
|
||||
| Integration | Purpose |
|
||||
|-------------|---------|
|
||||
| **Apilo** | ERP/WMS — order sync, inventory, pricing (OAuth 2.0) |
|
||||
| **Ekomi** | Customer review CSV export |
|
||||
| **TrustMate** | Review invitation (browser-based, separate cron) |
|
||||
| **Google XML Feed** | Google Shopping product feed |
|
||||
| **shopPRO Import** | Import products from another shopPRO instance |
|
||||
|
||||
## Key Architecture Decisions
|
||||
|
||||
- **DI via manual factories** in `admin\App`, `front\App`, `api\ApiRouter`
|
||||
- **Repository pattern** — all DB access in `autoload/Domain/{Module}/{Module}Repository.php`
|
||||
- **Redis caching** for products (TTL 24h), routes, and settings
|
||||
- **No Composer autoload at runtime** — custom dual-convention autoloader in each entry point
|
||||
- **Stateless REST API** — auth via `X-Api-Key` header + `hash_equals()`
|
||||
- **Job queue** — cron jobs stored in `pp_cron_jobs` table, processed by `cron.php`
|
||||
|
||||
## Quick Reference
|
||||
|
||||
- Full stack details: `stack.md`
|
||||
- Architecture & routing: `architecture.md`
|
||||
- Code conventions: `conventions.md`
|
||||
- Testing patterns: `testing.md`
|
||||
- Known issues & debt: `concerns.md`
|
||||
141
.paul/codebase/stack.md
Normal file
141
.paul/codebase/stack.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Technology Stack & Integrations
|
||||
|
||||
## Languages
|
||||
|
||||
| Language | Version | Notes |
|
||||
|----------|---------|-------|
|
||||
| PHP | 7.4 – <8.0 | Production constraint — no PHP 8.0+ syntax |
|
||||
| JavaScript | ES5 + jQuery 2.1.3 | No modern framework |
|
||||
| CSS | Bootstrap 4.x (pre-compiled SCSS) | No build pipeline |
|
||||
|
||||
**PHP 8.0+ features explicitly forbidden:**
|
||||
- `match` expressions → use ternary / if-else
|
||||
- Named arguments
|
||||
- Union types (`int|string`) → use single type + docblock
|
||||
- `str_contains()`, `str_starts_with()`, `str_ends_with()` → use `strpos()`
|
||||
|
||||
## Core Libraries
|
||||
|
||||
| Library | Version | Location | Purpose |
|
||||
|---------|---------|----------|---------|
|
||||
| Medoo | 1.7.10 | `libraries/medoo/medoo.php` | Database ORM |
|
||||
| PHPMailer | classic | `libraries/phpmailer/` | Email sending |
|
||||
| RedBeanPHP | — | `libraries/rb.php` | Legacy ORM — **unused, candidate for removal** |
|
||||
|
||||
## Frontend Libraries
|
||||
|
||||
| Library | Location | Purpose |
|
||||
|---------|----------|---------|
|
||||
| jQuery | 2.1.3 | DOM / AJAX |
|
||||
| jQuery Migrate | 1.0.0 | Backward compat |
|
||||
| Bootstrap | 4.1.3 / 4.5.2 | `libraries/bootstrap*/` |
|
||||
| CKEditor | 4.x | `libraries/ckeditor/` | Rich text editor |
|
||||
| ApexCharts | — | `libraries/apexcharts/` | Admin charts |
|
||||
| FancyBox | 2 + 3 | `libraries/fancyBox/`, `fancybox3/` | Lightbox |
|
||||
| Plupload | — | `libraries/plupload/` | File uploads |
|
||||
| Selectize.js | — | — | Select dropdowns |
|
||||
| Lozad.js | — | — | Lazy loading |
|
||||
| Swiper | — | — | Carousel/slider |
|
||||
| CodeMirror | — | `libraries/codemirror/` | Code editor |
|
||||
| Font Awesome | 5.7.0 | `libraries/fontawesome-5.7.0/` | Icons |
|
||||
| File Manager | 9.14.1 & 9.14.2 | `libraries/filemanager-9.14.*/` | File browsing |
|
||||
|
||||
## Database
|
||||
|
||||
- **ORM**: Medoo 1.7.10 (custom-extended with Redis support)
|
||||
- **Engine**: MySQL
|
||||
- **Table prefix**: `pp_`
|
||||
- **Connection**: `new medoo([...])` in each entry point via credentials from `config.php`
|
||||
- **Key tables**: `pp_shop_products`, `pp_shop_orders`, `pp_shop_categories`, `pp_shop_clients`
|
||||
|
||||
## Caching
|
||||
|
||||
- **Technology**: Redis
|
||||
- **PHP extension**: Native `Redis` class
|
||||
- **Wrapper**: `\Shared\Cache\CacheHandler` (singleton via `RedisConnection`)
|
||||
- **Config**: `config.php` → `$config['redis']['host/port/password']`
|
||||
- **Serialization**: PHP `serialize()` / `unserialize()`
|
||||
- **Default TTL**: 86400 seconds (24h)
|
||||
- **Key patterns**:
|
||||
- `shop\product:{id}:{lang_id}:{hash}` — product details
|
||||
- `ProductRepository::getProductPermutationQuantityOptions:v2:{id}:*`
|
||||
- `pp_routes:all` — URL routing patterns
|
||||
- `pp_settings_cache` — shop settings
|
||||
|
||||
## Email
|
||||
|
||||
- **Library**: PHPMailer (classic, not v6)
|
||||
- **Config**: `config.php` (host, port, login, password)
|
||||
- **Helpers**:
|
||||
- `\Shared\Helpers\Helpers::send_email($to, $subject, $text, $reply, $file)`
|
||||
- `\Shared\Email\Email::send(...)` — newsletter / template-based
|
||||
- **Issue**: Duplicate PHPMailer logic in both classes — should be unified
|
||||
|
||||
## HTTP Client
|
||||
|
||||
- **Technology**: Native PHP cURL (`curl_init`, `curl_setopt`, `curl_exec`)
|
||||
- **No abstraction library** (no Guzzle, Symfony HTTP Client)
|
||||
- **Used in**: `IntegrationsRepository.php` (Apilo calls), `cron.php` (image downloads)
|
||||
|
||||
## Dev & Build Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| Composer | PHP dependency management |
|
||||
| PHPUnit 9.6 | Testing (`phpunit.phar`) |
|
||||
| PowerShell `test.ps1` | Recommended test runner |
|
||||
| No webpack/Vite/Gulp | SCSS pre-compiled, assets served as-is |
|
||||
|
||||
## External Integrations
|
||||
|
||||
### Apilo (ERP/WMS)
|
||||
- **Auth**: OAuth 2.0 Bearer token (client_id + client_secret from `pp_shop_apilo_settings`)
|
||||
- **Base URL**: `https://projectpro.apilo.com/rest/api/`
|
||||
- **Sync operations**: order sending, payment sync, status polling, product qty/price sync, pricelist sync
|
||||
- **Code**: `autoload/Domain/Integrations/IntegrationsRepository.php`
|
||||
- **Cron jobs**: `APILO_SEND_ORDER`, `APILO_SYNC_PAYMENT`, `APILO_STATUS_POLL`, `APILO_PRODUCT_SYNC`, `APILO_PRICELIST_SYNC`
|
||||
- **Logging**: `\Domain\Integrations\ApiloLogger` → `pp_log` table
|
||||
|
||||
### Ekomi (Reviews)
|
||||
- **Type**: CSV export
|
||||
- **Code**: `api.php` → generates `/ekomi/ekomi-{date}.csv`
|
||||
|
||||
### TrustMate (Review Invitations)
|
||||
- **Type**: Browser-based (requires JS execution)
|
||||
- **Code**: `cron.php` (line ~741), `cron-trustmate.php`
|
||||
- **Config**: `$config['trustmate']['enabled']`
|
||||
|
||||
### Google Shopping Feed
|
||||
- **Type**: XML feed generation
|
||||
- **Cron job**: `GOOGLE_XML_FEED`
|
||||
- **Code**: `cron.php` → `ProductRepository::generateGoogleFeedXml()`
|
||||
|
||||
### shopPRO Product Import
|
||||
- **Type**: Direct MySQL connection to remote shopPRO instance
|
||||
- **Config**: `pp_shop_shoppro_settings` (domain, db credentials)
|
||||
- **Code**: `IntegrationsRepository.php` (lines 668–850)
|
||||
- **Logs**: `/logs/shoppro-import-debug.log`
|
||||
|
||||
### REST API (ordersPRO — outbound)
|
||||
- **Auth**: `X-Api-Key` header
|
||||
- **Endpoints**: orders (list/get/status/paid), products (list/get), dictionaries, categories
|
||||
- **Code**: `api.php` → `autoload/api/ApiRouter.php` → `autoload/api/Controllers/`
|
||||
|
||||
## Cron Job System
|
||||
|
||||
| Job Type | Purpose |
|
||||
|----------|---------|
|
||||
| `APILO_TOKEN_KEEPALIVE` | OAuth token refresh |
|
||||
| `APILO_SEND_ORDER` | Sync orders to Apilo (priority 40) |
|
||||
| `APILO_SYNC_PAYMENT` | Sync payment status |
|
||||
| `APILO_STATUS_POLL` | Poll order status changes |
|
||||
| `APILO_PRODUCT_SYNC` | Update product qty & prices |
|
||||
| `APILO_PRICELIST_SYNC` | Update pricelist |
|
||||
| `PRICE_HISTORY` | Record price history |
|
||||
| `ORDER_ANALYSIS` | Order/product correlation |
|
||||
| `TRUSTMATE_INVITATION` | Review invitations |
|
||||
| `GOOGLE_XML_FEED` | Google Shopping XML |
|
||||
|
||||
- **Priority levels**: CRITICAL(10), HIGH(50), NORMAL(100), LOW(200)
|
||||
- **Backoff**: Exponential on failure (60s → 3600s max)
|
||||
- **Storage**: `pp_cron_jobs` table
|
||||
245
.paul/codebase/testing.md
Normal file
245
.paul/codebase/testing.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# Testing Patterns
|
||||
|
||||
## Overview
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total tests | **810** |
|
||||
| Total assertions | **2264** |
|
||||
| Framework | PHPUnit 9.6 (`phpunit.phar`) |
|
||||
| Bootstrap | `tests/bootstrap.php` |
|
||||
| Config | `phpunit.xml` |
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Full suite (PowerShell — recommended)
|
||||
./test.ps1
|
||||
|
||||
# Specific file
|
||||
./test.ps1 tests/Unit/Domain/Product/ProductRepositoryTest.php
|
||||
|
||||
# Specific test method
|
||||
./test.ps1 --filter testGetQuantityReturnsCorrectValue
|
||||
|
||||
# Alternatives
|
||||
composer test # standard output
|
||||
./test.bat # testdox (readable list)
|
||||
./test-simple.bat # dots
|
||||
./test-debug.bat # debug output
|
||||
./test.sh # Git Bash
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
Tests mirror source structure:
|
||||
|
||||
```
|
||||
tests/Unit/
|
||||
├── Domain/
|
||||
│ ├── Product/ProductRepositoryTest.php
|
||||
│ ├── Category/CategoryRepositoryTest.php
|
||||
│ ├── Order/OrderRepositoryTest.php
|
||||
│ └── ... (all 29 modules covered)
|
||||
├── admin/Controllers/
|
||||
│ ├── ShopCategoryControllerTest.php
|
||||
│ └── ...
|
||||
└── api/
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Test Class Pattern
|
||||
|
||||
```php
|
||||
namespace Tests\Unit\Domain\Category;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Domain\Category\CategoryRepository;
|
||||
|
||||
class CategoryRepositoryTest extends TestCase
|
||||
{
|
||||
private $mockDb;
|
||||
private CategoryRepository $repository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->mockDb = $this->createMock(\medoo::class);
|
||||
$this->repository = new CategoryRepository($this->mockDb);
|
||||
}
|
||||
|
||||
// Tests follow below...
|
||||
}
|
||||
```
|
||||
|
||||
## AAA Pattern (Arrange-Act-Assert)
|
||||
|
||||
```php
|
||||
public function testGetQuantityReturnsCorrectValue(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->mockDb->expects($this->once())
|
||||
->method('get')
|
||||
->with(
|
||||
'pp_shop_products',
|
||||
'quantity',
|
||||
['id' => 123]
|
||||
)
|
||||
->willReturn(42);
|
||||
|
||||
// Act
|
||||
$result = $this->repository->getQuantity(123);
|
||||
|
||||
// Assert
|
||||
$this->assertSame(42, $result);
|
||||
}
|
||||
```
|
||||
|
||||
## Mock Patterns
|
||||
|
||||
### Simple return value
|
||||
```php
|
||||
$this->mockDb->method('get')->willReturn(['id' => 1, 'name' => 'Test']);
|
||||
```
|
||||
|
||||
### Multiple calls with different return values
|
||||
```php
|
||||
$this->mockDb->method('get')
|
||||
->willReturnCallback(function ($table, $columns, $where) {
|
||||
if ($table === 'pp_shop_categories') {
|
||||
return ['id' => 15, 'status' => '1'];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
```
|
||||
|
||||
### Verify exact call arguments
|
||||
```php
|
||||
$this->mockDb->expects($this->once())
|
||||
->method('delete')
|
||||
->with('pp_shop_categories', ['id' => 5]);
|
||||
```
|
||||
|
||||
### Verify method never called
|
||||
```php
|
||||
$this->mockDb->expects($this->never())->method('update');
|
||||
```
|
||||
|
||||
### Mock complex PDO statement (for `->query()` calls)
|
||||
```php
|
||||
$countStmt = $this->createMock(\PDOStatement::class);
|
||||
$countStmt->method('fetchAll')->willReturn([[25]]);
|
||||
|
||||
$productsStmt = $this->createMock(\PDOStatement::class);
|
||||
$productsStmt->method('fetchAll')->willReturn([['id' => 301], ['id' => 302]]);
|
||||
|
||||
$callIndex = 0;
|
||||
$this->mockDb->method('query')
|
||||
->willReturnCallback(function () use (&$callIndex, $countStmt, $productsStmt) {
|
||||
$callIndex++;
|
||||
return $callIndex === 1 ? $countStmt : $productsStmt;
|
||||
});
|
||||
```
|
||||
|
||||
## Controller Test Pattern
|
||||
|
||||
```php
|
||||
class ShopCategoryControllerTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = $this->createMock(CategoryRepository::class);
|
||||
$this->languagesRepository = $this->createMock(LanguagesRepository::class);
|
||||
$this->controller = new ShopCategoryController(
|
||||
$this->repository,
|
||||
$this->languagesRepository
|
||||
);
|
||||
}
|
||||
|
||||
// Verify constructor signature
|
||||
public function testConstructorRequiresCorrectRepositories(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass(ShopCategoryController::class);
|
||||
$params = $reflection->getConstructor()->getParameters();
|
||||
|
||||
$this->assertCount(2, $params);
|
||||
$this->assertEquals(
|
||||
'Domain\\Category\\CategoryRepository',
|
||||
$params[0]->getType()->getName()
|
||||
);
|
||||
}
|
||||
|
||||
// Verify action methods return string
|
||||
public function testViewListReturnsString(): void
|
||||
{
|
||||
$this->repository->method('categoriesList')->willReturn([]);
|
||||
$result = $this->controller->view_list();
|
||||
$this->assertIsString($result);
|
||||
}
|
||||
|
||||
// Verify expected methods exist
|
||||
public function testHasExpectedActionMethods(): void
|
||||
{
|
||||
$this->assertTrue(method_exists($this->controller, 'view_list'));
|
||||
$this->assertTrue(method_exists($this->controller, 'category_edit'));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Test Naming Convention
|
||||
|
||||
Pattern: `test{What}{WhenCondition}`
|
||||
|
||||
```php
|
||||
testGetQuantityReturnsCorrectValue()
|
||||
testGetQuantityReturnsNullWhenProductNotFound()
|
||||
testCategoryDetailsReturnsDefaultForInvalidId()
|
||||
testCategoryDeleteReturnsFalseWhenHasChildren()
|
||||
testCategoryDeleteReturnsTrueWhenDeleted()
|
||||
testSaveCategoriesOrderReturnsFalseForNonArray()
|
||||
testPaginatedCategoryProductsClampsPage()
|
||||
```
|
||||
|
||||
## Common Assertions
|
||||
|
||||
```php
|
||||
$this->assertTrue($bool);
|
||||
$this->assertFalse($bool);
|
||||
$this->assertEquals($expected, $actual);
|
||||
$this->assertSame($expected, $actual); // type-strict
|
||||
$this->assertNull($value);
|
||||
$this->assertIsArray($value);
|
||||
$this->assertIsInt($value);
|
||||
$this->assertIsString($value);
|
||||
$this->assertEmpty($array);
|
||||
$this->assertCount(3, $array);
|
||||
$this->assertArrayHasKey('id', $array);
|
||||
$this->assertArrayNotHasKey('foo', $array);
|
||||
$this->assertGreaterThanOrEqual(3, $count);
|
||||
$this->assertInstanceOf(ClassName::class, $obj);
|
||||
```
|
||||
|
||||
## Available Stubs (`tests/stubs/`)
|
||||
|
||||
| Stub | Purpose |
|
||||
|------|---------|
|
||||
| `Helpers.php` | `Helpers::seo()`, `::lang()`, `::send_email()`, `::normalize_decimal()` |
|
||||
| `ShopProduct.php` | Legacy `shop\Product` class stub |
|
||||
| `RedisConnection` | Redis singleton stub (auto-loaded from bootstrap) |
|
||||
| `CacheHandler` | Cache stub (no actual Redis needed in tests) |
|
||||
|
||||
## What's Covered
|
||||
|
||||
- All 29 Domain repositories ✓
|
||||
- Core business logic (quantity, pricing, category tree) ✓
|
||||
- Query behavior with mocked Medoo ✓
|
||||
- Cache patterns ✓
|
||||
- Controller constructor injection ✓
|
||||
- `FormValidator` behavior ✓
|
||||
- API controllers ✓
|
||||
|
||||
## What's Lightly Covered
|
||||
|
||||
- Full controller action execution (template rendering)
|
||||
- Session state in tests
|
||||
- AJAX response integration
|
||||
- Frontend Views (static classes)
|
||||
Reference in New Issue
Block a user