feat: add FormKit framework support and update dependencies

- Add `telecart_forms` table migration and default checkout form seeder
- Implement `FormsHandler` to fetch form schemas
- Update `OrderCreateService` to handle custom fields in order comments
- Add `update` method to QueryBuilder and Grammar
- Add `Arr::except` helper
- Update composer dependencies (Carbon, Symfony, PHPUnit, etc.)
- Improve `MigratorService` error handling
- Add unit tests for new functionality
This commit is contained in:
2025-11-15 01:23:17 +03:00
committed by Nikita Kiselev
parent ae9771dec4
commit 6a59dcc0c9
69 changed files with 12529 additions and 416 deletions

View File

@@ -0,0 +1,57 @@
<?php
namespace Bastion\Handlers;
use JsonException;
use Openguru\OpenCartFramework\Exceptions\EntityNotFoundException;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\Http\Response;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
class FormsHandler
{
private Builder $builder;
public function __construct(Builder $builder)
{
$this->builder = $builder;
}
/**
* @throws EntityNotFoundException
* @throws JsonException
*/
public function getFormByAlias(Request $request): JsonResponse
{
$alias = 'checkout';
//$request->json('alias');
if (! $alias) {
return new JsonResponse([
'error' => 'Form alias is required',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$form = $this->builder->newQuery()
->from('telecart_forms')
->where('alias', '=', $alias)
->firstOrNull();
if (! $form) {
throw new EntityNotFoundException("Form with alias `{$alias}` not found");
}
$schema = json_decode($form['schema'], true, 512, JSON_THROW_ON_ERROR);
return new JsonResponse([
'data' => [
'alias' => $alias,
'friendly_name' => $form['friendly_name'],
'is_custom' => filter_var($form['is_custom'], FILTER_VALIDATE_BOOLEAN),
'schema' => $schema,
'created_at' => $form['created_at'],
'updated_at' => $form['updated_at'],
],
]);
}
}

View File

@@ -11,6 +11,8 @@ use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\Http\Response;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\Support\Arr;
use Psr\Log\LoggerInterface;
@@ -21,19 +23,25 @@ class SettingsHandler
private SettingsService $settingsUpdateService;
private CacheInterface $cache;
private LoggerInterface $logger;
private Builder $builder;
private ConnectionInterface $connection;
public function __construct(
BotTokenConfigurator $botTokenConfigurator,
Settings $settings,
SettingsService $settingsUpdateService,
CacheInterface $cache,
LoggerInterface $logger
LoggerInterface $logger,
Builder $builder,
ConnectionInterface $connection
) {
$this->botTokenConfigurator = $botTokenConfigurator;
$this->settings = $settings;
$this->settingsUpdateService = $settingsUpdateService;
$this->cache = $cache;
$this->logger = $logger;
$this->builder = $builder;
$this->connection = $connection;
}
public function configureBotToken(Request $request): JsonResponse
@@ -62,17 +70,59 @@ class SettingsHandler
'mainpage_blocks',
]);
$data['forms'] = [];
$forms = $this->builder->newQuery()
->from('telecart_forms')
->get();
if ($forms) {
foreach ($forms as $form) {
$schema = json_decode($form['schema'], true, 512, JSON_THROW_ON_ERROR);
$data['forms'][$form['alias']] = [
'alias' => $form['alias'],
'friendly_name' => $form['friendly_name'],
'is_custom' => filter_var($form['is_custom'], FILTER_VALIDATE_BOOLEAN),
'schema' => $schema,
];
}
}
return new JsonResponse(compact('data'));
}
public function saveSettingsForm(Request $request): JsonResponse
{
$this->validate($request->json());
$input = $request->json();
$this->validate($input);
$this->settingsUpdateService->update(
$request->json(),
Arr::getWithKeys($input, [
'app',
'telegram',
'metrics',
'store',
'orders',
'texts',
'sliders',
'mainpage_blocks',
]),
);
// Update forms
$forms = Arr::get($input, 'forms', []);
foreach ($forms as $form) {
$schema = json_encode($form['schema'], JSON_THROW_ON_ERROR);
$this->builder->newQuery()
->where('alias', '=', $form['alias'])
->update('telecart_forms', [
'friendly_name' => $form['friendly_name'],
'is_custom' => $form['is_custom'],
'schema' => $schema,
]);
}
return new JsonResponse([], Response::HTTP_ACCEPTED);
}

View File

@@ -58,7 +58,7 @@ class BotTokenConfigurator
'webhook_url' => $webhookUrl,
];
} catch (TelegramClientException $exception) {
$this->logger->logException($exception);
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
if ($exception->getCode() === 404 || $exception->getCode() === 401) {
throw new BotTokenConfiguratorException(
'Telegram сообщает, что BotToken не верный. Проверьте корректность.'
@@ -67,7 +67,7 @@ class BotTokenConfigurator
throw new BotTokenConfiguratorException($exception->getMessage());
} catch (Exception | GuzzleException $exception) {
$this->logger->logException($exception);
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
throw new BotTokenConfiguratorException($exception->getMessage());
}
}

View File

@@ -27,4 +27,4 @@ class CachePruneTask extends BaseMaintenanceTask
{
return new DateInterval('P1D');
}
}
}

View File

@@ -2,6 +2,7 @@
use Bastion\Handlers\AutocompleteHandler;
use Bastion\Handlers\DictionariesHandler;
use Bastion\Handlers\FormsHandler;
use Bastion\Handlers\LogsHandler;
use Bastion\Handlers\SettingsHandler;
use Bastion\Handlers\StatsHandler;
@@ -24,4 +25,6 @@ return [
'getAutocompleteCategoriesFlat' => [AutocompleteHandler::class, 'getCategoriesFlat'],
'resetCache' => [SettingsHandler::class, 'resetCache'],
'getLogs' => [LogsHandler::class, 'getLogs'],
'getFormByAlias' => [FormsHandler::class, 'getFormByAlias'],
];

View File

@@ -19,24 +19,25 @@
}
],
"require": {
"php": "^7.4",
"ext-pdo": "*",
"psr/container": "^2.0",
"ext-json": "*",
"intervention/image": "^2.7",
"vlucas/phpdotenv": "^5.6",
"guzzlehttp/guzzle": "^7.9",
"symfony/cache": "^5.4",
"doctrine/dbal": "^3.10",
"ext-json": "*",
"ext-pdo": "*",
"guzzlehttp/guzzle": "^7.9",
"intervention/image": "^2.7",
"monolog/monolog": "^2.10",
"psr/log": "^1.1"
"nesbot/carbon": "^2.73",
"php": "^7.4",
"psr/container": "^2.0",
"psr/log": "^1.1",
"symfony/cache": "^5.4",
"vlucas/phpdotenv": "^5.6"
},
"require-dev": {
"roave/security-advisories": "dev-latest",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^9.6",
"doctrine/sql-formatter": "^1.3",
"mockery/mockery": "^1.6",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^9.6",
"roave/security-advisories": "dev-latest",
"squizlabs/php_codesniffer": "*"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
<?php
use Openguru\OpenCartFramework\Migrations\Migration;
return new class extends Migration {
public function up(): void
{
$sql = <<<SQL
CREATE TABLE IF NOT EXISTS `telecart_forms` (
`id` bigint(11) AUTO_INCREMENT PRIMARY KEY,
`alias` varchar(100) NOT NULL,
`friendly_name` varchar(100) NOT NULL,
`is_custom` tinyint(1) NOT NULL DEFAULT 0,
`schema` longtext NOT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
) collate = utf8_unicode_ci
SQL;
$this->database->statement($sql);
}
};

View File

@@ -0,0 +1,68 @@
<?php
use Carbon\Carbon;
use Openguru\OpenCartFramework\Migrations\Migration;
return new class extends Migration {
public function up(): void
{
$checkoutForm = json_encode(self::getCheckoutFormSchema(), JSON_THROW_ON_ERROR);
$this->database->insert('telecart_forms', [
'alias' => 'checkout',
'friendly_name' => 'Оформление заказа',
'schema' => $checkoutForm,
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
]);
}
private static function getCheckoutFormSchema(): array
{
return [
[
'id' => 'field_1_1763897608480',
'$formkit' => 'text',
'name' => 'firstname',
'label' => 'Имя',
'placeholder' => 'Например: Иван',
'help' => 'Введите ваше имя',
'validation' => 'required|length:0,32',
'prefixIcon' => 'avatarMan',
'locked' => true,
],
[
'id' => 'field_2_1763897611020',
'$formkit' => 'text',
'name' => 'lastname',
'label' => 'Фамилия',
'placeholder' => 'Например: Иванов',
'help' => 'Введите вашу фамилию',
'validation' => 'required|length:0,32',
'prefixIcon' => 'avatarMan',
'locked' => true,
],
[
'id' => 'field_5_1763897626036',
'$formkit' => 'tel',
'name' => 'telephone',
'label' => 'Телефон',
'placeholder' => 'Например: +7 (999) 000-00-00',
'validation' => 'required|length:0,32',
'help' => 'Введите ваш номер телефона.',
'prefixIcon' => 'telephone',
'locked' => true,
],
[
'id' => 'field_4_1763897617570',
'$formkit' => 'textarea',
'name' => 'comment',
'label' => 'Комментарий к заказу',
'placeholder' => 'Например: Домофон не работает',
'help' => 'Дополнительная информация к заказу',
'validation' => 'length:0,5000',
'locked' => true,
],
];
}
};

View File

@@ -66,6 +66,7 @@ class Container implements ContainerInterface
* @return T
* @psalm-param class-string<T>|string $id
* @psalm-suppress MoreSpecificImplementedParamType
*
*/
public function get(string $id)
{

View File

@@ -6,7 +6,6 @@ use Openguru\OpenCartFramework\Container\Container;
use Openguru\OpenCartFramework\Container\ServiceProvider;
use Psr\Log\LoggerInterface;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\Support\WorkLogsBag;
class MigrationsServiceProvider extends ServiceProvider
{

View File

@@ -109,6 +109,7 @@ SQL;
} catch (Exception $e) {
$this->connection->rollbackTransaction();
$this->logger->error("An error occurred while applying migration.", ['exception' => $e]);
break;
}
}

View File

@@ -455,4 +455,17 @@ class Builder
return $this;
}
public function update(string $table, array $values): bool
{
$sql = $this->grammar->compileUpdate($this, $table, $values);
$bindings = array_merge(
Utils::arrayFlatten($this->getBindings('join')),
array_values($values),
Utils::arrayFlatten($this->getBindings('where'))
);
return $this->connection->statement($sql, $bindings);
}
}

View File

@@ -201,4 +201,25 @@ abstract class Grammar
{
return 'GROUP BY ' . implode(', ', $groupBy);
}
public function compileUpdate(Builder $builder, string $table, array $values): string
{
$columns = [];
foreach ($values as $key => $value) {
$columns[] = "`{$key}` = ?";
}
$columns = implode(', ', $columns);
$joins = $this->compileJoins($builder, $builder->joins);
$joins = $joins ? ' ' . $joins : '';
$wheres = $this->compileWheres($builder, $builder->wheres);
$wheres = $wheres ? ' ' . $wheres : '';
return "UPDATE `{$table}`{$joins} SET {$columns}{$wheres}";
}
}

View File

@@ -206,4 +206,20 @@ class Arr
return $filtered;
}
/**
* Возвращает массив без указанных ключей.
*
* @param array $array Исходный массив
* @param array $keys Массив ключей, которые нужно исключить
* @return array Массив без исключенных ключей
*/
public static function except(array $array, array $keys): array
{
if (empty($keys)) {
return $array;
}
return array_diff_key($array, array_flip($keys));
}
}

View File

@@ -32,6 +32,7 @@
<env name="DB_DATABASE" value="ocstore3"/>
<env name="DB_USERNAME" value="root"/>
<env name="DB_PASSWORD" value="secret"/>
<env name="DB_PORT" value="3306"/>
<env name="DB_PREFIX" value="oc_"/>
</php>
</phpunit>

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Handlers;
use JsonException;
use Openguru\OpenCartFramework\Exceptions\EntityNotFoundException;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\Http\Response;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
class FormsHandler
{
private Builder $builder;
public function __construct(Builder $builder)
{
$this->builder = $builder;
}
/**
* @throws EntityNotFoundException
* @throws JsonException
*/
public function getForm(Request $request): JsonResponse
{
$alias = $request->json('alias');
if (! $alias) {
return new JsonResponse([
'error' => 'Form alias is required',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$form = $this->builder->newQuery()
->from('telecart_forms')
->where('alias', '=', $alias)
->firstOrNull();
if (! $form) {
throw new EntityNotFoundException("Form with alias `{$alias}` not found");
}
$schema = json_decode($form['schema'], true, 512, JSON_THROW_ON_ERROR);
return new JsonResponse([
'data' => [
'schema' => $schema,
],
]);
}
}

View File

@@ -55,7 +55,7 @@ class ProductsHandler
'message' => 'Product with id ' . $productId . ' not found',
], Response::HTTP_NOT_FOUND);
} catch (Exception $exception) {
$this->logger->logException($exception);
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
throw new RuntimeException('Error get product with id ' . $productId, 500);
}

View File

@@ -91,7 +91,7 @@ class TelegramHandler
} catch (TelegramCommandNotFoundException $exception) {
$this->telegramService->sendMessage($chatId, 'Неверная команда');
} catch (Exception $exception) {
$this->logger->logException($exception);
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
}
return new JsonResponse([]);

View File

@@ -4,16 +4,14 @@ declare(strict_types=1);
namespace App\Services;
use App\Exceptions\OrderValidationFailedException;
use DateTime;
use Carbon\Carbon;
use Exception;
use Psr\Log\LoggerInterface;
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\Telegram\TelegramService;
use Openguru\OpenCartFramework\Validator\ValidationRuleNotFoundException;
use Openguru\OpenCartFramework\Validator\ValidatorInterface;
use Psr\Log\LoggerInterface;
use RuntimeException;
class OrderCreateService
@@ -24,7 +22,6 @@ class OrderCreateService
private SettingsService $settings;
private TelegramService $telegramService;
private LoggerInterface $logger;
private ValidatorInterface $validator;
public function __construct(
ConnectionInterface $database,
@@ -32,8 +29,7 @@ class OrderCreateService
OcRegistryDecorator $registry,
SettingsService $settings,
TelegramService $telegramService,
LoggerInterface $logger,
ValidatorInterface $validator
LoggerInterface $logger
) {
$this->database = $database;
$this->cartService = $cartService;
@@ -41,18 +37,11 @@ class OrderCreateService
$this->settings = $settings;
$this->telegramService = $telegramService;
$this->logger = $logger;
$this->validator = $validator;
}
public function create(array $data, array $meta = []): array
{
try {
$this->validate($data);
} catch (ValidationRuleNotFoundException $e) {
throw new RuntimeException($e->getMessage());
}
$now = date('Y-m-d H:i:s');
$now = Carbon::now();
$storeId = $this->settings->get('store.oc_store_id');
$storeName = $this->settings->config()->getApp()->getAppName();
$orderStatusId = $this->settings->config()->getOrders()->getOrderDefaultStatusId();
@@ -70,12 +59,16 @@ class OrderCreateService
$orderData = [
'store_id' => $storeId,
'store_name' => $storeName,
'firstname' => $data['firstName'],
'lastname' => $data['lastName'],
'email' => $data['email'],
'telephone' => $data['phone'],
'comment' => $data['comment'],
'shipping_address_1' => $data['address'],
'firstname' => $data['firstname'] ?? '',
'lastname' => $data['lastname'] ?? '',
'email' => $data['email'] ?? '',
'telephone' => $data['telephone'] ?? '',
'comment' => $data['comment'] ?? '',
'payment_method' => $data['payment_method'] ?? '',
'shipping_address_1' => $data['shipping_address_1'] ?? '',
'shipping_city' => $data['shipping_city'] ?? '',
'shipping_zone' => $data['shipping_zone'] ?? '',
'shipping_postcode' => $data['shipping_postcode'] ?? '',
'total' => $total,
'order_status_id' => $orderStatusId,
'ip' => $meta['ip'] ?? '',
@@ -93,7 +86,7 @@ class OrderCreateService
$orderId = null;
$this->database->transaction(
function () use (&$orderData, $products, $totals, $orderStatusId, $now, &$orderId) {
function () use (&$orderData, $products, $totals, $orderStatusId, $now, &$orderId, $data) {
$success = $this->database->insert(db_table('order'), $orderData);
if (! $success) {
@@ -157,13 +150,14 @@ class OrderCreateService
}
// Insert history
$success = $this->database->insert(db_table('order_history'), [
$history = [
'order_id' => $orderId,
'order_status_id' => $orderStatusId,
'notify' => 0,
'comment' => 'Заказ оформлен через Telegram Mini App',
'comment' => $this->formatHistoryComment($data),
'date_added' => $now,
]);
];
$success = $this->database->insert(db_table('order_history'), $history);
if (! $success) {
[, $error] = $this->database->getLastError();
@@ -182,9 +176,9 @@ class OrderCreateService
$dateTimeFormatted = '';
try {
$dateTimeFormatted = (new DateTime($orderData['date_added']))->format('d.m.Y H:i');
$dateTimeFormatted = $now->format('d.m.Y H:i');
} catch (Exception $exception) {
$this->logger->logException($exception);
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
}
return [
@@ -197,25 +191,6 @@ class OrderCreateService
];
}
/**
* @throws ValidationRuleNotFoundException
*/
private function validate(array $data): void
{
$v = $this->validator->make($data, $this->makeValidationRulesFromSettings(), [
'firstName' => 'Имя',
'lastName' => 'Фамилия',
'email' => 'E-mail',
'phone' => 'Номер телефона',
'address' => 'Адрес доставки',
'comment' => 'Комментарий',
]);
if ($v->fails()) {
throw new OrderValidationFailedException($v->getErrors());
}
}
private function sendNotifications(array $orderData, array $tgInitData): void
{
$variables = [
@@ -239,8 +214,10 @@ class OrderCreateService
try {
$this->telegramService->sendMessage((int) $chatId, $message);
} catch (Exception $exception) {
$this->logger->error("Telegram sendMessage to owner error. ChatID: $chatId, Message: $message");
$this->logger->logException($exception);
$this->logger->error(
"Telegram sendMessage to owner error. ChatID: $chatId, Message: $message",
['exception' => $exception],
);
}
}
@@ -253,19 +230,38 @@ class OrderCreateService
try {
$this->telegramService->sendMessage($customerChatId, $message);
} catch (Exception $exception) {
$this->logger->error("Telegram sendMessage to customer error. ChatID: $chatId, Message: $message");
$this->logger->logException($exception);
$this->logger->error(
"Telegram sendMessage to customer error. ChatID: $chatId, Message: $message",
['exception' => $exception]
);
}
}
}
private function makeValidationRulesFromSettings(): array
private function formatHistoryComment(array $data): string
{
return [
'firstName' => 'required',
'lastName' => 'required',
'phone' => 'required',
'email' => 'email',
];
$customFields = Arr::except($data, [
'firstname',
'lastname',
'email',
'telephone',
'comment',
'shipping_address_1',
'shipping_city',
'shipping_zone',
'shipping_postcode',
'payment_method',
'tgData',
]);
$additionalString = '';
if ($customFields) {
$additionalString = "\n\nДополнительная информация по заказу:\n";
foreach ($customFields as $field => $value) {
$additionalString .= $field . ': ' . $value . "\n";
}
}
return "Заказ оформлен через Telegram Mini App.{$additionalString}";
}
}

View File

@@ -298,7 +298,7 @@ class ProductsService
'alt' => Utils::htmlEntityEncode($product_info['name']),
];
} catch (Exception $e) {
$this->logger->logException($e);
$this->logger->error($e->getMessage(), ['exception' => $e]);
}
}

View File

@@ -391,6 +391,10 @@ class SettingsSerializerService
}
if (isset($data['port'])) {
if (is_string($data['port']) && ctype_digit($data['port'])) {
$data['port'] = (int) $data['port'];
}
if (! is_int($data['port'])) {
throw new InvalidArgumentException('database.port must be an integer');
}

View File

@@ -142,7 +142,7 @@ MARKDOWN;
} catch (ClientException $exception) {
$this->telegram->sendMessage($chatId, 'Ошибка: ' . $exception->getResponse()->getBody()->getContents());
} catch (Throwable $exception) {
$this->logger->logException($exception);
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
$this->telegram->sendMessage($chatId, 'Произошла ошибка');
}
}

View File

@@ -1,10 +1,10 @@
<?php
use App\Handlers\BannerHandler;
use App\Handlers\BlocksHandler;
use App\Handlers\CartHandler;
use App\Handlers\CategoriesHandler;
use App\Handlers\FiltersHandler;
use App\Handlers\FormsHandler;
use App\Handlers\HealthCheckHandler;
use App\Handlers\OrderHandler;
use App\Handlers\ProductsHandler;
@@ -30,4 +30,6 @@ return [
'webhook' => [TelegramHandler::class, 'webhook'],
'processBlock' => [BlocksHandler::class, 'processBlock'],
'getForm' => [FormsHandler::class, 'getForm'],
];

View File

@@ -44,25 +44,82 @@ class TestCase extends BaseTestCase
private function bootstrapApplication(): Application
{
$app = ApplicationFactory::create([
'database' => [
'host' => getenv('DB_HOSTNAME') ?: 'mysql',
'database' => getenv('DB_DATABASE') ?: 'ocstore3',
'username' => getenv('DB_USERNAME') ?: 'root',
'password' => getenv('DB_PASSWORD') ?: 'secret',
'prefix' => getenv('DB_PREFIX') ?: 'oc_',
'port' => getenv('DB_PORT') ?: 3306,
'app' => [
'app_enabled' => true,
'app_name' => 'Telecart',
'app_icon' => null,
"theme_light" => "light",
"theme_dark" => "dark",
"app_debug" => false,
'shop_base_url' => 'http://localhost', // for catalog: HTTPS_SERVER, for admin: HTTPS_CATALOG
'language_id' => 10,
],
'logs' => [
'path' => sys_get_temp_dir(),
],
'base_url' => 'http://localhost',
'public_url' => 'http://localhost',
'telegram' => [
'bot_token' => 'test_token',
'chat_id' => '123',
'owner_notification_template' => 'Test',
'customer_notification_template' => 'Test',
'mini_app_url' => 'https://example.com',
"bot_token" => "",
"chat_id" => null,
"owner_notification_template" => 'owner_notification_template',
"customer_notification_template" => 'customer_notification_template',
"mini_app_url" => "",
],
"metrics" => [
"yandex_metrika_enabled" => false,
"yandex_metrika_counter" => "",
],
'store' => [
'enable_store' => true,
'feature_coupons' => true,
'feature_vouchers' => true,
'oc_store_id' => 777,
'oc_default_currency' => 'RRR',
'oc_config_tax' => true,
],
'texts' => [
'text_no_more_products' => 'Это всё по текущему запросу. Попробуйте уточнить фильтры или поиск.',
'text_empty_cart' => 'Ваша корзина пуста.',
'text_order_created_success' => 'Ваш заказ успешно оформлен и будет обработан в ближайшее время.'
],
'orders' => [
'order_default_status_id' => 11,
'oc_customer_group_id' => 99,
],
'mainpage_blocks' => [
[
'type' => 'products_feed',
'title' => '',
'description' => '',
'is_enabled' => true,
'goal_name' => '',
'data' => [
'max_page_count' => 10,
],
],
],
'cache' => [
'namespace' => 'telecart',
'default_lifetime' => 60 * 60 * 24,
'options' => [
'db_table' => 'telecart_cache_items',
],
],
'logs' => [
'path' => '/tmp'
],
'database' => [
'host' => env('DB_HOSTNAME'),
'database' => env('DB_DATABASE'),
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
'prefix' => env('DB_PREFIX') ?: 'oc_',
'port' => env('DB_PORT'),
],
]);

View File

@@ -663,4 +663,34 @@ class ArrTest extends TestCase
$this->assertSame([], $result);
}
public function testExceptRemovesSpecifiedKeys(): void
{
$array = [
'app' => 'telecart',
'debug' => true,
'version' => '1.0.0',
];
$result = Arr::except($array, ['debug', 'nonexistent']);
$expected = [
'app' => 'telecart',
'version' => '1.0.0',
];
$this->assertSame($expected, $result);
}
public function testExceptReturnsOriginalArrayWhenNoKeysProvided(): void
{
$array = [
'app' => 'telecart',
'debug' => true,
];
$result = Arr::except($array, []);
$this->assertSame($array, $result);
}
}

View File

@@ -512,4 +512,92 @@ class BuilderTest extends TestCase
);
}
public function testUpdate(): void
{
$connection = $this->createMock(MySqlConnection::class);
$connection->expects($this->once())
->method('statement')
->with(
'UPDATE `telecart_settings` SET `alias` = ?, `foo` = ? WHERE alias = ?',
['foobar2', 'bar2', 'foobar']
)
->willReturn(true);
$builder = new Builder($connection, new MySqlGrammar());
$builder->newQuery()
->where('alias', '=', 'foobar')
->update('telecart_settings', [
'alias' => 'foobar2',
'foo' => 'bar2',
]);
}
public function testUpdateJsonField(): void
{
$json = json_encode(['xyz' => 'bazz'], JSON_THROW_ON_ERROR);
$connection = $this->createMock(MySqlConnection::class);
$connection->expects($this->once())
->method('statement')
->with(
'UPDATE `telecart_settings` SET `json` = ? WHERE alias = ?',
[$json, 'foobar']
)
->willReturn(true);
$builder = new Builder($connection, new MySqlGrammar());
$builder->newQuery()
->where('alias', '=', 'foobar')
->update('telecart_settings', [
'json' => $json,
]);
}
public function testUpdateJsonFieldWithCyrillic(): void
{
$json = json_encode(['xyz' => 'привет'], JSON_THROW_ON_ERROR);
$connection = $this->createMock(MySqlConnection::class);
$connection->expects($this->once())
->method('statement')
->with(
'UPDATE `telecart_settings` SET `json` = ? WHERE alias = ?',
[$json, 'foobar']
)
->willReturn(true);
$builder = new Builder($connection, new MySqlGrammar());
$builder->newQuery()
->where('alias', '=', 'foobar')
->update('telecart_settings', [
'json' => $json,
]);
}
public function testUpdateWithJoin(): void
{
$connection = $this->createMock(MySqlConnection::class);
$connection->expects($this->once())
->method('statement')
->with(
'UPDATE `t1` INNER JOIN t2 ON t1.id = t2.t1_id SET `t1.foo` = ? WHERE t2.bar = ?',
['new_value', 'condition']
)
->willReturn(true);
$builder = new Builder($connection, new MySqlGrammar());
$builder->newQuery()
->join('t2', function (JoinClause $join) {
$join->on('t1.id', '=', 't2.t1_id');
})
->where('t2.bar', '=', 'condition')
->update('t1', [
't1.foo' => 'new_value',
]);
}
}

View File

@@ -9,6 +9,7 @@ class HelpersTest extends TestCase
{
public function testDbTable(): void
{
$this->assertEquals('oc_some_table', db_table('some_table'));
}

View File

@@ -230,4 +230,52 @@ class MySqlGrammarTest extends TestCase
$this->grammar->compileGroupBy($mock, ['foo', 'bar'])
);
}
public function testCompileUpdate(): void
{
$builder = m::mock(Builder::class);
$builder->joins = [];
$builder->wheres = [
[
'type' => 'Basic',
'column' => 'id',
'operator' => '=',
'value' => 1,
'boolean' => 'and',
]
];
$this->assertEquals(
"UPDATE `table` SET `foo` = ?, `bar` = ? WHERE id = ?",
$this->grammar->compileUpdate($builder, 'table', ['foo' => 'bar', 'bar' => 'baz'])
);
}
public function testCompileUpdateWithJoins(): void
{
$joinClause = m::mock(JoinClause::class);
$joinClause->table = 'other_table';
$joinClause->type = 'inner';
$joinClause->first = 'table.id';
$joinClause->operator = '=';
$joinClause->second = 'other_table.id';
$joinClause->wheres = [];
$builder = m::mock(Builder::class);
$builder->joins = [$joinClause];
$builder->wheres = [
[
'type' => 'Basic',
'column' => 'table.id',
'operator' => '=',
'value' => 1,
'boolean' => 'and',
]
];
$this->assertEquals(
"UPDATE `table` INNER JOIN other_table ON table.id = other_table.id SET `foo` = ?, `bar` = ? WHERE table.id = ?",
$this->grammar->compileUpdate($builder, 'table', ['foo' => 'bar', 'bar' => 'baz'])
);
}
}

View File

