Dodano mapę kodu w .paul/codebase/ (7 dokumentów)

Wygenerowano przez równoległą analizę czterech agentów: stack, architektura,
konwencje, integracje, testy, baza danych oraz wykryte problemy i dług techniczny.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-05 19:57:07 +02:00
parent 009141455c
commit 0776c4531e
7 changed files with 558 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
# Architecture — rank24.pl
## Pattern
Custom PHP MVC — no framework. Layered as **controls → factory → view**, with Savant3 templates.
## Entry Points
| File | Purpose |
|------|---------|
| `index.php` | Front controller — bootstraps app, routes all page requests |
| `ajax.php` | xajax AJAX endpoint |
| `json.php` | JSON responses for AJAX calls |
| `api.php` | External API integration endpoint |
| `cron.php` | Background job runner |
| `proxy.php` | Proxy management endpoint |
| `ajax-check.php` | Health check / secondary AJAX |
| `dsf.php` | DataForSEO callback handler |
## Directory Structure
```
rank24.pl/
├── index.php Front controller & bootstrap
├── config.php All configuration (DB, proxy, intervals)
├── cron.php Cron job runner
├── .htaccess URL rewriting rules
├── autoload/ PSR-0-style autoloaded classes
│ ├── class.*.php Core utility classes
│ ├── controls/ Page controllers (routing + business logic)
│ ├── factory/ Data access layer (DB queries via Medoo)
│ ├── view/ View renderers (build Savant3 template output)
│ ├── savant3/ Savant3 template engine internals
│ └── opd*/ Legacy PDO debug wrapper
├── templates/ PHP view templates
│ ├── page/ Admin main/unlogged layouts
│ ├── client/ Client dashboard templates
│ ├── ranker/ Admin/ranker management templates
│ ├── reseller/ Reseller dashboard templates
│ ├── html/ Reusable form component templates
│ ├── other/ Pagination, alerts, misc templates
│ └── cron/ Cron management view
├── functions/ xajax AJAX handler functions
│ ├── xajax.php xajax init & config
│ ├── xajax-ranker.php Rank-related AJAX
│ ├── xajax-messages.php Messaging AJAX
│ ├── xajax-analysis.php Analysis AJAX
│ └── xajax-settings.php Settings AJAX
├── libraries/ Third-party libraries
│ ├── medoo.php ORM
│ ├── grid/ Custom DataTables grid
│ └── framework/ Bootstrap + jQuery + 57 plugins
├── layout/ Static assets, compiled CSS
├── resources/ xajax, phpmailer, mPDF
└── temp/, temp_t/ File cache directories
```
## Autoloading
Defined in `index.php`:
```php
// namespace\ClassName → autoload/namespace/class.ClassName.php
// ClassName → autoload/class.ClassName.php
spl_autoload_register('__autoload_my_classes');
```
## Request Flow
```
HTTP request
→ .htaccess: rewrites /m/a/params → ?module=m&action=a&params
→ index.php: load config, init $db (OPD) + $mdb (Medoo), start session, init $cache
→ \controls\Page::checkUrlParams() (handles ?rw= special actions)
→ \controls\Page::getContent() (resolves module+action → controller class)
→ \controls\[Module]::method() (business logic, calls factory)
→ \factory\[Module]::query() (Medoo DB queries)
→ \view\[Module]::render() (assigns data to Savant3, returns HTML)
→ \view\Page::show() (wraps in role-appropriate layout)
→ HTML → browser
```
## User Roles & Routing
| Role | Source table | Layout |
|------|-------------|--------|
| `admin` | `pro_users` | `templates/page/main-layout.php` |
| `client` | `pro_rr_clients` (type=0) | `templates/client/main-layout.php` |
| `worker` | `pro_rr_clients` (type=2) | `templates/client/main-layout.php` |
| `reseller` | `pro_rr_clients` (type=1) | `templates/reseller/main-layout.php` |
## Template System
Two systems co-exist:
**Savant3** (primary): `$tpl = new \Savant3; $tpl->varName = $val; return $tpl->fetch('ranker/summary');`
**Tpl** (lightweight): `$tpl = new \Tpl; $tpl->render('other/pager');`
- Template search order: `templates_a/``templates_b/``templates/`
## Database Layer
Two connections initialized in `index.php`:
- `$db` — OPD (legacy PDO debug wrapper), used in older code paths
- `$mdb` — Medoo ORM, used in all newer code
Factory pattern: controllers call `\factory\Ranker::getClient($id)` which does `global $mdb; return $mdb->get(...)`.
## Cron System
`cron.php``\Cron::staticMethod()` — jobs defined as static methods in `autoload/class.Cron.php`:
- `fill_missing_positions()` — interpolates missing rank records
- `archive_positions()` / `archive_empty()` — data archiving
- `check_proxy()` — validates proxy pool
- `get_phrases_positions_dfs3()` / `post_phrases_positions_dfs3()` — DataForSEO rank fetching
## AJAX System
`functions/xajax.php` registers handler functions → `ajax.php` processes incoming xajax requests → returns DOM update commands to jQuery on client.
## Caching
`\FileCache` stores serialized data in `temp/` and `temp_t/`. Invalidated via `\S::deleteCache()`.

