Files
orderPRO/tests/Unit/OrderImportRepositoryTest.php
Jacek Pyziak 3a2c419c25 feat(119): protect total_paid from re-import overwrite
OrderImportRepository::updateOrderDelta() przechodzi na dynamic SET builder.
total_paid jest dolaczane do UPDATE tylko gdy payment_status realnie sie
zmienia; is_canceled_by_buyer analogicznie, ale z override przez
cancelledBySource (cancel ze zrodla nadal propaguje sie do bazy).

Chroni reczne korekty operatora (zwroty czesciowe) przed cichym
nadpisaniem z payloadu zrodla przy kolejnym sync. Incydent #976:
operator zwrocil klientowi 28,00 PLN obnizajac total_paid 119->91,
co bez tej zmiany byloby cofniete przez kolejny re-import shoppro.

Boundaries: identical-payload guard, paymentTransition, statusOverwriteAllowed,
cancel propagation (status_code='anulowane') - bez zmian.

Tests: tests/Unit/OrderImportRepositoryTest.php - 3 scenariusze
(preserve / transition / cancel propagation) via Reflection + sqlite
in-memory. PHPUnit run odroczony (vendor/ gitignored).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:57:04 +02:00

167 lines
5.6 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Modules\Orders\OrderImportRepository;
use PDO;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;
final class OrderImportRepositoryTest extends TestCase
{
private PDO $pdo;
private OrderImportRepository $repository;
protected function setUp(): void
{
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->sqliteCreateFunction('NOW', static fn(): string => date('Y-m-d H:i:s'));
$this->pdo->exec(
'CREATE TABLE orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status_code TEXT,
payment_status INTEGER,
total_paid REAL,
is_canceled_by_buyer INTEGER DEFAULT 0,
source_updated_at TEXT,
payload_json TEXT,
fetched_at TEXT,
updated_at TEXT
)'
);
$this->repository = new OrderImportRepository($this->pdo);
}
public function testTotalPaidPreservedWhenPaymentStatusUnchanged(): void
{
$this->seedOrder([
'status_code' => 'wyslane',
'payment_status' => 2,
'total_paid' => 91.00,
'is_canceled_by_buyer' => 0,
'payload_json' => '{"a":1}',
'source_updated_at' => '2026-05-01 10:00:00',
'fetched_at' => '2026-05-01 10:00:00',
]);
$orderData = [
'status_code' => 'wyslane',
'payment_status' => 2,
'total_paid' => 119.00,
'is_canceled_by_buyer' => 0,
'source_updated_at' => '2026-05-10 12:00:00',
'payload_json' => ['a' => 2],
'fetched_at' => '2026-05-10 12:00:00',
];
$this->invokeUpdateOrderDelta(1, $orderData, true, false);
$row = $this->fetchOrder(1);
self::assertSame('91', $this->normalizeAmount($row['total_paid']), 'total_paid must remain at manually corrected value');
self::assertSame(2, (int) $row['payment_status']);
self::assertSame('2026-05-10 12:00:00', $row['source_updated_at']);
}
public function testTotalPaidUpdatedOnPaymentTransition(): void
{
$this->seedOrder([
'status_code' => 'nieoplacone',
'payment_status' => 0,
'total_paid' => 0.00,
'is_canceled_by_buyer' => 0,
'payload_json' => '{}',
'source_updated_at' => '2026-05-01 10:00:00',
'fetched_at' => '2026-05-01 10:00:00',
]);
$orderData = [
'status_code' => 'w_realizacji',
'payment_status' => 2,
'total_paid' => 119.00,
'is_canceled_by_buyer' => 0,
'source_updated_at' => '2026-05-10 12:00:00',
'payload_json' => ['a' => 1],
'fetched_at' => '2026-05-10 12:00:00',
];
$this->invokeUpdateOrderDelta(1, $orderData, false, false);
$row = $this->fetchOrder(1);
self::assertSame('119', $this->normalizeAmount($row['total_paid']));
self::assertSame(2, (int) $row['payment_status']);
}
public function testIsCanceledByBuyerPropagatedOnSourceCancelEvenWhenPaymentStable(): void
{
$this->seedOrder([
'status_code' => 'wyslane',
'payment_status' => 2,
'total_paid' => 91.00,
'is_canceled_by_buyer' => 0,
'payload_json' => '{}',
'source_updated_at' => '2026-05-01 10:00:00',
'fetched_at' => '2026-05-01 10:00:00',
]);
$orderData = [
'status_code' => 'anulowane',
'payment_status' => 2,
'total_paid' => 119.00,
'is_canceled_by_buyer' => 1,
'source_updated_at' => '2026-05-10 12:00:00',
'payload_json' => ['a' => 9],
'fetched_at' => '2026-05-10 12:00:00',
];
$this->invokeUpdateOrderDelta(1, $orderData, true, true);
$row = $this->fetchOrder(1);
self::assertSame(1, (int) $row['is_canceled_by_buyer'], 'source cancellation must propagate');
self::assertSame('anulowane', $row['status_code']);
self::assertSame('91', $this->normalizeAmount($row['total_paid']), 'total_paid still protected because payment_status unchanged');
}
/**
* @param array<string, mixed> $orderData
*/
private function invokeUpdateOrderDelta(int $orderId, array $orderData, bool $paymentStatusUnchanged, bool $cancelledBySource): void
{
$method = new ReflectionMethod(OrderImportRepository::class, 'updateOrderDelta');
$method->setAccessible(true);
$method->invoke($this->repository, $orderId, $orderData, $paymentStatusUnchanged, $cancelledBySource);
}
/**
* @param array<string, mixed> $values
*/
private function seedOrder(array $values): void
{
$columns = array_keys($values);
$placeholders = array_map(static fn(string $c): string => ':' . $c, $columns);
$sql = 'INSERT INTO orders (' . implode(', ', $columns) . ', updated_at) VALUES (' . implode(', ', $placeholders) . ", '2026-05-01 10:00:00')";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($values);
}
/**
* @return array<string, mixed>
*/
private function fetchOrder(int $id): array
{
$stmt = $this->pdo->prepare('SELECT * FROM orders WHERE id = :id');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
self::assertIsArray($row);
return $row;
}
private function normalizeAmount(mixed $value): string
{
return (string) (float) $value;
}
}