Step 00 — Squelette + intégration naïve
3 Bounded Contexts (Sales, Invoicing, LegacyFulfillment) avec : - Domaines complets (agrégats, VOs, événements, invariants) - Couche application (commands, queries, ports) - Infrastructure in-memory (repos, gateway fake) - Controllers HTTP Symfony - Couplage naïf synchrone entre BC via NaiveSalesEventPublisher - 20 tests unitaires et d'intégration passants
This commit is contained in:
0
tests/Contract/.gitkeep
Normal file
0
tests/Contract/.gitkeep
Normal file
88
tests/Integration/NaiveWorkflowTest.php
Normal file
88
tests/Integration/NaiveWorkflowTest.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Tests\Integration;
|
||||
|
||||
use MiniShop\Invoicing\Application\Command\IssueInvoiceForExternalOrderHandler;
|
||||
use MiniShop\Invoicing\Infrastructure\Persistence\InMemoryInvoiceRepository;
|
||||
use MiniShop\Invoicing\Infrastructure\SequentialInvoiceNumberGenerator;
|
||||
use MiniShop\LegacyFulfillment\Application\Command\RequestShipmentFromSalesOrderHandler;
|
||||
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
|
||||
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentStatus;
|
||||
use MiniShop\LegacyFulfillment\Infrastructure\Gateway\FakeLegacyFulfillmentGateway;
|
||||
use MiniShop\LegacyFulfillment\Infrastructure\Persistence\InMemoryShipmentRequestRepository;
|
||||
use MiniShop\Sales\Application\Command\ConfirmOrder;
|
||||
use MiniShop\Sales\Application\Command\ConfirmOrderHandler;
|
||||
use MiniShop\Sales\Application\Command\PlaceOrder;
|
||||
use MiniShop\Sales\Application\Command\PlaceOrderHandler;
|
||||
use MiniShop\Sales\Domain\Model\OrderId;
|
||||
use MiniShop\Sales\Domain\Model\OrderStatus;
|
||||
use MiniShop\Sales\Infrastructure\Messaging\NaiveSalesEventPublisher;
|
||||
use MiniShop\Sales\Infrastructure\Persistence\InMemoryOrderRepository;
|
||||
use MiniShop\Shared\Technical\SystemClock;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Test d'integration naif : PlaceOrder -> ConfirmOrder -> Invoice + Shipment.
|
||||
* Illustre le couplage direct entre BC (episode 00).
|
||||
*/
|
||||
final class NaiveWorkflowTest extends TestCase
|
||||
{
|
||||
public function test_full_naive_workflow(): void
|
||||
{
|
||||
// --- Setup : cablage naif synchrone ---
|
||||
$clock = new SystemClock();
|
||||
$orderRepo = new InMemoryOrderRepository();
|
||||
$invoiceRepo = new InMemoryInvoiceRepository();
|
||||
$shipmentRepo = new InMemoryShipmentRequestRepository();
|
||||
$gateway = new FakeLegacyFulfillmentGateway();
|
||||
|
||||
$issueInvoiceHandler = new IssueInvoiceForExternalOrderHandler(
|
||||
$invoiceRepo,
|
||||
new SequentialInvoiceNumberGenerator(),
|
||||
$clock,
|
||||
);
|
||||
$requestShipmentHandler = new RequestShipmentFromSalesOrderHandler(
|
||||
$shipmentRepo,
|
||||
$gateway,
|
||||
$clock,
|
||||
);
|
||||
$publisher = new NaiveSalesEventPublisher($issueInvoiceHandler, $requestShipmentHandler);
|
||||
|
||||
$placeOrderHandler = new PlaceOrderHandler($orderRepo, $publisher, $clock);
|
||||
$confirmOrderHandler = new ConfirmOrderHandler($orderRepo, $publisher);
|
||||
|
||||
$orderId = 'test-order-001';
|
||||
|
||||
// --- Act : passer et confirmer une commande ---
|
||||
($placeOrderHandler)(new PlaceOrder(
|
||||
orderId: $orderId,
|
||||
customerId: 'cust-001',
|
||||
lines: [
|
||||
['productName' => 'Widget', 'quantity' => 2, 'unitPriceInCents' => 1500, 'currency' => 'EUR'],
|
||||
['productName' => 'Gadget', 'quantity' => 1, 'unitPriceInCents' => 2500, 'currency' => 'EUR'],
|
||||
],
|
||||
));
|
||||
|
||||
($confirmOrderHandler)(new ConfirmOrder(orderId: $orderId));
|
||||
|
||||
// --- Assert : ordre confirme ---
|
||||
$order = $orderRepo->get(OrderId::fromString($orderId));
|
||||
self::assertSame(OrderStatus::Confirmed, $order->status());
|
||||
|
||||
// --- Assert : facture creee ---
|
||||
$invoice = $invoiceRepo->findByExternalOrderId($orderId);
|
||||
self::assertNotNull($invoice, 'An invoice should have been created for the confirmed order.');
|
||||
self::assertSame($orderId, $invoice->externalOrderId);
|
||||
self::assertCount(2, $invoice->lines());
|
||||
|
||||
// --- Assert : expedition demandee ---
|
||||
$shipment = $shipmentRepo->findByOrderRef(LegacyOrderRef::fromExternalId($orderId));
|
||||
self::assertNotNull($shipment, 'A shipment request should have been created.');
|
||||
self::assertSame(ShipmentStatus::Requested, $shipment->status());
|
||||
|
||||
// --- Assert : gateway legacy appele ---
|
||||
self::assertCount(1, $gateway->sentRequests());
|
||||
}
|
||||
}
|
||||
97
tests/Unit/Invoicing/Domain/InvoiceTest.php
Normal file
97
tests/Unit/Invoicing/Domain/InvoiceTest.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Tests\Unit\Invoicing\Domain;
|
||||
|
||||
use MiniShop\Invoicing\Domain\Event\InvoiceIssued;
|
||||
use MiniShop\Invoicing\Domain\Model\BillingParty;
|
||||
use MiniShop\Invoicing\Domain\Model\Invoice;
|
||||
use MiniShop\Invoicing\Domain\Model\InvoiceId;
|
||||
use MiniShop\Invoicing\Domain\Model\InvoiceLine;
|
||||
use MiniShop\Invoicing\Domain\Model\InvoiceStatus;
|
||||
use MiniShop\Invoicing\Domain\Model\TaxRate;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class InvoiceTest extends TestCase
|
||||
{
|
||||
public function test_issue_invoice_for_external_order(): void
|
||||
{
|
||||
$invoice = $this->issueInvoice();
|
||||
|
||||
self::assertSame(InvoiceStatus::Issued, $invoice->status());
|
||||
self::assertSame('ext-order-1', $invoice->externalOrderId);
|
||||
self::assertCount(1, $invoice->lines());
|
||||
}
|
||||
|
||||
public function test_issue_invoice_records_event(): void
|
||||
{
|
||||
$invoice = $this->issueInvoice();
|
||||
|
||||
$events = $invoice->releaseEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(InvoiceIssued::class, $events[0]);
|
||||
}
|
||||
|
||||
public function test_invoice_without_lines_throws(): void
|
||||
{
|
||||
$this->expectException(\DomainException::class);
|
||||
|
||||
Invoice::issueForExternalOrder(
|
||||
InvoiceId::fromString('inv-1'),
|
||||
'INV-000001',
|
||||
'ext-order-1',
|
||||
new BillingParty('Acme', '1 Rue Test'),
|
||||
[],
|
||||
new \DateTimeImmutable(),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_total_coherent_with_lines(): void
|
||||
{
|
||||
$invoice = Invoice::issueForExternalOrder(
|
||||
InvoiceId::fromString('inv-1'),
|
||||
'INV-000001',
|
||||
'ext-order-1',
|
||||
new BillingParty('Acme', '1 Rue Test'),
|
||||
[
|
||||
new InvoiceLine('Widget', 2, 1000, 'EUR', TaxRate::standard()),
|
||||
new InvoiceLine('Gadget', 1, 2500, 'EUR', TaxRate::standard()),
|
||||
],
|
||||
new \DateTimeImmutable(),
|
||||
);
|
||||
|
||||
self::assertSame(4500, $invoice->totalExclTax());
|
||||
self::assertSame(5400, $invoice->totalInclTax());
|
||||
}
|
||||
|
||||
public function test_mark_as_sent(): void
|
||||
{
|
||||
$invoice = $this->issueInvoice();
|
||||
|
||||
$invoice->markAsSent();
|
||||
|
||||
self::assertSame(InvoiceStatus::Sent, $invoice->status());
|
||||
}
|
||||
|
||||
public function test_mark_as_sent_twice_throws(): void
|
||||
{
|
||||
$this->expectException(\DomainException::class);
|
||||
|
||||
$invoice = $this->issueInvoice();
|
||||
$invoice->markAsSent();
|
||||
$invoice->markAsSent();
|
||||
}
|
||||
|
||||
private function issueInvoice(): Invoice
|
||||
{
|
||||
return Invoice::issueForExternalOrder(
|
||||
InvoiceId::fromString('inv-1'),
|
||||
'INV-000001',
|
||||
'ext-order-1',
|
||||
new BillingParty('Acme', '1 Rue Test'),
|
||||
[new InvoiceLine('Widget', 2, 1500, 'EUR', TaxRate::standard())],
|
||||
new \DateTimeImmutable(),
|
||||
);
|
||||
}
|
||||
}
|
||||
68
tests/Unit/LegacyFulfillment/Domain/ShipmentRequestTest.php
Normal file
68
tests/Unit/LegacyFulfillment/Domain/ShipmentRequestTest.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Tests\Unit\LegacyFulfillment\Domain;
|
||||
|
||||
use MiniShop\LegacyFulfillment\Domain\Event\ShipmentRequested;
|
||||
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
|
||||
use MiniShop\LegacyFulfillment\Domain\Model\ParcelSpec;
|
||||
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentRequest;
|
||||
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentStatus;
|
||||
use MiniShop\LegacyFulfillment\Domain\Model\ShippingAddress;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ShipmentRequestTest extends TestCase
|
||||
{
|
||||
public function test_create_from_sales_order(): void
|
||||
{
|
||||
$request = $this->createRequest();
|
||||
|
||||
self::assertSame(ShipmentStatus::Requested, $request->status());
|
||||
self::assertSame('order-1', $request->orderRef->toString());
|
||||
}
|
||||
|
||||
public function test_create_records_event(): void
|
||||
{
|
||||
$request = $this->createRequest();
|
||||
|
||||
$events = $request->releaseEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(ShipmentRequested::class, $events[0]);
|
||||
}
|
||||
|
||||
public function test_mark_dispatched(): void
|
||||
{
|
||||
$request = $this->createRequest();
|
||||
|
||||
$request->markDispatched();
|
||||
|
||||
self::assertSame(ShipmentStatus::Dispatched, $request->status());
|
||||
}
|
||||
|
||||
public function test_mark_dispatched_twice_throws(): void
|
||||
{
|
||||
$this->expectException(\DomainException::class);
|
||||
|
||||
$request = $this->createRequest();
|
||||
$request->markDispatched();
|
||||
$request->markDispatched();
|
||||
}
|
||||
|
||||
public function test_parcel_with_zero_weight_throws(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
new ParcelSpec(0, 'Widget');
|
||||
}
|
||||
|
||||
private function createRequest(): ShipmentRequest
|
||||
{
|
||||
return ShipmentRequest::fromSalesOrder(
|
||||
LegacyOrderRef::fromExternalId('order-1'),
|
||||
new ShippingAddress('John Doe', '1 Rue du Commerce', 'Paris', '75001', 'FR'),
|
||||
new ParcelSpec(1000, 'Order order-1'),
|
||||
new \DateTimeImmutable(),
|
||||
);
|
||||
}
|
||||
}
|
||||
122
tests/Unit/Sales/Domain/OrderTest.php
Normal file
122
tests/Unit/Sales/Domain/OrderTest.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Tests\Unit\Sales\Domain;
|
||||
|
||||
use MiniShop\Sales\Domain\Event\OrderCancelled;
|
||||
use MiniShop\Sales\Domain\Event\OrderConfirmed;
|
||||
use MiniShop\Sales\Domain\Event\OrderPlaced;
|
||||
use MiniShop\Sales\Domain\Exception\EmptyOrderException;
|
||||
use MiniShop\Sales\Domain\Exception\InvalidOrderStateException;
|
||||
use MiniShop\Sales\Domain\Model\CustomerId;
|
||||
use MiniShop\Sales\Domain\Model\Money;
|
||||
use MiniShop\Sales\Domain\Model\Order;
|
||||
use MiniShop\Sales\Domain\Model\OrderId;
|
||||
use MiniShop\Sales\Domain\Model\OrderLine;
|
||||
use MiniShop\Sales\Domain\Model\OrderStatus;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class OrderTest extends TestCase
|
||||
{
|
||||
public function test_place_order_with_lines(): void
|
||||
{
|
||||
$order = $this->placeOrder();
|
||||
|
||||
self::assertSame(OrderStatus::Placed, $order->status());
|
||||
self::assertCount(1, $order->lines());
|
||||
self::assertTrue($order->total()->isPositive());
|
||||
}
|
||||
|
||||
public function test_place_order_records_order_placed_event(): void
|
||||
{
|
||||
$order = $this->placeOrder();
|
||||
|
||||
$events = $order->releaseEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(OrderPlaced::class, $events[0]);
|
||||
}
|
||||
|
||||
public function test_place_order_without_lines_throws(): void
|
||||
{
|
||||
$this->expectException(EmptyOrderException::class);
|
||||
|
||||
Order::place(
|
||||
OrderId::fromString('order-1'),
|
||||
CustomerId::fromString('cust-1'),
|
||||
[],
|
||||
new \DateTimeImmutable(),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_confirm_placed_order(): void
|
||||
{
|
||||
$order = $this->placeOrder();
|
||||
$order->releaseEvents();
|
||||
|
||||
$order->confirm();
|
||||
|
||||
self::assertSame(OrderStatus::Confirmed, $order->status());
|
||||
$events = $order->releaseEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(OrderConfirmed::class, $events[0]);
|
||||
}
|
||||
|
||||
public function test_confirm_draft_order_throws(): void
|
||||
{
|
||||
$this->expectException(InvalidOrderStateException::class);
|
||||
|
||||
$order = $this->placeOrder();
|
||||
$order->confirm();
|
||||
$order->releaseEvents();
|
||||
|
||||
$order->confirm(); // already confirmed
|
||||
}
|
||||
|
||||
public function test_cancel_placed_order(): void
|
||||
{
|
||||
$order = $this->placeOrder();
|
||||
$order->releaseEvents();
|
||||
|
||||
$order->cancel();
|
||||
|
||||
self::assertSame(OrderStatus::Cancelled, $order->status());
|
||||
$events = $order->releaseEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(OrderCancelled::class, $events[0]);
|
||||
}
|
||||
|
||||
public function test_cannot_cancel_confirmed_order(): void
|
||||
{
|
||||
$this->expectException(InvalidOrderStateException::class);
|
||||
|
||||
$order = $this->placeOrder();
|
||||
$order->confirm();
|
||||
$order->cancel();
|
||||
}
|
||||
|
||||
public function test_total_is_sum_of_line_totals(): void
|
||||
{
|
||||
$order = Order::place(
|
||||
OrderId::fromString('order-1'),
|
||||
CustomerId::fromString('cust-1'),
|
||||
[
|
||||
new OrderLine('Widget', 2, new Money(1000, 'EUR')),
|
||||
new OrderLine('Gadget', 1, new Money(2500, 'EUR')),
|
||||
],
|
||||
new \DateTimeImmutable(),
|
||||
);
|
||||
|
||||
self::assertTrue($order->total()->equals(new Money(4500, 'EUR')));
|
||||
}
|
||||
|
||||
private function placeOrder(): Order
|
||||
{
|
||||
return Order::place(
|
||||
OrderId::fromString('order-1'),
|
||||
CustomerId::fromString('cust-1'),
|
||||
[new OrderLine('Widget', 2, new Money(1500, 'EUR'))],
|
||||
new \DateTimeImmutable(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user