View File

@@ -0,0 +1,97 @@
# Concerns & Technical Debt — rank24.pl
## Security — CRITICAL
### Hardcoded Credentials (must fix before any public exposure)
| Secret | Location | Risk |
|--------|---------|------|
| MySQL password | `config.php` lines 2-5 | Full DB access if repo leaked |
| FTP password | `.vscode/ftp-kr.json`, `.vscode/sftp.json` | Full server access |
| DataForSEO API key | `autoload/class.Cron.php` ~lines 160, 262, 354 | API abuse / billing fraud |
| SMTP password | `autoload/class.S.php` ~lines 293-300 | Email spoofing |
**Remediation**: move all secrets to environment variables or a `.env` file excluded from VCS.
### SQL Injection
- `autoload/class.Cron.php` ~line 200: raw string concatenation in DELETE query
- `autoload/class.GoogleRank.php` lines 74, 96, 100, 136, 158, 162: raw string concat in UPDATE queries
- `autoload/class.DataBase.php` lines 15, 47, 82: mixed OPD with string building
**Remediation**: use Medoo's parameterized methods or PDO `bindValue()` for all dynamic values.
### Other Security Issues (MEDIUM)
- **Weak password hashing**: `md5($pass1)` in `autoload/class.DataBase.php` line 31 — use `password_hash()`
- **No CSRF protection**: state-changing AJAX operations in `ajax.php` lack CSRF tokens
- **Path traversal**: `autoload/class.DataBase.php` ~line 57 — user-supplied `image_folder` concatenated into file path without validation
- **Client-supplied MIME type**: file type validation in `class.DataBase.php` checks `$file['type']` (attacker-controlled)
- **Insecure deserialization**: `@unserialize()` used in `autoload/class.FileCache.php` line 43 and `autoload/opd.statement.php`
- **XSS**: `\S::get()` reads raw `$_POST`/`$_GET` without sanitization; values reach HTML output in multiple templates
## Technical Debt
### God Classes
- `autoload/class.S.php` — 700+ lines; handles sessions, email, DNS, CSV, URL, string utils, DB helpers. Should be split.
- `autoload/class.GoogleRank.php` — 300+ lines; proxy selection logic repeated 4+ times with no extraction.
- `autoload/class.Cron.php` — 400+ lines; hardcoded credentials, multiple large functions.
### Code Duplication
- Proxy selection + backoff UPDATE query repeated verbatim ~4 times in `class.GoogleRank.php`
- Google block-detection strings (`"Our systems have detected unusual traffic"`) duplicated in multiple methods
### Global State Anti-Pattern
Every class does `global $db, $mdb, $user, $config, $cache;` — no DI, no service container. Makes refactoring and testing very difficult.
### Two ORM Layers
Both `$db` (OPD) and `$mdb` (Medoo) are initialized and used. Older code paths use OPD raw queries; newer paths use Medoo. Inconsistent access patterns throughout.
### Deprecated PHP Patterns
- Old-style constructor: `function DataEdit()` in `autoload/class.DataEdit.php` line 32 (should be `__construct()`)
- `global` variable injection instead of constructor parameters
- Short open tags `<?` in templates — requires `short_open_tag = On` in php.ini
### Missing Abstractions
- No HTTP client wrapper — cURL used directly in `class.GoogleScraper.php`, no retry/logging
- No proxy manager class — proxy logic spread across `GoogleRank`, `GoogleSite`, `class.S`
- No logger — debugging done via `file_put_contents('google-rank.txt', ...)` and commented-out `\S::pre()`
- No centralized input validation layer — all `\S::get()` calls are point-of-use
## Reliability Concerns
### Cron Jobs
- No retry logic — single failure = job skipped until next run
- No max execution time enforcement — long foreach loops can time out silently
- `cron.php` outputs JSON directly — no structured logging for cron daemon
- API responses partially unchecked: `class.Cron.php` ~line 162 returns `'ok'` without verifying data integrity
### Scraping / Proxy
- Proxy rotation is deterministic (`ORDER BY used ASC LIMIT 1`) — predictable, easier for Google to detect
- No rate limiting or delays between requests in batch operations
- No exponential backoff — same fixed 10s timeout regardless of failure history (`CURLOPT_TIMEOUT = 10`)
- Block detection only covers known string patterns — new CAPTCHA formats would be missed
- `file_put_contents('google-rank.txt', $result)` in `class.GoogleRank.php` line ~185 — debug log left in production
### Error Handling
- Global `error_reporting` suppresses notices, warnings, deprecations — real errors can be masked
- cURL operations in `class.GoogleScraper.php` have no try/catch — silent failures return `-1`
- File operations in `class.DataBase.php` use `@` suppression — orphaned temp files possible
- No circuit breaker — scraping continues even after repeated proxy failures
## Minor Issues
- Debug variable `$debbbb` left in `autoload/class.S.php` line ~357
- Commented-out dead code in `autoload/class.Cron.php` lines 56-61
- Duplicate key in array: `autoload/class.Cron.php` lines ~364, ~376 — `'se_id'` set twice with different values
- Session-based state won't scale to multi-server deployment (no session locking)
- All proxy requests use `http://` (line ~120 `class.GoogleRank.php`) — credentials sniffable on network