@@ -0,0 +1,180 @@
<?php
namespace Tests\Unit\Services;
use App\Services\CartService;
use App\Services\OrderCreateService;
use App\Services\SettingsService;
use Carbon\Carbon;
use Mockery as m;
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\Telegram\TelegramService;
use Openguru\OpenCartFramework\Validator\ValidatorInterface;
use Psr\Log\LoggerInterface;
use Tests\TestCase;
class OrderCreateServiceTest extends TestCase
{
public function testCreateNewOrder(): void
{
$data = [
'firstname' => 'John',
'lastname' => 'Doe',
'email' => 'test@mail.com',
'telephone' => '+79999999999',
'comment' => 'Comment',
'shipping_address_1' => 'Russia, Moscow',
'shipping_city' => 'Moscow',
'shipping_zone' => 'Rostov',
'shipping_postcode' => 'Rostov',
'payment_method' => 'Cash',
'field_1' => 'кирилица',
'field_2' => 'hello',
'tgData' => [],
];
$meta = [
'ip' => '127.0.0.1',
'user_agent' => 'UnitTests',
];
$dateAdded = '2026-01-01 00:00:00';
$dateAddedFormatted = '01.01.2026 00:00';
Carbon::setTestNow($dateAdded);
$totalText = '100.5р.';
$totalNumeric = 100.5;
$totals = [];
$currencyId = 100;
$currencyCode = $this->app->getConfigValue('store.oc_default_currency');
$currencyValue = 222;
$orderId = 1111;
$orderProductId = 223;
$product = [
'product_id' => 93,
'name' => 'Product Name',
'model' => 'Product Model',
'quantity' => 1,
'price_numeric' => 100,
'total_numeric' => 100,
'reward_numeric' => 88,
];
$products = [$product];
$connection = m::mock(ConnectionInterface::class);
$connection->shouldReceive('transaction')->once()->andReturnUsing(fn($c) => $c());
$connection->shouldReceive('lastInsertId')->once()->andReturn($orderId)->ordered();
$connection->shouldReceive('lastInsertId')->once()->andReturn($orderProductId)->ordered();
$connection->shouldReceive('insert')->once()->with(
db_table('order'),
[
'store_id' => $this->app->getConfigValue('store.oc_store_id'),
'store_name' => $this->app->getConfigValue('app.app_name'),
'firstname' => $data['firstname'],
'lastname' => $data['lastname'],
'email' => $data['email'],
'telephone' => $data['telephone'],
'payment_method' => $data['payment_method'],
'comment' => $data['comment'],
'shipping_address_1' => $data['shipping_address_1'],
'shipping_city' => $data['shipping_city'],
'shipping_zone' => $data['shipping_zone'],
'shipping_postcode' => $data['shipping_postcode'],
'total' => $totalNumeric,
'order_status_id' => $this->app->getConfigValue('orders.order_default_status_id'),
'ip' => $meta['ip'],
'forwarded_ip' => $meta['ip'],
'user_agent' => $meta['user_agent'],
'date_added' => $dateAdded,
'date_modified' => $dateAdded,
'language_id' => $this->app->getConfigValue('app.language_id'),
'currency_id' => $currencyId,
'currency_code' => $currencyCode,
'currency_value' => $currencyValue,
'customer_group_id' => $this->app->getConfigValue('orders.oc_customer_group_id'),
],
)
->andReturn(true);
$connection->shouldReceive('insert')->once()->with(
db_table('order_product'),
[
'order_id' => $orderId,
'product_id' => $product['product_id'],
'name' => $product['name'],
'model' => $product['model'],
'quantity' => $product['quantity'],
'price' => $product['price_numeric'],
'total' => $product['total_numeric'],
'reward' => $product['reward_numeric'],
]
)->andReturn(true);
$connection->shouldReceive('insert')->once()->with(
db_table('order_history'),
[
'order_id' => $orderId,
'order_status_id' => $this->app->getConfigValue('orders.order_default_status_id'),
'notify' => 0,
'comment' => 'Заказ оформлен через Telegram Mini App.'
. "\n\nДополнительная информация по заказу:"
. "\nfield_1: кирилица"
. "\nfield_2: hello\n",
'date_added' => $dateAdded,
],
)->andReturnTrue();
$cartService = m::mock(CartService::class);
$cartService
->shouldReceive('getCart')
->once()
->andReturn([
'total' => $totalNumeric,
'totals' => $totals,
'products' => $products,
'total_text' => $totalText,
]);
$cartService->shouldReceive('flush')->once()->andReturnNull();
$ocCurrencyMock = m::mock();
$ocCurrencyMock->shouldReceive('getId')->once()->andReturn($currencyId);
$ocCurrencyMock->shouldReceive('getValue')->once()->andReturn($currencyValue);
$ocSessionMock = m::mock();
$ocSessionMock->data = [
'currency' => $currencyCode,
];
$registryMock = m::mock('Registry');
$registryMock->shouldReceive('get')->with('currency')->andReturn($ocCurrencyMock);
$registryMock->shouldReceive('get')->with('session')->andReturn($ocSessionMock);
$ocRegistryDecorator = new OcRegistryDecorator($registryMock);
$telegramServiceMock = m::mock(TelegramService::class);
$loggerMock = m::mock(LoggerInterface::class);
$validatorMock = m::mock(ValidatorInterface::class);
$service = new OrderCreateService(
$connection,
$cartService,
$ocRegistryDecorator,
$this->app->get(SettingsService::class),
$telegramServiceMock,
$loggerMock,
$validatorMock,
);
$order = $service->create($data, $meta);
$this->assertEquals([
'id' => $orderId,
'created_at' => $dateAddedFormatted,
'total' => $totalText,
'final_total_numeric' => $totalNumeric,
'currency' => $currencyCode,
'products' => $products,
], $order);
}
}