feat: 🎉 base DDD project

This commit is contained in:
Alexis Gousseau 2026-07-24 17:19:56 +02:00
parent 06cdc3e7ca
commit 27cdac86cb
No known key found for this signature in database
GPG key ID: 7F3C786EE12DBB72
33 changed files with 6879 additions and 0 deletions

19
.editorconfig Normal file
View file

@ -0,0 +1,19 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{php,json}]
indent_size = 4
[*.{css,scss}]
indent_size = 2
quote_type = single

7
.gitattributes vendored Normal file
View file

@ -0,0 +1,7 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php

51
.gitignore vendored Normal file
View file

@ -0,0 +1,51 @@
.env
.env.testing
.env.backup
.env.production
.phpunit.result.cache
.stylelintcache
/.fleet
/.idea
/.phpunit.cache
/.vscode
/node_modules
/bootstrap/cache
/public/build
/public/cache
/public/hot
/public/sitemap.xml
/public/storage
/storage/*.key
/vendor
coverage/
auth.json
docker-compose.override.yml
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
.npmrc
js.save
phpstan-report.xml
# Ignore public favicon
/public/android-chrome-192x192.png
/public/android-chrome-512x512.png
/public/apple-touch-icon.png
/public/browserconfig.xml
/public/browserconfig.xml
/public/favicon-16x16.png
/public/favicon-32x32.png
/public/favicon.ico
/public/mstile-150x150.png
/public/safari-pinned-tab.svg
/public/site.webmanifest
/public/files
develop
_ide_helper.php
.phpstorm.meta.php
phpstan-report.xml
// Ignore PHPUnit cache files
cache

64
composer.json Normal file
View file

@ -0,0 +1,64 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "projets/mesbr",
"type": "project",
"description": "MesBr project.",
"keywords": [
"framework",
"tests"
],
"license": "MIT",
"require": {
"php": "^8.5"
},
"require-dev": {
"ace-of-aces/intellipest": "^0.4.0",
"laravel/pint": "^1.22",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"nunomaduro/mock-final-classes": "^1.2",
"pestphp/pest": "^4.0",
"pestphp/pest-plugin-faker": "^4.0",
"pestphp/pest-plugin-type-coverage": "^4.0",
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/phpstan": "^2.2",
"phpunit/phpunit": "^12.5",
"ramsey/uuid": "^4.9",
"rector/rector": "^2.5",
"slevomat/coding-standard": "^8.22"
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"composer/package-versions-deprecated": true,
"dealerdirect/phpcodesniffer-composer-installer": true,
"php-http/discovery": true,
"pestphp/pest-plugin": true
}
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"autoload": {
"psr-4": {
"Application\\": "src/Application/",
"Domain\\": "src/Domain/",
"Shared\\": "src/Shared/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"minimum-stability": "stable",
"prefer-stable": true,
"scripts": {
"tests": "vendor/bin/pest",
"linters": "vendor/bin/pint && vendor/bin/rector && vendor/bin/phpcs && vendor/bin/phpstan analyse"
}
}

5608
composer.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,43 @@
# ADR-0001 — [Titre]
## Status
Proposed
## Context
### Quel est le problème ?
[Réponse]
### Pourquoi devons-nous prendre cette décision ?
[Réponse]
## Decision
[Qu'avons-nous décidé ?]
## Rationale
[Pourquoi cette solution ?]
## Consequences
### Positives
- ...
### Negatives
- ...
## Alternatives
### Alternative 1
[Description + pourquoi rejetée]
### Alternative 2
[Description + pourquoi rejetée]

52
phpcs.xml Normal file
View file

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<ruleset name="Php Coding Standard"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="vendor/squizlabs/php_codesniffer/phpcs.xsd">
<description>Standard optimisé: Analyse statique complémentaire à Pint.</description>
<arg name="colors" />
<arg name="extensions" value="php" />
<arg name="parallel" value="75" />
<file>src/</file>
<file>tests/</file>
<exclude-pattern>*/vendor/*</exclude-pattern>
<exclude-pattern>*/storage/*</exclude-pattern>
<exclude-pattern>_ide_helper.php</exclude-pattern>
<rule ref="PSR12"/>
<rule ref="Generic.CodeAnalysis.EmptyStatement"/>
<rule ref="Generic.CodeAnalysis.UnconditionalIfStatement"/>
<rule ref="Generic.CodeAnalysis.UselessOverridingMethod"/>
<rule ref="Generic.Metrics.CyclomaticComplexity">
<properties>
<property name="complexity" value="10"/>
<property name="absoluteComplexity" value="20"/>
</properties>
</rule>
<rule ref="Generic.Metrics.NestingLevel"/>
<rule ref="Generic.PHP.DeprecatedFunctions"/>
<rule ref="Generic.PHP.ForbiddenFunctions"/>
<rule ref="Generic.PHP.NoSilencedErrors"/>
<rule ref="Squiz.PHP.Eval"/>
<rule ref="Squiz.PHP.GlobalKeyword"/>
<rule ref="Squiz.PHP.NonExecutableCode"/>
<rule ref="Squiz.Scope.StaticThisUsage"/>
<rule ref="Generic.NamingConventions.ConstructorName"/>
<rule ref="Generic.NamingConventions.CamelCapsFunctionName"/>
<rule ref="Generic.Files.LineLength">
<properties>
<property name="lineLimit" value="150"/>
<property name="absoluteLineLimit" value="250"/>
</properties>
</rule>
<rule ref="PSR1.Classes.ClassDeclaration.MissingNamespace">
<exclude-pattern>*/database/migrations/*</exclude-pattern>
</rule>
</ruleset>

9
phpstan.neon Normal file
View file

@ -0,0 +1,9 @@
parameters:
paths:
- src/
# Level 10 is the highest level
level: 10
# Enforce strict types analysis
treatPhpDocTypesAsCertain: false

22
phpunit.xml Normal file
View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/12.5/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="Architecture">
<directory suffix="Test.php">./tests/Architecture</directory>
</testsuite>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">./src</directory>
</include>
</source>
<coverage>
<report>
<clover outputFile="coverage/clover.xml"/>
<html outputDirectory="coverage/html"/>
</report>
</coverage>
</phpunit>

78
pint.json Normal file
View file

@ -0,0 +1,78 @@
{
"version": "1.0.0",
"preset": "psr12",
"rules": {
"indentation_type": true,
"single_line_empty_body": false,
"types_spaces": {
"space": "single",
"space_multiple_catch": "single"
},
"control_structure_braces": true,
"new_with_parentheses": {
"anonymous_class": true,
"named_class": true
},
"class_definition": {
"single_item_single_line": false,
"single_line": false,
"space_before_parenthesis": true
},
"curly_braces_position": {
"allow_single_line_empty_anonymous_classes": false,
"classes_opening_brace": "next_line_unless_newline_at_signature_end",
"anonymous_classes_opening_brace": "next_line_unless_newline_at_signature_end",
"control_structures_opening_brace": "same_line",
"functions_opening_brace": "next_line_unless_newline_at_signature_end",
"anonymous_functions_opening_brace": "same_line"
},
"binary_operator_spaces": {
"default": "align_single_space_minimal",
"operators": {
"=": "align_single_space",
"|": "single_space",
"=>": "align_single_space",
".=": "align_single_space"
}
},
"concat_space": {
"spacing": "one"
},
"align_multiline_comment": {
"comment_type": "all_multiline"
},
"array_indentation": true,
"method_chaining_indentation": true,
"statement_indentation": {
"stick_comment_to_next_continuous_control_statement": true
},
"no_unused_imports": true,
"fully_qualified_strict_types": {
"import_symbols": true,
"leading_backslash_in_global_namespace": true,
"phpdoc_tags": []
},
"visibility_required": {
"elements": [
"const",
"method",
"property"
]
},
"no_useless_else": true,
"function_declaration": {
"closure_function_spacing": "one",
"closure_fn_spacing": "one",
"trailing_comma_single_line": false
},
"single_line_after_imports": true,
"combine_consecutive_issets": true,
"array_syntax": {
"syntax": "short"
},
"single_quote": true,
"blank_line_after_namespace": true,
"blank_line_after_opening_tag": true,
"declare_strict_types": true
}
}

21
rector.php Normal file
View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\SetList;
return RectorConfig::configure()
->withPaths([
__DIR__ . '/src',
__DIR__ . '/tests',
])
->withPhpSets()
->withSets([
SetList::CODE_QUALITY,
SetList::DEAD_CODE,
SetList::TYPE_DECLARATION,
SetList::EARLY_RETURN,
SetList::PRIVATIZATION,
SetList::INSTANCEOF,
]);

0
src/Application/.gitkeep Normal file
View file

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Domain\Models\Entities;
use Domain\Models\ValueObjects\ProductId;
use DomainException;
use Shared\DDD\Entity;
/**
* @implements \Shared\DDD\Entity<\Domain\Models\ValueObjects\ProductId>
*/
final readonly class Product implements Entity
{
public function __construct(
private ProductId $productId,
private string $name,
) {
if (trim($name) === '') {
throw new DomainException('Product name cannot be empty.');
}
}
public function id(): ProductId
{
return $this->productId;
}
public function name(): string
{
return $this->name;
}
public function equals(Product $product): bool
{
return $this->productId->equals($product->productId);
}
}

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use DateTimeInterface;
use DomainException;
use InvalidArgumentException;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use Shared\DDD\EntityId;
use Shared\Helpers\UuidHelper;
final readonly class ProductId extends EntityId
{
/**
* @param UuidInterface $identifier Universally unique identifier.
*/
public function __construct(
private UuidInterface $identifier
) {
parent::__construct($identifier->toString());
try {
UuidHelper::validateUuid($identifier);
} catch (InvalidArgumentException $e) {
throw new DomainException(
"Invalid value '{$identifier->toString()}' is not a valid UUID",
0,
$e
);
}
if (! UuidHelper::isV7($identifier)) {
throw new DomainException(
"Invalid value '{$identifier}' is not a valid V7 UUID"
);
}
}
/**
* Generate the value of the universally unique identifier.
*/
public function toDateTime(): DateTimeInterface
{
return UuidHelper::toDateTime($this->identifier);
}
/**
* Generate the value of the universally unique identifier.
*/
public static function generate(): self
{
return new self(Uuid::uuid7());
}
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Shared\DDD;
/**
* @template T of EntityId
*
* @extends Entity<T>
*/
interface AggregateRoot extends Entity
{
}

14
src/Shared/DDD/Entity.php Normal file
View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Shared\DDD;
/**
* @template T of EntityId
*/
interface Entity
{
/** @return T */
public function id(): EntityId;
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Shared\DDD;
use DomainException;
use Stringable;
abstract readonly class EntityId implements Stringable, Equatable
{
public function __construct(
public string $value
) {
if (trim($value) === '') {
throw new DomainException('EntityId value cannot be empty');
}
}
#[\Override]
public function __toString(): string
{
return $this->value;
}
#[\Override]
public function equals(mixed $other): bool
{
return $other instanceof static && $this->value === $other->value;
}
}

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Shared\DDD;
interface Equatable
{
public function equals(mixed $other): bool;
}

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Shared\DDD;
/**
*/
interface Repository
{
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Shared\Exceptions;
use InvalidArgumentException;
final class InvalidUuidException extends InvalidArgumentException
{
public static function forValue(string $uuid): self
{
return new self("Invalid UUID: {$uuid}");
}
}

View file

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Shared\Helpers;
use DateTimeImmutable;
use DateTimeZone;
use Ramsey\Uuid\Rfc4122\FieldsInterface;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use Shared\Exceptions\InvalidUuidException;
final readonly class UuidHelper
{
/**
* Check if uuid is v7 or not.
*/
public static function isV7(UuidInterface $uuid): bool
{
return self::version($uuid) === 7;
}
/**
* Get the version of uuid.
*/
public static function version(UuidInterface $uuid): int
{
$fields = $uuid->getFields();
if ($fields instanceof FieldsInterface) {
$version = $fields->getVersion();
if ($version !== null) {
return $version;
}
}
$uuidString = $uuid->toString();
if (strlen($uuidString) >= 15) {
return (int) hexdec($uuidString[14]);
}
return 0;
}
/**
* Validate uuid.
*/
public static function validateUuid(UuidInterface $uuid): void
{
if (! Uuid::isValid($uuid->toString())) {
throw InvalidUuidException::forValue($uuid->toString());
}
}
/**
* Get date time from uuid.
*/
public static function toDateTime(UuidInterface $uuid): DateTimeImmutable
{
if (! self::isV7($uuid)) {
throw new InvalidUuidException("Cannot extract datetime from non-V7 UUID: {$uuid->toString()}");
}
$fields = $uuid->getFields();
if ($fields instanceof FieldsInterface) {
$timeMsHex = $fields->getTimeLow()->toString() . $fields->getTimeMid()->toString();
} else {
$hex = str_replace('-', '', $uuid->toString());
$timeMsHex = substr($hex, 0, 12);
}
$timeMs = (int) hexdec($timeMsHex);
$sec = intdiv($timeMs, 1000);
$ms = $timeMs % 1000;
$dateTime = DateTimeImmutable::createFromFormat('U.u', sprintf('%d.%06d', $sec, $ms * 1000));
if ($dateTime === false) {
throw new InvalidUuidException("Failed to extract datetime from UUID: {$uuid->toString()}");
}
return $dateTime->setTimezone(new DateTimeZone('UTC'));
}
}

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Shared\Models\ValueObjects;
use DomainException;
use Shared\DDD\Equatable;
/**
* Represents a valid email.
*/
final readonly class Email implements Equatable
{
private string $value;
/**
* @param string $value A valid email address.
* @throws DomainException
*/
public function __construct(
string $value,
) {
$trimmed = trim($value);
if ($trimmed === '') {
throw new DomainException('Email cannot be empty.');
}
if (mb_strlen($trimmed) > 2048) {
throw new DomainException('Email exceeds the maximum allowed length.');
}
if (filter_var($trimmed, FILTER_VALIDATE_EMAIL) === false) {
throw new DomainException(sprintf('"%s" is not a valid email.', $value));
}
$this->value = mb_strtolower($trimmed, 'UTF-8');
}
/**
* Get the email address value.
*/
public function value(): string
{
return $this->value;
}
/**
* Compare two email addresses for equality.
*
* @param mixed $other Email to compare with.
*/
public function equals(mixed $other): bool
{
if (! $other instanceof self) {
return false;
}
return $this->value === $other->value;
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Shared\Models\ValueObjects;
use DomainException;
use Shared\DDD\Equatable;
/**
* Represents the path to an image file.
*/
final readonly class PicturePath implements Equatable
{
public const array ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'];
/**
* @param string $value path of picture
* @throws DomainException
*/
public function __construct(
private string $value,
) {
$trimmed = trim($this->value);
if ($trimmed === '') {
throw new DomainException('Picture Path cannot be empty.');
}
if (mb_strlen($trimmed) > 2048) {
throw new DomainException('Picture Path exceeds the maximum allowed length.');
}
$extension = strtolower(pathinfo($trimmed, PATHINFO_EXTENSION));
if (! in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
throw new DomainException('Picture Path must end with one of: ' . implode(', ', self::ALLOWED_EXTENSIONS));
}
}
/**
* Get the path name value.
*/
public function value(): string
{
return $this->value;
}
/**
* Compare two path addresses for equality.
*
* @param mixed $other Picture path to compare with.
*/
public function equals(mixed $other): bool
{
if (! $other instanceof self) {
return false;
}
return $this->value === $other->value;
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
describe('application coding rules', function (): void {
arch('no Domain Entities are used in Controllers')
->expect('Application\\Http\\Controllers')
->not->toUse('Domain\\Models\\Entities');
arch('no Domain Entities are used in Policies')
->expect('Application\\Policies')
->not->toUse('Domain\\Models\\Entities');
arch('app Repositories implement Application\Contracts\Repository')
->expect('Application\\Repositories')
->toImplement('Application\\Contracts\\Repository');
arch('app Directories implement Application\Contracts\Directory')
->expect('Application\\Directories')
->toImplement('Application\\Contracts\\Directory');
arch('app DTOs are final readonly classes')
->expect('Application\\Dto')
->classes()
->toBeFinal()
->toBeReadonly();
arch('no debug calls in production code')
->expect(['dd', 'dump', 'die', 'var_dump', 'sleep'])
->not->toBeUsed();
});

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
describe('coding standards', function (): void {
arch()->preset()->php();
arch()->preset()->security();
arch('it has no shell execution functions are used')
->expect(['exec', 'shell_exec', 'system', 'passthru', 'proc_open'])
->not->toBeUsed();
arch('it has no eval usage')
->expect(['eval'])
->not->toBeUsed();
arch('it has no dangerous file functions')
->expect(['file_get_contents', 'file_put_contents', 'fopen'])
->not->toBeUsed();
arch('it has no globals are used')
->expect(['$GLOBALS', 'global'])
->not->toBeUsed();
arch('it has no singletons')
->expect(['getInstance', 'instance'])
->not->toBeUsed();
arch('it has no insecure hashing')
->expect(['md5', 'sha1'])
->not->toBeUsed();
arch('it has no sleep function are used')
->expect(['sleep', 'usleep'])
->not->toBeUsed();
arch('it has no debug globals are used')
->expect(['var_dump', 'die', 'print_r'])
->not->toBeUsed();
arch('all app source files use strict types')
->expect('Application')
->toUseStrictTypes();
arch('all domain source files use strict types')
->expect('Domain')
->toUseStrictTypes();
arch('all shared source files use strict types')
->expect('Shared')
->toUseStrictTypes();
});

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use Shared\DDD\Entity;
use Shared\DDD\Repository;
describe('domain coding rules', function (): void {
arch('entities implements Entity')
->expect('Domain\\Models\\Entities')
->toImplement(Entity::class);
arch('entities are immutable (disabled due to mock-final-classes conflict)')
->expect('Domain\\Models\\Entities')
->toBeReadonly()
->toBeFinal();
arch('valueObjects are immutable (disabled due to mock-final-classes conflict)')
->expect('Domain\\Models\\ValueObjects')
->toBeReadonly()
->toBeFinal();
arch('ports contains only interfaces')
->expect('Domain\\Ports')
->classes()
->toBeInterface();
arch('port Repositories extend Shared\DDD\Repository')
->expect('Domain\\Ports\\Repositories')
->toExtend(Repository::class);
arch('no Application classes are used in Domain')
->expect('Domain')
->not->toUse('Application');
arch('no Infrastructure classes are used in Domain')
->expect('Domain')
->not->toUse('Infrastructure');
arch('no Illuminate classes are used in Domain')
->expect('Domain')
->not->toUse('Illuminate');
arch('no Symfony components are used in Domain')
->expect('Domain')
->not->toUse('Symfony');
});

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
describe('naming conventions in application', function (): void {
arch('casts have Cast suffix')
->expect('Application\\Cast')
->toHaveSuffix('Cast');
arch('adapters repositories have Repository suffix')
->expect('Application\\Adapters\\Repositories')
->toHaveSuffix('Repository');
arch('exceptions have Exception suffix')
->expect('Application\\Exceptions')
->toHaveSuffix('Exception');
arch('controllers have Controller suffix')
->expect('Application\\Http\\Controllers')
->toHaveSuffix('Controller');
arch('requests have Request suffix')
->expect('Application\\Http\\Requests')
->toHaveSuffix('Request');
arch('resources have Resource suffix')
->expect('Application\\Http\\Resources')
->toHaveSuffix('Resource');
arch('policies have Policy suffix')
->expect('Application\\Policies')
->toHaveSuffix('Policy');
arch('providers have Provider suffix')
->expect('Application\\Providers')
->toHaveSuffix('Provider');
arch('services have Service suffix')
->expect('Application\\Services')
->toHaveSuffix('Service');
});
describe('naming conventions in domain', function (): void {
arch('dtos have Dto suffix')
->expect('Domain\\Dto')
->toHaveSuffix('Dto');
arch('exceptions have Exception suffix')
->expect('Domain\\Exceptions')
->toHaveSuffix('Exception');
arch('policies have Policy suffix')
->expect('Domain\\Policies')
->toHaveSuffix('Policy');
arch('port repositories have Repository suffix')
->expect('Domain\\Ports\\Repositories')
->toHaveSuffix('Repository');
arch('domain services have Service suffix')
->expect('Domain\\Services')
->toHaveSuffix('Service');
});

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use Shared\DDD\Entity;
use Shared\DDD\Repository;
describe('shared coding rules', function (): void {
arch('entities implements Entity')
->expect('Shared\\Domain\\Models\\Entities')
->toImplement(Entity::class);
arch('entities are immutable (disabled due to mock-final-classes conflict)')
->expect('Shared\\Entities')
->toBeReadonly()
->toBeFinal();
arch('valueObjects are immutable (disabled due to mock-final-classes conflict)')
->expect('Shared\\ValueObjects')
->toBeReadonly()
->toBeFinal();
arch('ports contains only interfaces')
->expect('Shared\\Ports')
->classes()
->toBeInterface();
arch('port Repositories extend Shared\DDD\Repository')
->expect('Shared\\Ports\\Repositories')
->toExtend(Repository::class);
arch('no Application classes are used in Shared')
->expect('Shared')
->not->toUse('Application');
arch('no Infrastructure classes are used in Shared')
->expect('Shared')
->not->toUse('Infrastructure');
arch('no Illuminate classes are used in Shared')
->expect('Shared')
->not->toUse('Illuminate');
arch('no Symfony components are used in Shared')
->expect('Shared')
->not->toUse('Symfony');
});

40
tests/Pest.php Normal file
View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use DG\BypassFinals;
use Tests\TestCase;
// Prevents mock-final-classes/bypass-finals from removing final/readonly.
if (class_exists(BypassFinals::class)) {
BypassFinals::denyPaths([
dirname(__DIR__) . '/src/*',
]);
}
/*
*--------------------------------------------------------------------------
* Test Case
*--------------------------------------------------------------------------
*
* The closure you provide to your test functions is always bound to a specific PHPUnit test
* case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
* need to change it using the "pest()" function to bind different classes or traits.
*
*/
pest()->extend(TestCase::class)
->in('Architecture', 'Unit');
/*
*--------------------------------------------------------------------------
* Expectations
*--------------------------------------------------------------------------
*
* When you're writing tests, you often need to check that values meet certain conditions. The
* "expect()" function gives you access to a set of "expectations" methods that you can use
* to assert different things. Of course, you may extend the Expectation API at any time.
*
*/
expect()->extend('toBeOne', fn () => $this->toBe(1));

10
tests/TestCase.php Normal file
View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Tests;
abstract class TestCase extends \PHPUnit\Framework\TestCase
{
//
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\Entities\ValueObjects;
use Domain\Models\Entities\Product;
use Domain\Models\ValueObjects\ProductId;
describe('product', function (): void {
it('creates a product with a generated id and a name', function (): void {
$productName = 'New Product';
$productId = ProductId::generate();
$product = new Product($productId, $productName);
expect($product->id())->toBe($productId)
->and($product->name())->toBe($productName);
});
it('rejects an empty name', function (): void {
expect(fn (): Product => new Product(ProductId::generate(), ''))
->toThrow(\DomainException::class, 'Product name cannot be empty.');
});
it('considers two products with the same id as equal', function (): void {
$productId = ProductId::generate();
$first = new Product($productId, 'New Product 1');
$second = new Product($productId, 'New Product 2');
expect($first->equals($second))->toBeTrue();
});
it('considers two products with different ids as not equal', function (): void {
$productName = 'New Product';
$first = new Product(ProductId::generate(), $productName);
$second = new Product(ProductId::generate(), $productName);
expect($first->equals($second))->toBeFalse();
});
});

View file

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Shared\Models\ValueObjects;
use DomainException;
use Shared\Models\ValueObjects\Email;
use stdClass;
covers(Email::class);
describe('email', function (): void {
it('creates a valid email', function (): void {
// GIVEN
$emailValue = ' Contact@Example.COM ';
// WHEN
$email = new Email($emailValue);
// THEN
expect($email)->toBeInstanceOf(Email::class)
->and($email->value())->toBe('contact@example.com');
});
it('rejects an empty email', function (): void {
// GIVEN
$emailValue = '';
// WHEN
$action = fn (): Email => new Email($emailValue);
// THEN
expect($action)->toThrow(DomainException::class, 'Email cannot be empty.');
});
it('rejects invalid email formats', function (string $invalid): void {
// GIVEN & WHEN & THEN
expect(fn (): Email => new Email($invalid))->toThrow(DomainException::class);
})->with([
'',
' ',
'not-an-email',
'@example.com',
'contact@',
'contact@.com',
'contact@com',
str_repeat('a', 2048) . '@example.fr'
]);
it('considers two emails with the same value as equal', function (): void {
// GIVEN
$first = new Email('contact@example.fr');
$second = new Email('contact@example.fr');
// WHEN
$result = $first->equals($second);
// THEN
expect($result)->toBeTrue();
});
it('considers an email as not equal to', function (mixed $invalid): void {
// GIVEN
$email = new Email('contact-1@example.fr');
// WHEN
$result = $email->equals($invalid);
// THEN
expect($result)->toBeFalse();
// GIVEN
})->with([
'different email' => new Email('contact-2@example.fr'),
'non-email value' => new stdClass(),
]);
});

View file

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Shared\Models\ValueObjects;
use DomainException;
use Shared\Models\ValueObjects\PicturePath;
use stdClass;
covers(PicturePath::class);
describe('picture path', function (): void {
it('creates a valid PicturePath', function (): void {
// GIVEN
$pathValue = 'coupons/logo.png';
// WHEN
$path = new PicturePath($pathValue);
// THEN
expect($path)->toBeInstanceOf(PicturePath::class)
->and($path->value())->toBe($pathValue);
});
it('rejects an empty path', function (): void {
// GIVEN
$pathValue = '';
// WHEN
$action = fn (): PicturePath => new PicturePath($pathValue);
// THEN
expect($action)->toThrow(DomainException::class, 'Picture Path cannot be empty.');
});
it('rejects a path that exceeds the maximum allowed length', function (): void {
// GIVEN
$pathValue = str_repeat('a', 2049) . '.jpg';
// WHEN
$action = fn (): PicturePath => new PicturePath($pathValue);
// THEN
expect($action)->toThrow(DomainException::class, 'Picture Path exceeds the maximum allowed length.');
});
it('accepts a path of exactly the maximum length', function (): void {
// GIVEN
$pathValue = str_repeat('a', 2044) . '.jpg';
// WHEN
$path = new PicturePath($pathValue);
// THEN
expect($path)->toBeInstanceOf(PicturePath::class)
->and($path->value())->toHaveLength(2048);
});
it('rejects a path without an image extension', function (): void {
// GIVEN
$pathValue = 'image';
// WHEN
$action = fn (): PicturePath => new PicturePath($pathValue);
// THEN
expect($action)->toThrow(DomainException::class, 'Picture Path must end with one of: ' . implode(', ', PicturePath::ALLOWED_EXTENSIONS));
});
it('considers two paths to be equal if they have the same value', function (): void {
// GIVEN
$first = new PicturePath('coupons/logo.png');
$second = new PicturePath('coupons/logo.png');
// WHEN
$result = $first->equals($second);
// THEN
expect($result)->toBeTrue();
});
it('considers a logo as not equal to', function (mixed $invalid): void {
// GIVEN
$logo = new PicturePath('coupons/logo-1.png');
// WHEN
$result = $logo->equals($invalid);
// THEN
expect($result)->toBeFalse();
// GIVEN
})->with([
'different picture path' => new PicturePath('coupons/logo-2.png'),
'non-picture-path value' => new stdClass(),
]);
});