Files
api/services/nginx/app/tests/Support/Api/ApiFixtures.php
T

924 lines
32 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Support\Api;
use mysqli;
use Predis\Client as PredisClient;
use RuntimeException;
final class ApiFixtures
{
private static int $sequence = 0;
public function __construct(
private readonly mysqli $db,
private readonly ?PredisClient $redis,
private readonly ApiCleanup $cleanup,
) {
}
/**
* @param array<string, mixed> $attributes
* @param array<int, string> $permissions
* @return array<string, mixed>
*/
public function createUser(array $attributes = [], array $permissions = []): array
{
$groupId = $attributes['group_id'] ?? null;
if ($groupId === null && $permissions !== []) {
$group = $this->createGroup([], $permissions);
$groupId = $group['id'];
}
$customerNumber = (int)($attributes['customer_number'] ?? $this->uniqueCustomerNumber());
$displayName = (string)($attributes['display_name'] ?? ('API User ' . $customerNumber));
$email = (string)($attributes['email'] ?? ('api+' . $customerNumber . '@example.test'));
$passwordPlaintext = (string)($attributes['password_plaintext'] ?? 'Secret123!');
$now = $this->now();
$userId = $this->insertRow('users', [
'customer_number' => $customerNumber,
'display_name' => $displayName,
'email' => $email,
'phone_country_code' => 45,
'phone' => (int)substr((string)$customerNumber, -8),
'password' => password_hash($passwordPlaintext, PASSWORD_DEFAULT),
'group_id' => (int)($groupId ?? 0),
'two_factor_enabled' => (int)($attributes['two_factor_enabled'] ?? 0),
'two_factor_secret' => $attributes['two_factor_secret'] ?? null,
'created_at' => $attributes['created_at'] ?? $now,
'updated_at' => $attributes['updated_at'] ?? $now,
]);
$this->cleanup->add(function () use ($userId, $customerNumber): void {
$this->deleteWhere('customer_attributes', ['user_id' => $userId]);
$this->deleteWhere('tokens', ['user_id' => $userId]);
$this->deleteById('users', $userId);
$this->deleteRedisKey('user_id_from_customer_number_' . $customerNumber);
$this->deleteRedisKey('customer_number_from_user_id_' . $userId);
$this->deleteRedisKey('users_' . $customerNumber . '_economic_customer_name');
$this->deleteRedisKey('users_' . $userId . '_economic_customer');
$this->deleteRedisPattern('perm:user:' . $userId . ':*');
});
$economicName = (string)($attributes['economic_customer_name'] ?? $displayName);
$this->seedCustomerNameCache($customerNumber, $economicName);
$this->seedEconomicCustomerCache($userId, $customerNumber, $economicName, $email);
return [
'id' => $userId,
'group_id' => (int)($groupId ?? 0),
'customer_number' => $customerNumber,
'display_name' => $displayName,
'email' => $email,
'password_plaintext' => $passwordPlaintext,
];
}
/**
* @param array<string, mixed> $attributes
* @param array<int, string> $permissions
* @return array<string, mixed>
*/
public function createGroup(array $attributes = [], array $permissions = []): array
{
$groupId = $this->insertRow('groups', [
'name' => (string)($attributes['name'] ?? ('API Group ' . $this->uniqueSuffix())),
'description' => (string)($attributes['description'] ?? 'API test group'),
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
]);
foreach ($permissions as $permission) {
$permissionId = $this->insertRow('groups_permissions', [
'group_id' => $groupId,
'permission' => $permission,
'created_at' => $this->now(),
]);
$this->cleanup->add(fn() => $this->deleteById('groups_permissions', $permissionId));
}
$this->cleanup->add(fn() => $this->deleteById('groups', $groupId));
return ['id' => $groupId];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createDepartment(array $attributes = []): array
{
$departmentId = $this->insertRow('departments', [
'name' => (string)($attributes['name'] ?? ('API Department ' . $this->uniqueSuffix())),
'description' => (string)($attributes['description'] ?? 'API department'),
'economic_department_id' => (int)($attributes['economic_department_id'] ?? 0),
'slack_webhook' => $attributes['slack_webhook'] ?? null,
'dimension' => (int)($attributes['dimension'] ?? 0),
'branding' => (int)($attributes['branding'] ?? 0),
'visible' => (int)($attributes['visible'] ?? 1),
'latitude' => $attributes['latitude'] ?? 0.0,
'longitude' => $attributes['longitude'] ?? 0.0,
'order_priority' => (int)($attributes['order_priority'] ?? 0),
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
]);
$this->cleanup->add(fn() => $this->deleteById('departments', $departmentId));
$this->cleanup->add(fn() => $this->deleteRedisPattern('department_*'));
return ['id' => $departmentId];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createDepartmentGate(array $attributes): array
{
$departmentId = (int)($attributes['department'] ?? $attributes['department_id'] ?? 0);
if ($departmentId <= 0) {
throw new RuntimeException('Department gates require a department id.');
}
$gateId = $this->insertRow('department_gates', [
'department' => $departmentId,
'is_entrance' => (bool)($attributes['is_entrance'] ?? false),
'is_exit' => (bool)($attributes['is_exit'] ?? false),
'name' => (string)($attributes['name'] ?? ('API Gate ' . $this->uniqueSuffix())),
'config' => $attributes['config'] ?? [
'type' => 'PHONE_CALL',
'phone_number' => '+4511122233',
'call_duration_threshold' => 5,
],
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
'deleted_at' => $attributes['deleted_at'] ?? null,
]);
$this->cleanup->add(fn() => $this->deleteById('department_gates', $gateId));
return ['id' => $gateId, 'department' => $departmentId];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createCategory(array $attributes = []): array
{
$categoryId = $this->insertRow('categories', [
'name' => (string)($attributes['name'] ?? ('API Category ' . $this->uniqueSuffix())),
'description' => (string)($attributes['description'] ?? 'API category'),
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
]);
$this->cleanup->add(fn() => $this->deleteById('categories', $categoryId));
return ['id' => $categoryId];
}
public function linkDepartmentCategory(int $departmentId, int $categoryId): int
{
$linkId = $this->insertRow('department_categories', [
'department_id' => $departmentId,
'category_id' => $categoryId,
'created_at' => $this->now(),
'updated_at' => $this->now(),
'deleted_at' => null,
]);
$this->cleanup->add(fn() => $this->deleteById('department_categories', $linkId));
return $linkId;
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createInvoiceCollection(array $attributes): array
{
$customerNumber = (int)($attributes['customer_number'] ?? 0);
if ($customerNumber <= 0) {
throw new RuntimeException('Invoice collections require a customer_number.');
}
$invoiceId = $this->insertRow('collected_order_invoices', [
'customer_number' => $customerNumber,
'name' => (string)($attributes['name'] ?? ('API Invoice ' . $customerNumber)),
'notes' => $attributes['notes'] ?? '',
'processor' => $attributes['processor'] ?? null,
'external_id' => $attributes['external_id'] ?? null,
'booked_invoice_id' => $attributes['booked_invoice_id'] ?? null,
'po_number' => $attributes['po_number'] ?? null,
'error_message' => $attributes['error_message'] ?? null,
'closed_at' => $attributes['closed_at'] ?? null,
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
]);
$this->cleanup->add(fn() => $this->deleteById('collected_order_invoices', $invoiceId));
return [
'id' => $invoiceId,
'customer_number' => $customerNumber,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createOrder(array $attributes): array
{
$customerId = (int)($attributes['customer_id'] ?? 0);
$departmentId = (int)($attributes['department_id'] ?? 0);
if ($customerId <= 0 || $departmentId <= 0) {
throw new RuntimeException('Orders require customer_id and department_id.');
}
$invoiceCollectionId = (int)($attributes['invoice_collection_id'] ?? 0);
if ($invoiceCollectionId <= 0) {
$invoiceCollection = $this->createInvoiceCollection([
'customer_number' => $customerId,
]);
$invoiceCollectionId = (int)$invoiceCollection['id'];
}
$orderId = $this->insertRow('orders', [
'customer_id' => $customerId,
'cashier_id' => (int)($attributes['cashier_id'] ?? 1),
'department_id' => $departmentId,
'reference' => (string)($attributes['reference'] ?? 'API-REF'),
'notes' => (string)($attributes['notes'] ?? 'API order'),
'reg_1' => (string)($attributes['reg_1'] ?? 'ABCD123'),
'reg_2' => (string)($attributes['reg_2'] ?? ''),
'reg_3' => (string)($attributes['reg_3'] ?? ''),
'invoice_collection_id' => $invoiceCollectionId,
'booking_id' => $attributes['booking_id'] ?? null,
'wash_id' => $attributes['wash_id'] ?? null,
'lane' => $attributes['lane'] ?? null,
'po' => $attributes['po'] ?? null,
'using_hand_held' => (int)($attributes['using_hand_held'] ?? 0),
'include_in_invoice' => $attributes['include_in_invoice'] ?? 1,
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
'completed_at' => $attributes['completed_at'] ?? null,
'deleted_at' => null,
]);
$this->cleanup->add(function () use ($orderId): void {
$this->deleteWhere('order_items', ['order_id' => $orderId]);
$this->deleteById('orders', $orderId);
$this->deleteRedisKey('orders_' . $orderId . '_asArray');
$this->deleteRedisKey('orders_' . $orderId . '_pending_handheld_cache_indicator');
});
return [
'id' => $orderId,
'invoice_collection_id' => $invoiceCollectionId,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createVehicle(array $attributes): array
{
$customerId = (int)($attributes['customer_id'] ?? 0);
$type = (int)($attributes['type'] ?? 0);
$reg = trim((string)($attributes['reg'] ?? ''));
if ($customerId <= 0 || $type <= 0 || $reg === '') {
throw new RuntimeException('Vehicles require customer_id, type, and reg.');
}
$vehicleId = $this->insertRow('customer_vehicles', [
'customer_id' => $customerId,
'type' => $type,
'reg' => $reg,
'wash_subscription' => (int)($attributes['wash_subscription'] ?? 0),
'notes' => $attributes['notes'] ?? null,
'reference' => $attributes['reference'] ?? null,
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
]);
$this->cleanup->add(fn() => $this->deleteById('customer_vehicles', $vehicleId));
return [
'id' => $vehicleId,
'customer_id' => $customerId,
'type' => $type,
'reg' => $reg,
'wash_subscription' => (bool)($attributes['wash_subscription'] ?? false),
'reference' => $attributes['reference'] ?? null,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createOrderItem(array $attributes): array
{
$orderId = (int)($attributes['order_id'] ?? 0);
$productId = (int)($attributes['product_id'] ?? 0);
$cashierId = (int)($attributes['cashier_id'] ?? 0);
if ($orderId <= 0 || $productId <= 0 || $cashierId <= 0) {
throw new RuntimeException('Order items require order_id, product_id, and cashier_id.');
}
$orderItemId = $this->insertRow('order_items', [
'order_id' => $orderId,
'product_id' => $productId,
'reference' => (string)($attributes['reference'] ?? ''),
'notes' => $attributes['notes'] ?? null,
'cashier_id' => $cashierId,
'price' => (int)($attributes['price'] ?? 0),
'quantity' => (int)($attributes['quantity'] ?? 1),
'related_item_id' => $attributes['related_item_id'] ?? null,
'include_in_invoice' => (int)($attributes['include_in_invoice'] ?? 1),
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
'deleted_at' => $attributes['deleted_at'] ?? null,
]);
$this->cleanup->add(fn() => $this->deleteById('order_items', $orderItemId));
return [
'id' => $orderItemId,
'order_id' => $orderId,
'product_id' => $productId,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createSubuser(array $attributes = []): array
{
$username = (string)($attributes['username'] ?? ('api-subuser-' . strtolower($this->uniqueSuffix())));
$passwordPlaintext = (string)($attributes['password_plaintext'] ?? 'Secret123!');
$name = (string)($attributes['name'] ?? 'API Subuser');
$email = (string)($attributes['email'] ?? ($username . '@example.test'));
$now = $this->now();
$subuserId = $this->insertRow('subusers', [
'username' => $username,
'password' => password_hash($passwordPlaintext, PASSWORD_DEFAULT),
'name' => $name,
'email' => $email,
'phone_country_code' => 45,
'phone' => 10000000 + (++self::$sequence),
'two_factor_enabled' => 0,
'two_factor_secret' => null,
'created_at' => $attributes['created_at'] ?? $now,
'updated_at' => $attributes['updated_at'] ?? $now,
'suspended_at' => $attributes['suspended_at'] ?? null,
]);
$this->cleanup->add(function () use ($subuserId): void {
$this->deleteWhere('subuser_grants', ['subuser' => $subuserId]);
$this->deleteWhere('tokens', ['user_id' => $subuserId, 'type' => 'AUTH_TOKEN_SUBUSER']);
$this->deleteById('subusers', $subuserId);
$this->deleteRedisPattern('session_token:*');
});
return [
'id' => $subuserId,
'username' => $username,
'password_plaintext' => $passwordPlaintext,
];
}
public function grantSubuser(int $subuserId, int $customerNumber, array $permissions): int
{
$grantId = $this->insertRow('subuser_grants', [
'billing_customer_number' => $customerNumber,
'subuser' => $subuserId,
'enabled' => 1,
'note' => 'API test grant',
'permissions' => json_encode(array_values($permissions), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'created_at' => $this->now(),
'updated_at' => $this->now(),
'deleted_at' => null,
]);
$this->cleanup->add(fn() => $this->deleteById('subuser_grants', $grantId));
return $grantId;
}
/**
* @return array{user:array<string,mixed>,token:string,headers:array<string,string>}
*/
public function createUserSession(array $permissions = [], array $userAttributes = []): array
{
if ($permissions === [] && !array_key_exists('group_id', $userAttributes)) {
$group = $this->createGroup();
$userAttributes['group_id'] = $group['id'];
}
$user = $this->createUser($userAttributes, $permissions);
$token = $this->createAuthToken((int)$user['id']);
return [
'user' => $user,
'token' => $token,
'headers' => $this->bearerHeaders($token),
];
}
/**
* @param array<int, string> $permissions
* @return array{user:array<string,mixed>,subuser:array<string,mixed>,token:string,headers:array<string,string>}
*/
public function createSubuserSession(int $customerNumber, array $permissions, array $subuserAttributes = []): array
{
$subuser = $this->createSubuser($subuserAttributes);
$this->grantSubuser((int)$subuser['id'], $customerNumber, $permissions);
$token = $this->createAuthToken((int)$subuser['id'], 'AUTH_TOKEN_SUBUSER');
return [
'user' => [
'customer_number' => $customerNumber,
],
'subuser' => $subuser,
'token' => $token,
'headers' => $this->bearerHeaders($token, [
'X-Customer-Number' => (string)$customerNumber,
]),
];
}
public function createAuthToken(int $userId, string $type = 'AUTH_TOKEN', ?string $token = null): string
{
$token = $token ?: bin2hex(random_bytes(32));
$tokenId = $this->insertRow('tokens', [
'user_id' => $userId,
'type' => $type,
'description' => 'API test token',
'token' => $token,
'created_at' => $this->now(),
]);
$this->cleanup->add(function () use ($tokenId, $token): void {
$this->deleteById('tokens', $tokenId);
$this->deleteRedisKey('token_' . $token);
$this->deleteRedisKey('auth_session_' . $token);
});
return $token;
}
public function addCustomerAttribute(int $userId, string $attribute): int
{
$attributeId = $this->insertRow('customer_attributes', [
'user_id' => $userId,
'attribute' => $attribute,
]);
$this->cleanup->add(fn() => $this->deleteById('customer_attributes', $attributeId));
return $attributeId;
}
public function setModuleConfig(string $module, string $variable, string $value, string $type = 'bool'): void
{
$moduleEscaped = $this->db->real_escape_string($module);
$variableEscaped = $this->db->real_escape_string($variable);
$existing = $this->queryOneBySql(
"SELECT * FROM module_config WHERE module = '{$moduleEscaped}' AND variable = '{$variableEscaped}' LIMIT 1"
);
if ($existing !== null) {
$conditions = [
'module' => $module,
'variable' => $variable,
];
$this->updateWhere('module_config', $conditions, [
'value' => $value,
'type' => $type,
'updated_at' => $this->now(),
]);
$this->cleanup->add(function () use ($conditions, $existing): void {
$this->updateWhere('module_config', $conditions, [
'value' => $existing['value'] ?? null,
'type' => $existing['type'] ?? null,
'updated_at' => $existing['updated_at'] ?? null,
'created_at' => $existing['created_at'] ?? null,
]);
});
return;
}
$id = $this->insertRow('module_config', [
'module' => $module,
'variable' => $variable,
'value' => $value,
'type' => $type,
'created_at' => $this->now(),
'updated_at' => $this->now(),
]);
$this->cleanup->add(fn() => $this->deleteById('module_config', $id));
}
/**
* @param array<int, string> $permissions
* @param array<string, mixed>|null $overrides
*/
public function cacheAuthSessionForUser(array $user, string $token, array $permissions, ?array $overrides = null): void
{
$payload = [
'id' => (int)$user['id'],
'customer_number' => (int)$user['customer_number'],
'customer_name' => (string)$user['display_name'],
'display_name' => (string)$user['display_name'],
'group_id' => (int)$user['group_id'],
'phone' => [
'country_code' => 45,
'number' => (int)substr((string)$user['customer_number'], -8),
],
'email' => (string)$user['email'],
'notifications' => [
'sms_notifications_enabled' => false,
'email_notifications_enabled' => false,
'wash_certificate_email' => null,
],
'created_at' => $this->now(),
'updated_at' => $this->now(),
'economic_customer' => [
'customerNumber' => (int)$user['customer_number'],
'name' => (string)$user['display_name'],
],
'permissions' => array_values($permissions),
'two_factor_enabled' => false,
];
if ($overrides !== null) {
$payload = array_replace_recursive($payload, $overrides);
}
$this->setRedisJson('auth_session_' . $token, $payload);
}
/**
* @param array<string, string> $extraHeaders
* @return array<string, string>
*/
public function bearerHeaders(string $token, array $extraHeaders = []): array
{
return array_merge([
'Authorization' => 'Bearer ' . $token,
], $extraHeaders);
}
public function fetchRowById(string $table, int $id): ?array
{
$table = $this->sanitizeIdentifier($table);
return $this->queryOneBySql("SELECT * FROM `{$table}` WHERE id = {$id} LIMIT 1");
}
public function cleanupDeleteById(string $table, int $id): void
{
$this->cleanup->add(fn() => $this->deleteById($table, $id));
}
/**
* @param array<string, mixed> $conditions
*/
public function cleanupDeleteWhere(string $table, array $conditions): void
{
$this->cleanup->add(fn() => $this->deleteWhere($table, $conditions));
}
private function seedCustomerNameCache(int $customerNumber, string $name): void
{
$payload = [
'name' => $name,
];
$this->setRedisJson('users_' . $customerNumber . '_economic_customer_name', $payload);
$this->setRedisJson('`users`_' . $customerNumber . '_economic_customer_name', $payload);
}
private function seedEconomicCustomerCache(int $userId, int $customerNumber, string $name, string $email): void
{
$payload = [
'customerNumber' => $customerNumber,
'name' => $name,
'email' => $email,
'country' => 'DK',
'currency' => 'DKK',
'barred' => false,
];
$this->setRedisJson('users_' . $userId . '_economic_customer', $payload);
$this->setRedisJson('`users`_' . $userId . '_economic_customer', $payload);
}
/**
* @param array<string, mixed> $data
*/
private function insertRow(string $table, array $data): int
{
$table = $this->sanitizeIdentifier($table);
if ($data === []) {
throw new RuntimeException('Cannot insert an empty row into ' . $table . '.');
}
$columns = [];
$placeholders = [];
$types = '';
$values = [];
foreach ($data as $column => $value) {
$columns[] = '`' . $this->sanitizeIdentifier((string)$column) . '`';
$placeholders[] = '?';
[$type, $normalizedValue] = $this->normalizeValue($value);
$types .= $type;
$values[] = $normalizedValue;
}
$sql = sprintf(
'INSERT INTO `%s` (%s) VALUES (%s)',
$table,
implode(', ', $columns),
implode(', ', $placeholders),
);
$statement = $this->db->prepare($sql);
if ($statement === false) {
throw new RuntimeException('Failed to prepare insert for ' . $table . '.');
}
$statement->bind_param($types, ...$values);
$statement->execute();
$statement->close();
return (int)$this->db->insert_id;
}
/**
* @param array<string, mixed> $conditions
*/
private function deleteWhere(string $table, array $conditions): void
{
if ($conditions === []) {
return;
}
$table = $this->sanitizeIdentifier($table);
$parts = [];
$types = '';
$values = [];
foreach ($conditions as $column => $value) {
$column = $this->sanitizeIdentifier((string)$column);
if ($value === null) {
$parts[] = '`' . $column . '` IS NULL';
continue;
}
$parts[] = '`' . $column . '` = ?';
[$type, $normalizedValue] = $this->normalizeValue($value);
$types .= $type;
$values[] = $normalizedValue;
}
$sql = 'DELETE FROM `' . $table . '` WHERE ' . implode(' AND ', $parts);
$statement = $this->db->prepare($sql);
if ($statement === false) {
throw new RuntimeException('Failed to prepare delete for ' . $table . '.');
}
if ($values !== []) {
$statement->bind_param($types, ...$values);
}
$statement->execute();
$statement->close();
}
private function deleteById(string $table, int $id): void
{
$table = $this->sanitizeIdentifier($table);
if ($id <= 0) {
return;
}
$this->db->query('DELETE FROM `' . $table . '` WHERE id = ' . $id . ' LIMIT 1');
}
/**
* @param array<string, mixed> $data
*/
private function updateById(string $table, int $id, array $data): void
{
$table = $this->sanitizeIdentifier($table);
if ($id <= 0 || $data === []) {
return;
}
$parts = [];
$types = '';
$values = [];
foreach ($data as $column => $value) {
$column = $this->sanitizeIdentifier((string)$column);
if ($value === null) {
$parts[] = '`' . $column . '` = NULL';
continue;
}
$parts[] = '`' . $column . '` = ?';
[$type, $normalizedValue] = $this->normalizeValue($value);
$types .= $type;
$values[] = $normalizedValue;
}
$sql = 'UPDATE `' . $table . '` SET ' . implode(', ', $parts) . ' WHERE id = ? LIMIT 1';
$types .= 'i';
$values[] = $id;
$statement = $this->db->prepare($sql);
if ($statement === false) {
throw new RuntimeException('Failed to prepare update for ' . $table . '.');
}
$statement->bind_param($types, ...$values);
$statement->execute();
$statement->close();
}
/**
* @param array<string, mixed> $conditions
* @param array<string, mixed> $data
*/
private function updateWhere(string $table, array $conditions, array $data): void
{
$table = $this->sanitizeIdentifier($table);
if ($conditions === [] || $data === []) {
return;
}
$setParts = [];
$whereParts = [];
$types = '';
$values = [];
foreach ($data as $column => $value) {
$column = $this->sanitizeIdentifier((string)$column);
if ($value === null) {
$setParts[] = '`' . $column . '` = NULL';
continue;
}
$setParts[] = '`' . $column . '` = ?';
[$type, $normalizedValue] = $this->normalizeValue($value);
$types .= $type;
$values[] = $normalizedValue;
}
foreach ($conditions as $column => $value) {
$column = $this->sanitizeIdentifier((string)$column);
if ($value === null) {
$whereParts[] = '`' . $column . '` IS NULL';
continue;
}
$whereParts[] = '`' . $column . '` = ?';
[$type, $normalizedValue] = $this->normalizeValue($value);
$types .= $type;
$values[] = $normalizedValue;
}
$sql = 'UPDATE `' . $table . '` SET ' . implode(', ', $setParts) . ' WHERE ' . implode(' AND ', $whereParts);
$statement = $this->db->prepare($sql);
if ($statement === false) {
throw new RuntimeException('Failed to prepare update for ' . $table . '.');
}
$statement->bind_param($types, ...$values);
$statement->execute();
$statement->close();
}
private function queryOneBySql(string $sql): ?array
{
$result = $this->db->query($sql);
if ($result === false) {
throw new RuntimeException('Query failed: ' . $sql);
}
$row = $result->fetch_assoc();
$result->free();
return $row ?: null;
}
private function setRedisJson(string $key, array $payload): void
{
if ($this->redis === null) {
throw new RuntimeException('API tests require Redis for cache-backed endpoint flows.');
}
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encoded)) {
throw new RuntimeException('Unable to encode Redis payload for API tests.');
}
$this->redis->set($key, $encoded);
$this->cleanup->add(fn() => $this->deleteRedisKey($key));
}
private function deleteRedisKey(string $key): void
{
if ($this->redis === null) {
return;
}
$this->redis->del([$key]);
}
private function deleteRedisPattern(string $pattern): void
{
if ($this->redis === null) {
return;
}
$keys = $this->redis->keys($pattern);
if ($keys === []) {
return;
}
$this->redis->del($keys);
}
/**
* @return array{0:string,1:mixed}
*/
private function normalizeValue(mixed $value): array
{
if (is_bool($value)) {
return ['i', $value ? 1 : 0];
}
if (is_int($value)) {
return ['i', $value];
}
if (is_float($value)) {
return ['d', $value];
}
if (is_array($value)) {
$encoded = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encoded)) {
throw new RuntimeException('Unable to encode array value for API fixture data.');
}
return ['s', $encoded];
}
if ($value === null) {
return ['s', null];
}
return ['s', (string)$value];
}
private function sanitizeIdentifier(string $identifier): string
{
if (!preg_match('/^[A-Za-z0-9_]+$/', $identifier)) {
throw new RuntimeException('Invalid SQL identifier: ' . $identifier);
}
return $identifier;
}
private function now(): string
{
return date('Y-m-d H:i:s');
}
private function uniqueCustomerNumber(): int
{
return 80000000 + (++self::$sequence);
}
private function uniqueSuffix(): string
{
return strtoupper(dechex(++self::$sequence));
}
}