View File

@@ -0,0 +1,77 @@
# Conventions — rank24.pl
## PHP Naming
| Element | Convention | Example |
|---------|-----------|---------|
| Class files | `class.ClassName.php` | `class.GoogleRank.php` |
| Class names | PascalCase | `GoogleScraper`, `FileCache` |
| Namespaces | lowercase | `controls\`, `factory\`, `view\` |
| Public methods | camelCase | `SaveData()`, `getpagedata()` |
| Private properties | underscore + camelCase | `$_table`, `$_proxy`, `$_header` |
| Local variables | snake_case | `$db_edit_table`, `$last_id` |
| Config keys | snake_case | `$config['db']['host']` |
## File Naming
- Class files: `class.ClassName.php` in `autoload/` subdirectory matching namespace
- Templates: lowercase with hyphens — `main-layout.php`, `site-edit.php`
- Entry points: lowercase — `index.php`, `ajax.php`, `cron.php`
- Directories: lowercase — `autoload/`, `templates/`, `libraries/`
## Code Style
- **Indentation**: 2 spaces
- **Braces**: K&R — opening brace on same line for control structures; new line for methods
- **Spacing**: spaces around `->`, `=`, `=>` operators; spaces inside `( )` for function calls
- **Arrays**: mix of long `array()` in config, short `[]` in Medoo queries
- **Short PHP tags**: templates use `<?` and `<?=` (not `<?php`)
- **No type hints**: no PHP type declarations used anywhere
## Error Handling
- Global error suppression in `index.php`:
```php
error_reporting(E_ALL ^ E_NOTICE ^ E_STRICT ^ E_WARNING ^ E_DEPRECATED);
```
- User alerts via `\S::alert('message')` — stored in session, displayed on next render
- Very limited try/catch; exceptions only in OPD wrapper (`opd.class.php`)
- File/curl errors frequently suppressed with `@` operator
## Database Conventions
- **New code**: use Medoo (`$mdb`), array-based query syntax
- **Legacy code**: use OPD (`$db`), PDO `prepare()` + `execute()`
- Table names: `pro_` prefix (e.g., `pro_rr_clients`, `pro_proxy_servers`)
- All factory methods access DB via `global $mdb;`
## Configuration Access
- All config in `config.php` as `$config['section']['key']` array
- Classes access via `global $config;`
- No `.env` files, no constants for settings (only `OPD_DIR`, `OPD_VERSION`)
## Comments & Docs
- Comments are sparse and in **Polish**
- No docblocks on most classes/methods (exception: `RestClient3.php`)
- Inline comments explain "why" only — rare but present
## Global State
Classes depend on globals declared at bootstrap:
```php
global $db, $mdb, $lang, $sys, $user, $cache, $config, $settings;
```
No dependency injection. No service container.
## Template Conventions
- Savant3 templates receive variables as `$this->varName`
- Tpl templates receive variables as assigned properties, accessed in template scope
- HTML helper components in `templates/html/` — generated via `\Html::form_text()`, `\Html::select()`, etc.
- All UI strings use Polish language
## Language
- All user-facing text, comments, variable names, and commit messages are in **Polish**

View File

@@ -0,0 +1,98 @@
# Database Schema — rank24.pl
Database: `host700513_rank24` (MySQL 5.x, charset utf8)
> This document reflects table names and columns inferred from source code analysis.
> For authoritative schema, run: `SHOW CREATE TABLE <table_name>;`
## Known Tables
### `pro_users`
Admin user accounts.
| Column | Type | Notes |
|--------|------|-------|
| `id` | int | PK |
| `login` | varchar | Username |
| `password` | varchar | MD5 hash (legacy) |
| `type` | varchar | Role: `admin` |
### `pro_rr_clients`
Client, reseller, and worker accounts.
| Column | Type | Notes |
|--------|------|-------|
| `id` | int | PK |
| `login` | varchar | |
| `password` | varchar | MD5 hash |
| `type` | int | 0=client, 1=reseller, 2=worker |
| `active` | int | |
| `reseller_id` | int | FK to self (reseller parent) |
### `pro_proxy_servers`
HTTP proxy pool for scraping.
| Column | Type | Notes |
|--------|------|-------|
| `id` | int | PK |
| `proxy` | varchar | `ip:port` |
| `bg` | int | Ban/backoff counter |
| `bgd` | datetime | Backoff until datetime |
| `used` | datetime | Last used timestamp |
| `enabled` | int | 1=active, 0=disabled |
### `pro_rr_sites`
Monitored websites per client.
| Column | Type | Notes |
|--------|------|-------|
| `id` | int | PK |
| `client_id` | int | FK → `pro_rr_clients.id` |
| `url` | varchar | Domain / URL |
| `active` | int | |
### `pro_rr_sites_majestic`
Majestic metrics cache per site.
| Column | Type | Notes |
|--------|------|-------|
| `site_id` | int | FK → `pro_rr_sites.id` |
| `trust_flow` | int | |
| `citation_flow` | int | |
| `external_backlinks` | int | |
| `ref_domains` | int | |
| `updated_at` | datetime | |
### `phrase_positions_statistic`
Live ranking data (rolling 2 years).
| Column | Type | Notes |
|--------|------|-------|
| `id` | int | PK |
| `phrase_id` | int | FK → phrases table |
| `date` | date | |
| `position` | int | Google rank position |
| `url` | varchar | Result URL |
### `phrase_positions_archive`
Archived ranking data (older than 2 years).
Same structure as `phrase_positions_statistic`.
## Table Name Patterns
All application tables use the `pro_` prefix:
- `pro_users` — admin users
- `pro_rr_clients` — clients / resellers / workers
- `pro_rr_sites` — tracked sites
- `pro_rr_sites_majestic` — Majestic metrics
- `pro_proxy_servers` — proxy pool
- `phrase_positions_statistic` — live positions (no prefix — legacy naming)
- `phrase_positions_archive` — archived positions
## Notes
- Passwords stored as **MD5** — upgrade to `password_hash()` / `password_verify()` is a known debt item
- Positions older than 2 years are moved from `phrase_positions_statistic``phrase_positions_archive` by `\Cron::archive_positions()`
- Missing positions (gaps in daily records) are interpolated by `\Cron::fill_missing_positions()`
- Proxy backoff: `bgd = NOW() + (bg * 15 MINUTE)` where `bg` increments on each failure

View File

@@ -0,0 +1,72 @@
# External Integrations — rank24.pl
## DataForSEO API (Primary — Active)
- **Purpose**: Google SERP rank checking — replaces direct Google scraping
- **Client v2**: `autoload/RestClient.php`
- **Client v3**: `autoload/RestClient3.php`
- **Credentials**: hardcoded in `autoload/class.Cron.php` (lines ~160, ~262, ~354) — `pyziak84@gmail.com`
- **Usage**: `\Cron::post_phrases_positions_dfs3()` submits tasks; `\Cron::get_phrases_positions_dfs3()` retrieves results
- **Auth**: HTTP Basic Auth over HTTPS
## Majestic (Active)
- **Purpose**: Domain authority metrics — TF (Trust Flow), CF (Citation Flow), backlinks, RefDomains
- **Data stored in**: `pro_rr_sites_majestic` table
- **Refresh interval**: `$config['site']['majestic_interval']` = 7 days
- **Integration point**: `api.php` + relevant factory methods
## SEMstorm (Active)
- **Purpose**: Keyword traffic / visibility data
- **Refresh interval**: `$config['site']['semstorm_interval']` = 1 day
- **Integration point**: `api.php`
## Proxy Providers (Disabled)
| Provider | API endpoint (stored in config) | Config key | Status |
|---------|-------------------------------|-----------|--------|
| ProxyMarket | `http://www.proxymarket.pl/api/get/...` | `proxymarket-api` | disabled |
| Proxy.Adding.pl | `http://proxy.adding.pl/apiproxy/...` | `adding-api` | disabled |
Active proxies are managed internally in the `pro_proxy_servers` DB table.
## Internal Proxy Pool
- **Table**: `pro_proxy_servers` (columns: `id`, `proxy`, `bg`, `bgd`, `used`, `enabled`)
- **Rotation logic**: `ORDER BY used ASC LIMIT 1` (least-recently-used)
- **Backoff**: failed proxy gets `bgd = NOW() + (bg * 15 minutes)` cooldown
- **Validation**: `\Cron::check_proxy()` pings each proxy, marks invalid ones disabled
- **Config**: `$config['proxy']['sv-check']` = 2, `$config['proxy']['ht-check']` = 4, `$config['proxy']['s-version']` = 1.218
## Direct Google Scraping (Legacy — still in codebase)
- **Classes**: `autoload/class.GoogleScraper.php`, `autoload/class.GoogleRank.php`, `autoload/class.GoogleSite.php`
- `GoogleScraper` — cURL-based scraper with 24 rotating user agents, proxy support, cookie handling
- `GoogleRank` — orchestrates proxy selection → scrape → parse position → store result
- Block detection: checks for `"Our systems have detected unusual traffic"`, `"Forbidden"`, etc.
- Status: likely superseded by DataForSEO but classes remain
## PHPMailer / SMTP Email
- **Library**: `resources/phpmailer/` (v5.2.15)
- **Credentials**: SMTP hardcoded in `autoload/class.S.php` (lines ~293-300) — `biuro@project-pro.pl`
- **Usage**: transactional emails, notifications, organizer email: `poczta@project-dc.pl`
## mPDF — PDF Reports
- **Library**: `resources/mpdf60/` (v6.0)
- **Usage**: PDF report generation, rendered via `templates/*/reports-pdf.php`
## xajax — AJAX Framework
- **Library**: `resources/xajax/`
- **Handler files**: `functions/xajax-*.php`
- **Entry point**: `ajax.php`
## FTP/SFTP Deployment
- **Tool**: VS Code FTP-KR extension
- **Host**: `host700513.hostido.net.pl`
- **Remote path**: `/public_html/`
- **Config files**: `.vscode/ftp-kr.json`, `.vscode/sftp.json` (credentials stored in plaintext)

55
.paul/codebase/stack.md Normal file
View File

@@ -0,0 +1,55 @@
# Stack — rank24.pl
## Core Language & Runtime
- **PHP** (5.6+, no version pinned) — backend logic, no framework
- **MySQL 5.x** — primary data store (host: `localhost`, db: `host700513_rank24`)
- **Apache** with `mod_rewrite` — URL rewriting via `.htaccess`
- **Timezone**: `Europe/Warsaw`, encoding: `UTF-8`
## PHP Libraries
| Library | Version | Purpose | Path |
|---------|---------|---------|------|
| Medoo | 1.2.1 | ORM / query builder (MySQL) | `libraries/medoo.php` |
| Savant3 | — | Template engine | `autoload/Savant3.php` / `autoload/savant3/` |
| OPD (opdClass) | — | Legacy PDO wrapper with debug console | `autoload/opd.class.php` |
| PHPMailer | 5.2.15 | SMTP email sending | `resources/phpmailer/` |
| mPDF | 6.0 | Server-side PDF generation | `resources/mpdf60/` |
| xajax | — | Server-side AJAX request handler | `resources/xajax/` |
| RestClient | 2.0 | DataForSEO API v2 client | `autoload/RestClient.php` |
| RestClient3 | 3.0 | DataForSEO API v3 client | `autoload/RestClient3.php` |
| Custom Grid | — | DataTables-based grid (view/edit/upload) | `libraries/grid/` |
## Frontend Stack
| Library | Version | Purpose |
|---------|---------|---------|
| jQuery | 1.11.1 | DOM / AJAX | `libraries/framework/vendor/jquery/` |
| Bootstrap | 3.x | CSS framework, JS components | `libraries/framework/` |
| DataTables | — | Sortable/filterable tables | `libraries/framework/vendor/plugins/` |
| CKEditor | — | Rich text editing | `libraries/framework/vendor/plugins/` |
| Highcharts / C3 | — | Charts and data viz | `libraries/framework/vendor/plugins/` |
| Moment.js | — | Date manipulation | `libraries/framework/vendor/plugins/` |
| Select2 | — | Searchable selects | `libraries/framework/vendor/plugins/` |
| Dropzone | — | Drag-and-drop file uploads | `libraries/framework/vendor/plugins/` |
| Font Awesome / Glyphicons | — | Icon fonts | `libraries/framework/` |
CSS preprocessing via **SCSS/SASS** — source in `layout/style-scss/`, compiled to `layout/style-css/custom.css`.
## External Service Integrations
| Service | Purpose | Config key |
|---------|---------|-----------|
| DataForSEO API v2/v3 | Google rank checking (primary) | credentials in `class.Cron.php` |
| Majestic | Domain metrics (TF, CF, backlinks) | `$config['site']['majestic_interval']` = 7d |
| SEMstorm | Keyword / traffic data | `$config['site']['semstorm_interval']` = 1d |
| ProxyMarket | Proxy provider (disabled) | `$config['proxy']['proxymarket-api']` |
| Proxy.Adding.pl | Proxy provider (disabled) | `$config['proxy']['adding-api']` |
| Internal proxy pool | HTTP proxies for scraping | table `pro_proxy_servers` |
## Deployment
- **FTP/SFTP** to `host700513.hostido.net.pl` — auto-upload via VS Code FTP-KR extension
- Remote root: `/public_html/`
- Config: `.vscode/ftp-kr.json`, `.vscode/sftp.json`
- No CI/CD pipeline; manual or FTP-based deploys only

33
.paul/codebase/testing.md Normal file
View File

@@ -0,0 +1,33 @@
# Testing — rank24.pl
## Test Infrastructure
**No automated tests exist.** There are no:
- PHPUnit configuration files (`phpunit.xml`, `phpunit.xml.dist`)
- Test directories (`tests/`, `test/`, `spec/`)
- Test class files (`*Test.php`, `*Spec.php`)
- CI/CD pipeline that runs tests
## How Testing Is Done
All testing is **manual** via the web interface:
1. **Web UI testing** — load pages in browser, exercise forms and interactions
2. **AJAX endpoints**`ajax-check.php` used for health checks / spot validation
3. **Cron output**`cron.php` returns JSON; check via browser or log inspection
4. **PHP error logs**`error_reporting` is suppressed in production; errors must be caught via server logs or temporary enabling of reporting
## Debugging Aids
- **OPD debug console** — `$config['db']['debug']` enables query logging via `opd.debug.php`
- **`\S::pre()`** — utility for dumping variables (calls `var_dump` / `print_r`), used inline and commented out: `//\S::pre($results); exit;`
- **`file_put_contents('google-rank.txt', $result)`** — ad-hoc scraping debug log in `class.GoogleRank.php`
- **`$debbbb = $data`** — debug variable left in `class.S.php`
## Adding Tests (Guidance for Future)
If automated testing is introduced:
- PHPUnit is the natural choice for PHP
- Heavy global state (`$db`, `$mdb`, `$user`) makes unit testing hard without refactoring — start with integration tests against a test database
- Factory classes (`factory\*`) are the best seam for testing — they have clear input/output and centralize DB access
- Cron methods in `class.Cron.php` return `['status' => 'ok'|'empty', 'msg' => '...']` — easily assertable