feat(api): P4 chunk 1b - encaissement commande + decrement stock
All checks were successful
CI / secret-scan (push) Successful in 12s
CI / php-lint (push) Successful in 28s
CI / static-tests (push) Successful in 1m0s
CI / js-tests (push) Successful in 35s
CI / secret-scan (pull_request) Successful in 13s
CI / php-lint (pull_request) Successful in 33s
CI / static-tests (pull_request) Successful in 59s
CI / js-tests (pull_request) Successful in 27s
CI / auto-merge (push) Has been skipped
CI / auto-merge (pull_request) Successful in 7s
All checks were successful
CI / secret-scan (push) Successful in 12s
CI / php-lint (push) Successful in 28s
CI / static-tests (push) Successful in 1m0s
CI / js-tests (push) Successful in 35s
CI / secret-scan (pull_request) Successful in 13s
CI / php-lint (pull_request) Successful in 33s
CI / static-tests (pull_request) Successful in 59s
CI / js-tests (pull_request) Successful in 27s
CI / auto-merge (push) Has been skipped
CI / auto-merge (pull_request) Successful in 7s
OrderController (POST /api/orders + /api/orders/{number}/pay) cable sur
OrderRepository::pay : transition gardee pending_payment -> paid et
decrement de stock atomique (RG-5 etapes 5-6, RG-T11/RG-T20) dans une
transaction. Idempotent (deja paid -> renvoi sans re-decrement ; course
concurrente perdue -> sortie idempotente) ; statut terminal -> 409.
Decrement agrege par ingredient (un UPDATE auto-verrouillant + une ligne
stock_movement(sale) par ingredient, ordre de verrou stable par id) ;
modificateurs appliques (remove => 0, add => portion supplementaire) ;
menu = recette du burger + recettes des selections, au format de la ligne.
Inerte tant que les recettes (product_ingredient) ne sont pas seedees.
Routes /api/orders anonymes (kiosk, pas de session). Double de test dedie
extrait en tests/Support/FakeOrderDatabase. PHPUnit 290 + PHPStan L6 verts.
This commit is contained in:
parent
29a191e506
commit
f669c32c25
6 changed files with 758 additions and 91 deletions
130
src/app/Controllers/OrderController.php
Normal file
130
src/app/Controllers/OrderController.php
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Catalogue\MenuRepository;
|
||||
use App\Catalogue\ProductRepository;
|
||||
use App\Core\Controller;
|
||||
use App\Core\DatabaseInterface;
|
||||
use App\Core\Response;
|
||||
use App\Order\OrderRepository;
|
||||
use App\Order\OrderValidationException;
|
||||
|
||||
/**
|
||||
* API publique de commande borne (P4, domaine 7). Anonyme : la borne kiosk poste
|
||||
* sans session ; l'idempotence (RG-T19, idempotency_key) tient lieu de garde-fou
|
||||
* anti double-clic / retry reseau. Deux operations :
|
||||
* - POST /api/orders : creation en pending_payment (RG-5 etapes 1-4) ;
|
||||
* - POST /api/orders/{number}/pay : encaissement -> paid + decrement stock (RG-T20).
|
||||
*
|
||||
* Les erreurs metier (OrderValidationException) sont mappees par code :
|
||||
* ORDER_NOT_FOUND -> 404, INVALID_TRANSITION -> 409, le reste (reference /
|
||||
* disponibilite / selection / modificateur) -> 422. Enveloppe standard
|
||||
* {data} / {data:null, error:{code, message}}.
|
||||
*
|
||||
* Non `final` a dessein : les tests sous-classent pour injecter un acces BDD double
|
||||
* (FakeOrderDatabase) via le hook protege db().
|
||||
*/
|
||||
class OrderController extends Controller
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $params
|
||||
*/
|
||||
public function create(array $params = []): Response
|
||||
{
|
||||
try {
|
||||
$order = $this->orders()->createPending($this->request->json());
|
||||
} catch (OrderValidationException $exception) {
|
||||
return $this->orderError($exception);
|
||||
}
|
||||
|
||||
return $this->json(['data' => $this->present($order)], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $params
|
||||
*/
|
||||
public function pay(array $params = []): Response
|
||||
{
|
||||
try {
|
||||
$order = $this->orders()->pay((string) ($params['number'] ?? ''));
|
||||
} catch (OrderValidationException $exception) {
|
||||
return $this->orderError($exception);
|
||||
}
|
||||
|
||||
return $this->json(['data' => $this->present($order)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fabrique le repository de commande sur l'acces BDD courant. Hook de test
|
||||
* (sous-classe -> double) : redefinir db() suffit a injecter une base factice.
|
||||
*/
|
||||
protected function orders(): OrderRepository
|
||||
{
|
||||
$db = $this->db();
|
||||
|
||||
return new OrderRepository($db, new ProductRepository($db), new MenuRepository($db));
|
||||
}
|
||||
|
||||
/**
|
||||
* Acces BDD comme DatabaseInterface (seam de test). Database l'implemente.
|
||||
*/
|
||||
protected function db(): DatabaseInterface
|
||||
{
|
||||
return $this->database;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id:int, order_number:string, total_ttc_cents:int, status:string} $order
|
||||
* @return array{id:int, order_number:string, status:string, total_ttc_cents:int}
|
||||
*/
|
||||
private function present(array $order): array
|
||||
{
|
||||
return [
|
||||
'id' => $order['id'],
|
||||
'order_number' => $order['order_number'],
|
||||
'status' => $order['status'],
|
||||
'total_ttc_cents' => $order['total_ttc_cents'],
|
||||
];
|
||||
}
|
||||
|
||||
private function orderError(OrderValidationException $exception): Response
|
||||
{
|
||||
$code = $exception->getMessage();
|
||||
$status = match ($code) {
|
||||
'ORDER_NOT_FOUND' => 404,
|
||||
'INVALID_TRANSITION' => 409,
|
||||
default => 422,
|
||||
};
|
||||
|
||||
return $this->json(
|
||||
['data' => null, 'error' => ['code' => $code, 'message' => $this->messageFor($code)]],
|
||||
$status,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Message lisible par code metier. Reste cote serveur : la borne affiche un
|
||||
* libelle generique, ce texte sert au diagnostic / aux logs.
|
||||
*/
|
||||
private function messageFor(string $code): string
|
||||
{
|
||||
return match ($code) {
|
||||
'ORDER_NOT_FOUND' => 'Commande introuvable.',
|
||||
'INVALID_TRANSITION' => 'Transition de statut invalide.',
|
||||
'EMPTY_ORDER' => 'La commande est vide.',
|
||||
'INVALID_SERVICE_MODE' => 'Mode de service invalide.',
|
||||
'INVALID_SERVICE_TAG' => 'Numero de chevalet invalide.',
|
||||
'INVALID_ITEM_TYPE' => 'Type d\'article invalide.',
|
||||
'PRODUCT_UNAVAILABLE' => 'Produit indisponible.',
|
||||
'MENU_UNAVAILABLE' => 'Menu indisponible.',
|
||||
'INVALID_SELECTION' => 'Choix invalide pour ce menu.',
|
||||
'INVALID_MODIFIER' => 'Modification d\'ingredient invalide.',
|
||||
'INGREDIENT_NOT_REMOVABLE' => 'Cet ingredient ne peut pas etre retire.',
|
||||
'INGREDIENT_NOT_ADDABLE' => 'Cet ingredient ne peut pas etre ajoute.',
|
||||
default => 'Requete invalide.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -62,7 +62,10 @@ class OrderRepository
|
|||
/**
|
||||
* Cree une commande borne en pending_payment. Idempotent sur idempotency_key.
|
||||
*
|
||||
* @param array{idempotency_key?:string, service_mode:string, service_tag?:?string, items:list<array<string,mixed>>} $req
|
||||
* Tolerant sur la forme d'entree (corps JSON decode tel quel) : chaque cle est
|
||||
* relue defensivement et la validation metier leve OrderValidationException.
|
||||
*
|
||||
* @param array<string, mixed> $req
|
||||
* @return array{id:int, order_number:string, total_ttc_cents:int, status:string}
|
||||
* @throws OrderValidationException si une reference est invalide / indisponible.
|
||||
*/
|
||||
|
|
@ -169,6 +172,164 @@ class OrderRepository
|
|||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encaisse une commande pending_payment : transition -> paid ET decrement de
|
||||
* stock atomique (RG-5 etapes 5-6, RG-T11 / RG-T20) dans UNE transaction.
|
||||
*
|
||||
* Idempotent : une commande deja `paid` est renvoyee telle quelle sans
|
||||
* re-decrementer ; `delivered` / `cancelled` -> INVALID_TRANSITION ; numero
|
||||
* inconnu -> ORDER_NOT_FOUND. La transition est gardee par `status =
|
||||
* 'pending_payment'` dans l'UPDATE : sous une course concurrente, seul le
|
||||
* premier appel decremente (l'autre voit 0 ligne affectee et sort idempotent).
|
||||
*
|
||||
* Decrement (RG-5 etape 5) : par ingredient consomme, units =
|
||||
* (format maxi ? quantity_maxi : quantity_normal) * order_item.quantity, ajuste
|
||||
* par les modificateurs de la ligne (remove => pas de decrement pour cet
|
||||
* ingredient ; add => portion de base + supplement). Les unites sont AGREGEES
|
||||
* par ingredient sur toute la commande : un seul UPDATE auto-verrouillant et une
|
||||
* seule ligne stock_movement(sale) par ingredient affecte (POST-4). Les UPDATE
|
||||
* sont ordonnes par ingredient_id (ordre de verrou stable -> pas de deadlock
|
||||
* entre commandes concurrentes). stock_quantity est signe (survente possible,
|
||||
* RG-T20) : le decrement ne se conditionne a aucun plancher.
|
||||
*
|
||||
* NB : inerte tant que les recettes (product_ingredient) ne sont pas seedees —
|
||||
* la transition `paid` s'applique, mais aucun mouvement de stock n'est produit
|
||||
* faute de composition. La logique s'active des que les recettes existent.
|
||||
*
|
||||
* @param int|null $actingUserId acteur comptoir/drive (stock_movement.user_id +
|
||||
* customer_order.acting_user_id) ; NULL pour le kiosk.
|
||||
* @return array{id:int, order_number:string, total_ttc_cents:int, status:string}
|
||||
* @throws OrderValidationException
|
||||
*/
|
||||
public function pay(string $orderNumber, ?int $actingUserId = null): array
|
||||
{
|
||||
$order = $this->db->fetch(
|
||||
'SELECT id, order_number, total_ttc_cents, status FROM customer_order WHERE order_number = :n',
|
||||
['n' => $orderNumber],
|
||||
);
|
||||
if ($order === null) {
|
||||
throw new OrderValidationException('ORDER_NOT_FOUND');
|
||||
}
|
||||
|
||||
$result = [
|
||||
'id' => (int) $order['id'],
|
||||
'order_number' => (string) $order['order_number'],
|
||||
'total_ttc_cents' => (int) $order['total_ttc_cents'],
|
||||
'status' => 'paid',
|
||||
];
|
||||
|
||||
$status = (string) $order['status'];
|
||||
if ($status === 'paid') {
|
||||
return $result; // idempotent : deja encaissee, pas de re-decrement.
|
||||
}
|
||||
if ($status !== 'pending_payment') {
|
||||
throw new OrderValidationException('INVALID_TRANSITION'); // delivered / cancelled.
|
||||
}
|
||||
|
||||
$orderId = (int) $order['id'];
|
||||
$this->db->transaction(function (DatabaseInterface $db) use ($orderId, $actingUserId): void {
|
||||
$affected = $db->execute(
|
||||
'UPDATE customer_order SET status = \'paid\', paid_at = NOW(), '
|
||||
. 'acting_user_id = COALESCE(:uid, acting_user_id), updated_at = NOW() '
|
||||
. 'WHERE id = :id AND status = \'pending_payment\'',
|
||||
['uid' => $actingUserId, 'id' => $orderId],
|
||||
);
|
||||
if ($affected === 0) {
|
||||
// Course perdue : un autre appel a deja transite. S'il a abouti a
|
||||
// `paid`, il a fait le decrement -> on sort idempotent ; sinon la
|
||||
// transition est invalide (statut terminal).
|
||||
$current = (string) ($db->fetch('SELECT status FROM customer_order WHERE id = :id', ['id' => $orderId])['status'] ?? '');
|
||||
if ($current === 'paid') {
|
||||
return;
|
||||
}
|
||||
throw new OrderValidationException('INVALID_TRANSITION');
|
||||
}
|
||||
|
||||
foreach ($this->consumption($db, $orderId) as $ingredientId => $units) {
|
||||
$db->execute(
|
||||
'UPDATE ingredient SET stock_quantity = stock_quantity - :u WHERE id = :id',
|
||||
['u' => $units, 'id' => $ingredientId],
|
||||
);
|
||||
$db->execute(
|
||||
'INSERT INTO stock_movement (ingredient_id, movement_type, delta, order_id, user_id, note) '
|
||||
. 'VALUES (:ing, \'sale\', :delta, :oid, :uid, NULL)',
|
||||
['ing' => $ingredientId, 'delta' => -$units, 'oid' => $orderId, 'uid' => $actingUserId],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unites de stock a decrementer, AGREGEES par ingredient_id sur toute la
|
||||
* commande (lecture des lignes persistees + recettes des produits supports).
|
||||
* Cle = ingredient_id, triee croissant (ordre de verrou stable). Un ingredient
|
||||
* dont l'unite agregee retombe a 0 (entierement retire) n'est PAS retourne :
|
||||
* aucun mouvement n'est alors produit. Voir pay() pour la regle de calcul.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function consumption(DatabaseInterface $db, int $orderId): array
|
||||
{
|
||||
$items = $db->fetchAll(
|
||||
'SELECT id, item_type, product_id, menu_id, format, quantity FROM order_item WHERE order_id = :oid',
|
||||
['oid' => $orderId],
|
||||
);
|
||||
|
||||
/** @var array<int, int> $units */
|
||||
$units = [];
|
||||
foreach ($items as $item) {
|
||||
$itemId = (int) $item['id'];
|
||||
$quantity = max(1, (int) $item['quantity']);
|
||||
$maxi = ((string) $item['format']) === 'maxi';
|
||||
|
||||
// Produit(s) dont la recette est consommee : le produit pour une ligne
|
||||
// produit ; le burger + chaque selection pour une ligne menu.
|
||||
$productIds = [];
|
||||
if ((string) $item['item_type'] === 'product') {
|
||||
$productIds[] = (int) $item['product_id'];
|
||||
} else {
|
||||
$menu = $this->menus->find((int) $item['menu_id']);
|
||||
if ($menu !== null) {
|
||||
$productIds[] = (int) $menu['burger_product_id'];
|
||||
}
|
||||
foreach ($db->fetchAll('SELECT product_id FROM order_item_selection WHERE order_item_id = :oiid', ['oiid' => $itemId]) as $sel) {
|
||||
$productIds[] = (int) $sel['product_id'];
|
||||
}
|
||||
}
|
||||
|
||||
// Modificateurs de la ligne (ingredient_id => action). Ils s'appliquent a
|
||||
// toute recette de la ligne contenant l'ingredient ; en pratique ils
|
||||
// ciblent le produit support (burger), dont les ingredients ne recoupent
|
||||
// pas ceux des selections (boisson / accompagnement).
|
||||
$actions = [];
|
||||
foreach ($db->fetchAll('SELECT ingredient_id, action FROM order_item_modifier WHERE order_item_id = :oiid', ['oiid' => $itemId]) as $mod) {
|
||||
$actions[(int) $mod['ingredient_id']] = (string) $mod['action'];
|
||||
}
|
||||
|
||||
foreach ($productIds as $productId) {
|
||||
foreach ($this->products->composition($productId) as $row) {
|
||||
$ingredientId = (int) $row['ingredient_id'];
|
||||
$perUnit = $maxi ? (int) $row['quantity_maxi'] : (int) $row['quantity_normal'];
|
||||
$base = $perUnit * $quantity;
|
||||
$consumed = match ($actions[$ingredientId] ?? null) {
|
||||
'remove' => 0,
|
||||
'add' => $base * 2, // portion de base + supplement (RG-5).
|
||||
default => $base,
|
||||
};
|
||||
if ($consumed > 0) {
|
||||
$units[$ingredientId] = ($units[$ingredientId] ?? 0) + $consumed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ksort($units);
|
||||
|
||||
return $units;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resout une ligne (produit ou menu) : lit le catalogue, valide, calcule le prix.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ use App\Controllers\HomeController;
|
|||
use App\Controllers\IngredientController;
|
||||
use App\Controllers\MeController;
|
||||
use App\Controllers\MenuController;
|
||||
use App\Controllers\OrderController;
|
||||
use App\Controllers\PasswordResetController;
|
||||
use App\Controllers\ProductController;
|
||||
use App\Controllers\ProfileController;
|
||||
|
|
@ -70,6 +71,13 @@ try {
|
|||
// RBAC : identite + permissions de la session courante (gardee par SessionGuard).
|
||||
$router->add('GET', '/api/me', [MeController::class, 'show']);
|
||||
|
||||
// Commandes borne (P4, domaine 7). API publique kiosk, ANONYME (pas de session) :
|
||||
// creation en pending_payment puis encaissement (paid + decrement stock RG-T20).
|
||||
// Idempotente sur idempotency_key (anti double-clic / retry reseau). {number} =
|
||||
// un seul segment (numero K+id), pas de collision avec un sous-chemin.
|
||||
$router->add('POST', '/api/orders', [OrderController::class, 'create']);
|
||||
$router->add('POST', '/api/orders/{number}/pay', [OrderController::class, 'pay']);
|
||||
|
||||
// Back-office (P3) : pages rendues serveur sous /admin, gardees par SessionGuard.
|
||||
$router->add('GET', '/admin/dashboard', [DashboardController::class, 'index']);
|
||||
// Tableau de bord statistiques (stats.read) : landing du role manager. KPIs
|
||||
|
|
|
|||
157
tests/Support/FakeOrderDatabase.php
Normal file
157
tests/Support/FakeOrderDatabase.php
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Support;
|
||||
|
||||
use App\Core\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Double DatabaseInterface dedie au domaine Commande (P4). Le double generique
|
||||
* FakeDatabase repond par boutons fixes (un seul produit/menu) ; une commande
|
||||
* mele plusieurs produits/menus distincts, d'ou ce double indexe par id.
|
||||
*
|
||||
* Couvre createPending (catalogue + idempotence) ET pay (lecture de la commande
|
||||
* persistee + recettes -> decrement). Les ecritures sont tracees pour assertion ;
|
||||
* payUpdateAffected simule l'issue de la transition gardee (0 = course perdue).
|
||||
*/
|
||||
final class FakeOrderDatabase implements DatabaseInterface
|
||||
{
|
||||
/** @var list<array{sql:string, params:array<string,mixed>}> */
|
||||
public array $writes = [];
|
||||
|
||||
/** @var array<int, array<string,mixed>> produits indexes par id (find). */
|
||||
public array $products = [];
|
||||
/** @var array<int, array<string,mixed>> menus indexes par id (find). */
|
||||
public array $menus = [];
|
||||
/** @var array<int, list<array<string,mixed>>> slots (slotsWithOptions) par menu id. */
|
||||
public array $slotRows = [];
|
||||
/** @var array<int, list<array<string,mixed>>> recettes (composition) par produit id. */
|
||||
public array $compositions = [];
|
||||
|
||||
/** Commande existante renvoyee par la recherche idempotency_key ; null = aucune. */
|
||||
/** @var array<string,mixed>|null */
|
||||
public ?array $existingByKey = null;
|
||||
|
||||
/** Commande renvoyee par la recherche order_number (pay) ; null = introuvable. */
|
||||
/** @var array<string,mixed>|null */
|
||||
public ?array $orderByNumber = null;
|
||||
|
||||
/** Statut relu apres une transition gardee a 0 ligne (course concurrente). */
|
||||
public string $recheckStatus = 'paid';
|
||||
|
||||
/** Lignes order_item renvoyees pour la commande encaissee. */
|
||||
/** @var list<array<string,mixed>> */
|
||||
public array $orderItems = [];
|
||||
|
||||
/** Selections (product_id) par order_item id. */
|
||||
/** @var array<int, list<array<string,mixed>>> */
|
||||
public array $selectionsByItem = [];
|
||||
|
||||
/** Modificateurs (ingredient_id, action) par order_item id. */
|
||||
/** @var array<int, list<array<string,mixed>>> */
|
||||
public array $modifiersByItem = [];
|
||||
|
||||
/** Lignes affectees par l'UPDATE de transition pending_payment -> paid. */
|
||||
public int $payUpdateAffected = 1;
|
||||
|
||||
private int $autoId = 99;
|
||||
|
||||
public function fetch(string $sql, array $params = []): ?array
|
||||
{
|
||||
if (str_contains($sql, 'LAST_INSERT_ID')) {
|
||||
return ['id' => $this->autoId];
|
||||
}
|
||||
if (str_contains($sql, 'FROM customer_order WHERE idempotency_key')) {
|
||||
return $this->existingByKey;
|
||||
}
|
||||
if (str_contains($sql, 'FROM customer_order WHERE order_number')) {
|
||||
return $this->orderByNumber;
|
||||
}
|
||||
if (str_contains($sql, 'SELECT status FROM customer_order WHERE id')) {
|
||||
return ['status' => $this->recheckStatus];
|
||||
}
|
||||
if (str_contains($sql, 'FROM product WHERE id = :id')) {
|
||||
return $this->products[(int) $params['id']] ?? null;
|
||||
}
|
||||
if (str_contains($sql, 'FROM menu WHERE id = :id')) {
|
||||
return $this->menus[(int) $params['id']] ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function fetchAll(string $sql, array $params = []): array
|
||||
{
|
||||
if (str_contains($sql, 'FROM menu_slot s')) {
|
||||
return $this->slotRows[(int) $params['id']] ?? [];
|
||||
}
|
||||
if (str_contains($sql, 'FROM product_ingredient pi')) {
|
||||
return $this->compositions[(int) $params['id']] ?? [];
|
||||
}
|
||||
if (str_contains($sql, 'FROM order_item WHERE order_id')) {
|
||||
return $this->orderItems;
|
||||
}
|
||||
if (str_contains($sql, 'FROM order_item_selection WHERE order_item_id')) {
|
||||
return $this->selectionsByItem[(int) $params['oiid']] ?? [];
|
||||
}
|
||||
if (str_contains($sql, 'FROM order_item_modifier WHERE order_item_id')) {
|
||||
return $this->modifiersByItem[(int) $params['oiid']] ?? [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public function execute(string $sql, array $params = []): int
|
||||
{
|
||||
$this->writes[] = ['sql' => $sql, 'params' => $params];
|
||||
|
||||
if (str_contains($sql, 'INSERT INTO customer_order') || str_contains($sql, 'INSERT INTO order_item ')) {
|
||||
$this->autoId++;
|
||||
}
|
||||
if (str_contains($sql, 'UPDATE customer_order SET status')) {
|
||||
return $this->payUpdateAffected;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function transaction(callable $fn): void
|
||||
{
|
||||
$fn($this);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function firstWrite(string $needle): array
|
||||
{
|
||||
foreach ($this->writes as $write) {
|
||||
if (str_contains($write['sql'], $needle)) {
|
||||
return $write['params'];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public function countWrites(string $needle): int
|
||||
{
|
||||
return count(array_filter($this->writes, static fn (array $w): bool => str_contains($w['sql'], $needle)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parametres de toutes les ecritures dont le SQL contient $needle (ordre d'insertion).
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function allWrites(string $needle): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($this->writes as $write) {
|
||||
if (str_contains($write['sql'], $needle)) {
|
||||
$out[] = $write['params'];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
135
tests/Unit/Order/OrderControllerTest.php
Normal file
135
tests/Unit/Order/OrderControllerTest.php
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Order;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Controllers\OrderController;
|
||||
use App\Core\Config;
|
||||
use App\Core\Database;
|
||||
use App\Core\DatabaseInterface;
|
||||
use App\Core\Request;
|
||||
use App\Tests\Support\FakeOrderDatabase;
|
||||
|
||||
/**
|
||||
* Sous-classe de test : redefinit le hook db() pour injecter le double dedie, sans
|
||||
* base reelle. orders() construit alors le vrai OrderRepository sur ce double, ce
|
||||
* qui exerce le cablage complet controleur -> repository.
|
||||
*/
|
||||
final class TestOrderController extends OrderController
|
||||
{
|
||||
public function __construct(
|
||||
Request $request,
|
||||
Config $config,
|
||||
Database $database,
|
||||
private readonly FakeOrderDatabase $fakeDb,
|
||||
) {
|
||||
parent::__construct($request, $config, $database);
|
||||
}
|
||||
|
||||
protected function db(): DatabaseInterface
|
||||
{
|
||||
return $this->fakeDb;
|
||||
}
|
||||
}
|
||||
|
||||
final class OrderControllerTest extends TestCase
|
||||
{
|
||||
private function controller(FakeOrderDatabase $db, string $body = '', string $path = '/api/orders'): TestOrderController
|
||||
{
|
||||
$request = new Request('POST', $path, [], ['content-type' => 'application/json'], $body, '203.0.113.5');
|
||||
|
||||
return new TestOrderController($request, new Config(), new Database(new Config()), $db);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function jsonBody(array $payload): string
|
||||
{
|
||||
return (string) json_encode($payload);
|
||||
}
|
||||
|
||||
public function testCreateReturns201WithOrderNumber(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->products[12] = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1];
|
||||
|
||||
$body = $this->jsonBody(['service_mode' => 'takeaway', 'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1]]]);
|
||||
$response = $this->controller($db, $body)->create();
|
||||
|
||||
self::assertSame(201, $response->status());
|
||||
$data = json_decode($response->body(), true);
|
||||
self::assertIsArray($data);
|
||||
self::assertSame('K100', $data['data']['order_number'] ?? null);
|
||||
self::assertSame('pending_payment', $data['data']['status'] ?? null);
|
||||
self::assertSame(890, $data['data']['total_ttc_cents'] ?? null);
|
||||
}
|
||||
|
||||
public function testCreateUnknownProductReturns422(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$body = $this->jsonBody(['service_mode' => 'takeaway', 'items' => [['type' => 'product', 'product_id' => 999, 'quantity' => 1]]]);
|
||||
|
||||
$response = $this->controller($db, $body)->create();
|
||||
|
||||
self::assertSame(422, $response->status());
|
||||
$data = json_decode($response->body(), true);
|
||||
self::assertIsArray($data);
|
||||
self::assertSame('PRODUCT_UNAVAILABLE', $data['error']['code'] ?? null);
|
||||
}
|
||||
|
||||
public function testCreateInvalidServiceModeReturns422(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$body = $this->jsonBody(['service_mode' => 'bogus', 'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1]]]);
|
||||
|
||||
$response = $this->controller($db, $body)->create();
|
||||
|
||||
self::assertSame(422, $response->status());
|
||||
$data = json_decode($response->body(), true);
|
||||
self::assertIsArray($data);
|
||||
self::assertSame('INVALID_SERVICE_MODE', $data['error']['code'] ?? null);
|
||||
}
|
||||
|
||||
public function testPayReturns200Paid(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
|
||||
|
||||
$response = $this->controller($db, '', '/api/orders/K100/pay')->pay(['number' => 'K100']);
|
||||
|
||||
self::assertSame(200, $response->status());
|
||||
$data = json_decode($response->body(), true);
|
||||
self::assertIsArray($data);
|
||||
self::assertSame('paid', $data['data']['status'] ?? null);
|
||||
self::assertSame('K100', $data['data']['order_number'] ?? null);
|
||||
}
|
||||
|
||||
public function testPayUnknownReturns404(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = null;
|
||||
|
||||
$response = $this->controller($db, '', '/api/orders/K404/pay')->pay(['number' => 'K404']);
|
||||
|
||||
self::assertSame(404, $response->status());
|
||||
$data = json_decode($response->body(), true);
|
||||
self::assertIsArray($data);
|
||||
self::assertSame('ORDER_NOT_FOUND', $data['error']['code'] ?? null);
|
||||
}
|
||||
|
||||
public function testPayTerminalStatusReturns409(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'delivered'];
|
||||
|
||||
$response = $this->controller($db, '', '/api/orders/K100/pay')->pay(['number' => 'K100']);
|
||||
|
||||
self::assertSame(409, $response->status());
|
||||
$data = json_decode($response->body(), true);
|
||||
self::assertIsArray($data);
|
||||
self::assertSame('INVALID_TRANSITION', $data['error']['code'] ?? null);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,104 +7,25 @@ namespace App\Tests\Unit\Order;
|
|||
use PHPUnit\Framework\TestCase;
|
||||
use App\Catalogue\MenuRepository;
|
||||
use App\Catalogue\ProductRepository;
|
||||
use App\Core\DatabaseInterface;
|
||||
use App\Order\OrderRepository;
|
||||
use App\Order\OrderValidationException;
|
||||
use App\Tests\Support\FakeOrderDatabase;
|
||||
|
||||
/**
|
||||
* Double DatabaseInterface dedie : catalogue canned + enregistrement des ecritures.
|
||||
* Permet de tester le calcul de prix (RG-4), le numero K+id, l'idempotence et la
|
||||
* validation de createPending sans base reelle.
|
||||
* Couvre createPending (calcul RG-4, numero K+id, idempotence, validation) et pay
|
||||
* (transition gardee -> paid, decrement de stock atomique RG-T20, idempotence)
|
||||
* sur le double dedie FakeOrderDatabase, sans base reelle.
|
||||
*/
|
||||
final class OrderFakeDb implements DatabaseInterface
|
||||
{
|
||||
/** @var list<array{sql:string, params:array<string,mixed>}> */
|
||||
public array $writes = [];
|
||||
/** @var array<int, array<string,mixed>> */
|
||||
public array $products = [];
|
||||
/** @var array<int, array<string,mixed>> */
|
||||
public array $menus = [];
|
||||
/** @var array<int, list<array<string,mixed>>> */
|
||||
public array $slotRows = [];
|
||||
/** @var array<int, list<array<string,mixed>>> */
|
||||
public array $compositions = [];
|
||||
/** @var array<string,mixed>|null */
|
||||
public ?array $existingByKey = null;
|
||||
private int $autoId = 99;
|
||||
|
||||
public function fetch(string $sql, array $params = []): ?array
|
||||
{
|
||||
if (str_contains($sql, 'LAST_INSERT_ID')) {
|
||||
return ['id' => $this->autoId];
|
||||
}
|
||||
if (str_contains($sql, 'FROM customer_order WHERE idempotency_key')) {
|
||||
return $this->existingByKey;
|
||||
}
|
||||
if (str_contains($sql, 'FROM product WHERE id = :id')) {
|
||||
return $this->products[(int) $params['id']] ?? null;
|
||||
}
|
||||
if (str_contains($sql, 'FROM menu WHERE id = :id')) {
|
||||
return $this->menus[(int) $params['id']] ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function fetchAll(string $sql, array $params = []): array
|
||||
{
|
||||
if (str_contains($sql, 'FROM menu_slot s')) {
|
||||
return $this->slotRows[(int) $params['id']] ?? [];
|
||||
}
|
||||
if (str_contains($sql, 'FROM product_ingredient pi')) {
|
||||
return $this->compositions[(int) $params['id']] ?? [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public function execute(string $sql, array $params = []): int
|
||||
{
|
||||
if (str_contains($sql, 'INSERT INTO customer_order') || str_contains($sql, 'INSERT INTO order_item ')) {
|
||||
$this->autoId++;
|
||||
}
|
||||
$this->writes[] = ['sql' => $sql, 'params' => $params];
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function transaction(callable $fn): void
|
||||
{
|
||||
$fn($this);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function firstWrite(string $needle): array
|
||||
{
|
||||
foreach ($this->writes as $w) {
|
||||
if (str_contains($w['sql'], $needle)) {
|
||||
return $w['params'];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public function countWrites(string $needle): int
|
||||
{
|
||||
return count(array_filter($this->writes, static fn (array $w): bool => str_contains($w['sql'], $needle)));
|
||||
}
|
||||
}
|
||||
|
||||
final class OrderRepositoryTest extends TestCase
|
||||
{
|
||||
private function repo(OrderFakeDb $db): OrderRepository
|
||||
private function repo(FakeOrderDatabase $db): OrderRepository
|
||||
{
|
||||
return new OrderRepository($db, new ProductRepository($db), new MenuRepository($db));
|
||||
}
|
||||
|
||||
public function testProductOrderComputesLineVatAndKId(): void
|
||||
{
|
||||
$db = new OrderFakeDb();
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->products[12] = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1];
|
||||
|
||||
$res = $this->repo($db)->createPending([
|
||||
|
|
@ -130,7 +51,7 @@ final class OrderRepositoryTest extends TestCase
|
|||
|
||||
public function testMenuMaxiUsesBurgerVatAndMaxiPrice(): void
|
||||
{
|
||||
$db = new OrderFakeDb();
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu Best Of', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1];
|
||||
$db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 600, 'vat_rate' => 100, 'is_available' => 1];
|
||||
$db->products[20] = ['id' => 20, 'name' => 'Coca', 'price_cents' => 250, 'vat_rate' => 100, 'is_available' => 1];
|
||||
|
|
@ -156,7 +77,7 @@ final class OrderRepositoryTest extends TestCase
|
|||
|
||||
public function testAddModifierAddsExtraToLine(): void
|
||||
{
|
||||
$db = new OrderFakeDb();
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1];
|
||||
$db->compositions[12] = [['ingredient_id' => 3, 'is_removable' => 1, 'is_addable' => 1, 'extra_price_cents' => 50, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
|
||||
|
||||
|
|
@ -172,7 +93,7 @@ final class OrderRepositoryTest extends TestCase
|
|||
|
||||
public function testIdempotentReturnsExistingWithoutInsert(): void
|
||||
{
|
||||
$db = new OrderFakeDb();
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->existingByKey = ['id' => 7, 'order_number' => 'K7', 'total_ttc_cents' => 500, 'status' => 'pending_payment'];
|
||||
|
||||
$res = $this->repo($db)->createPending([
|
||||
|
|
@ -187,7 +108,7 @@ final class OrderRepositoryTest extends TestCase
|
|||
|
||||
public function testRejectsUnknownProduct(): void
|
||||
{
|
||||
$db = new OrderFakeDb();
|
||||
$db = new FakeOrderDatabase();
|
||||
$this->expectException(OrderValidationException::class);
|
||||
$this->repo($db)->createPending([
|
||||
'service_mode' => 'takeaway',
|
||||
|
|
@ -197,7 +118,7 @@ final class OrderRepositoryTest extends TestCase
|
|||
|
||||
public function testRejectsSelectionOutsideSlotOptions(): void
|
||||
{
|
||||
$db = new OrderFakeDb();
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1];
|
||||
$db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 600, 'vat_rate' => 100, 'is_available' => 1];
|
||||
$db->slotRows[5] = [['id' => 7, 'name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'display_order' => 0, 'product_id' => 20]];
|
||||
|
|
@ -209,4 +130,159 @@ final class OrderRepositoryTest extends TestCase
|
|||
'selections' => [['menu_slot_id' => 7, 'product_id' => 999]]]], // 999 hors options
|
||||
]);
|
||||
}
|
||||
|
||||
// --- pay() : transition + decrement de stock (RG-5 etapes 5-6, RG-T20) ---
|
||||
|
||||
public function testPayTransitionsToPaidAndDecrementsProductRecipe(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
|
||||
$db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 2]];
|
||||
$db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
|
||||
|
||||
$res = $this->repo($db)->pay('K100');
|
||||
|
||||
self::assertSame('paid', $res['status']);
|
||||
self::assertSame('K100', $res['order_number']);
|
||||
self::assertSame(1, $db->countWrites('UPDATE customer_order SET status'));
|
||||
|
||||
// 2 unites consommees (qn 1 * quantite 2) -> stock -2 sur l'ingredient 5.
|
||||
$dec = $db->firstWrite('UPDATE ingredient SET stock_quantity');
|
||||
self::assertSame(2, $dec['u']);
|
||||
self::assertSame(5, $dec['id']);
|
||||
$move = $db->firstWrite('INSERT INTO stock_movement');
|
||||
self::assertSame(-2, $move['delta']);
|
||||
self::assertSame(100, $move['oid']);
|
||||
self::assertNull($move['uid']); // kiosk : pas d'acteur.
|
||||
}
|
||||
|
||||
public function testPayIsIdempotentWhenAlreadyPaid(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'paid'];
|
||||
$db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 2]];
|
||||
$db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
|
||||
|
||||
$res = $this->repo($db)->pay('K100');
|
||||
|
||||
self::assertSame('paid', $res['status']);
|
||||
self::assertSame(0, $db->countWrites('UPDATE customer_order SET status'));
|
||||
self::assertSame(0, $db->countWrites('UPDATE ingredient SET stock_quantity'));
|
||||
self::assertSame(0, $db->countWrites('INSERT INTO stock_movement'));
|
||||
}
|
||||
|
||||
public function testPayRejectsUnknownOrder(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = null;
|
||||
|
||||
$this->expectException(OrderValidationException::class);
|
||||
$this->expectExceptionMessage('ORDER_NOT_FOUND');
|
||||
$this->repo($db)->pay('K404');
|
||||
}
|
||||
|
||||
public function testPayRejectsTerminalStatus(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'cancelled'];
|
||||
|
||||
$this->expectException(OrderValidationException::class);
|
||||
$this->expectExceptionMessage('INVALID_TRANSITION');
|
||||
$this->repo($db)->pay('K100');
|
||||
}
|
||||
|
||||
public function testPayLosesConcurrentRaceReturnsPaidWithoutDecrement(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
|
||||
$db->payUpdateAffected = 0; // un autre process a deja transite...
|
||||
$db->recheckStatus = 'paid'; // ...vers paid : on sort idempotent.
|
||||
$db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 2]];
|
||||
$db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
|
||||
|
||||
$res = $this->repo($db)->pay('K100');
|
||||
|
||||
self::assertSame('paid', $res['status']);
|
||||
self::assertSame(0, $db->countWrites('UPDATE ingredient SET stock_quantity'));
|
||||
self::assertSame(0, $db->countWrites('INSERT INTO stock_movement'));
|
||||
}
|
||||
|
||||
public function testPayMenuDecrementsBurgerAndSelectionRecipesAtMaxi(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 1200, 'status' => 'pending_payment'];
|
||||
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1];
|
||||
$db->orderItems = [['id' => 1, 'item_type' => 'menu', 'product_id' => null, 'menu_id' => 5, 'format' => 'maxi', 'quantity' => 1]];
|
||||
$db->selectionsByItem[1] = [['product_id' => 20]];
|
||||
$db->compositions[12] = [['ingredient_id' => 3, 'quantity_normal' => 1, 'quantity_maxi' => 2]]; // burger : maxi -> 2
|
||||
$db->compositions[20] = [['ingredient_id' => 7, 'quantity_normal' => 1, 'quantity_maxi' => 1]]; // boisson : 1
|
||||
|
||||
$this->repo($db)->pay('K100');
|
||||
|
||||
$decs = $db->allWrites('UPDATE ingredient SET stock_quantity');
|
||||
self::assertCount(2, $decs);
|
||||
// Ordonne par ingredient_id (ordre de verrou stable) : 3 puis 7.
|
||||
self::assertSame(3, $decs[0]['id']);
|
||||
self::assertSame(2, $decs[0]['u']);
|
||||
self::assertSame(7, $decs[1]['id']);
|
||||
self::assertSame(1, $decs[1]['u']);
|
||||
}
|
||||
|
||||
public function testPayAppliesRemoveAndAddModifiers(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
|
||||
$db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 1]];
|
||||
$db->compositions[12] = [
|
||||
['ingredient_id' => 3, 'quantity_normal' => 1, 'quantity_maxi' => 1], // retire
|
||||
['ingredient_id' => 9, 'quantity_normal' => 1, 'quantity_maxi' => 1], // ajoute
|
||||
];
|
||||
$db->modifiersByItem[1] = [
|
||||
['ingredient_id' => 3, 'action' => 'remove'],
|
||||
['ingredient_id' => 9, 'action' => 'add'],
|
||||
];
|
||||
|
||||
$this->repo($db)->pay('K100');
|
||||
|
||||
// ingredient 3 retire -> aucun mouvement ; ingredient 9 ajoute -> base + supplement = 2.
|
||||
$decs = $db->allWrites('UPDATE ingredient SET stock_quantity');
|
||||
self::assertCount(1, $decs);
|
||||
self::assertSame(9, $decs[0]['id']);
|
||||
self::assertSame(2, $decs[0]['u']);
|
||||
}
|
||||
|
||||
public function testPayAggregatesSharedIngredientIntoSingleMovement(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 1500, 'status' => 'pending_payment'];
|
||||
$db->orderItems = [
|
||||
['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 1],
|
||||
['id' => 2, 'item_type' => 'product', 'product_id' => 13, 'menu_id' => null, 'format' => 'normal', 'quantity' => 1],
|
||||
];
|
||||
$db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
|
||||
$db->compositions[13] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
|
||||
|
||||
$this->repo($db)->pay('K100');
|
||||
|
||||
// Meme ingredient sur deux lignes -> un seul mouvement, delta agrege -2.
|
||||
self::assertSame(1, $db->countWrites('INSERT INTO stock_movement'));
|
||||
self::assertSame(1, $db->countWrites('UPDATE ingredient SET stock_quantity'));
|
||||
$move = $db->firstWrite('INSERT INTO stock_movement');
|
||||
self::assertSame(-2, $move['delta']);
|
||||
}
|
||||
|
||||
public function testPayAttributesActingUserWhenProvided(): void
|
||||
{
|
||||
$db = new FakeOrderDatabase();
|
||||
$db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
|
||||
$db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 1]];
|
||||
$db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
|
||||
|
||||
$this->repo($db)->pay('K100', 7);
|
||||
|
||||
$transition = $db->firstWrite('UPDATE customer_order SET status');
|
||||
self::assertSame(7, $transition['uid']);
|
||||
$move = $db->firstWrite('INSERT INTO stock_movement');
|
||||
self::assertSame(7, $move['uid']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue