From 28fa964a7b2df7145718845f6094f683d314f5f8 Mon Sep 17 00:00:00 2001
From: Petr Knap <8299754+petrknap@users.noreply.github.com>
Date: Wed, 21 Aug 2024 12:06:08 +0200
Subject: [PATCH] Customers name and surname can not be empty strings and
 e-mail must be valid e-mail address

---
 composer.json                                 |  1 +
 src/Model/CreatePaymentCustomer.php           | 19 ++++---
 src/ValueObject/Amount.php                    |  6 +-
 src/ValueObject/BaseValueObject.php           | 49 +++++++++++++++-
 src/ValueObject/CountryCode.php               |  6 +-
 src/ValueObject/CurrencyCode.php              |  6 +-
 src/ValueObject/EmailAddress.php              | 20 +++++++
 src/ValueObject/EnumValueObject.php           |  6 +-
 src/ValueObject/Identifier.php                |  6 +-
 src/ValueObject/LanguageCode.php              |  6 +-
 src/ValueObject/NonEmptyString.php            | 17 ++++++
 src/ValueObject/PhoneNumber.php               |  3 +
 src/ValueObject/SecureUrl.php                 |  3 +
 src/ValueObject/StringValue.php               | 31 ++++------
 src/ValueObject/Url.php                       |  3 +
 tests/ValueObject/BaseValueObjectTestCase.php | 57 +++++++++++++++++++
 tests/ValueObject/EmailAddressTest.php        | 34 +++++++++++
 tests/ValueObject/NonEmptyStringTest.php      | 33 +++++++++++
 tests/ValueObject/StringValueTest.php         | 29 ++++++++++
 19 files changed, 284 insertions(+), 51 deletions(-)
 create mode 100644 src/ValueObject/EmailAddress.php
 create mode 100644 src/ValueObject/NonEmptyString.php
 create mode 100644 tests/ValueObject/BaseValueObjectTestCase.php
 create mode 100644 tests/ValueObject/EmailAddressTest.php
 create mode 100644 tests/ValueObject/NonEmptyStringTest.php
 create mode 100644 tests/ValueObject/StringValueTest.php

diff --git a/composer.json b/composer.json
index 9e4ace2..479f191 100644
--- a/composer.json
+++ b/composer.json
@@ -5,6 +5,7 @@
     "require": {
         "php": "~7.4|~8.0",
         "ext-json": "*",
+        "egulias/email-validator": "^4.0",
         "psr/http-client": "^1.0",
         "psr/http-factory": "^1.0",
         "psr/http-message": "^1.0|^2.0"
diff --git a/src/Model/CreatePaymentCustomer.php b/src/Model/CreatePaymentCustomer.php
index 205a97f..8446a4d 100644
--- a/src/Model/CreatePaymentCustomer.php
+++ b/src/Model/CreatePaymentCustomer.php
@@ -3,16 +3,17 @@
 namespace ThePay\ApiClient\Model;
 
 use InvalidArgumentException;
+use ThePay\ApiClient\ValueObject\EmailAddress;
+use ThePay\ApiClient\ValueObject\NonEmptyString;
 use ThePay\ApiClient\ValueObject\PhoneNumber;
-use ThePay\ApiClient\ValueObject\StringValue;
 
 final class CreatePaymentCustomer
 {
     private string $name;
     private string $surname;
-    /** @var StringValue|null */
+    /** @var string|null */
     private $email;
-    /** @var PhoneNumber|null */
+    /** @var string|null */
     private $phone;
     /** @var Address|null */
     private $billingAddress;
@@ -31,10 +32,10 @@ public function __construct(string $name, string $surname, $email, $phone, Addre
             throw new InvalidArgumentException('At least one of $email and $phone is required.');
         }
 
-        $this->name = $name;
-        $this->surname = $surname;
-        $this->email = $email === null ? null : new StringValue($email);
-        $this->phone = $phone === null ? null : new PhoneNumber($phone);
+        $this->name = (new NonEmptyString($name))->getValue();
+        $this->surname = (new NonEmptyString($surname))->getValue();
+        $this->email = $email === null ? null : (new EmailAddress($email))->getValue();
+        $this->phone = $phone === null ? null : (new PhoneNumber($phone))->getValue();
         $this->billingAddress = $billingAddress;
         $this->shippingAddress = $shippingAddress;
     }
@@ -54,7 +55,7 @@ public function getSurname(): string
      */
     public function getEmail()
     {
-        return $this->email === null ? null : $this->email->getValue();
+        return $this->email;
     }
 
     /**
@@ -62,7 +63,7 @@ public function getEmail()
      */
     public function getPhone()
     {
-        return $this->phone === null ? null : $this->phone->getValue();
+        return $this->phone;
     }
 
     /**
diff --git a/src/ValueObject/Amount.php b/src/ValueObject/Amount.php
index 1056318..5d7d71a 100644
--- a/src/ValueObject/Amount.php
+++ b/src/ValueObject/Amount.php
@@ -4,11 +4,11 @@
 
 use InvalidArgumentException;
 
+/**
+ * @extends BaseValueObject<int>
+ */
 final class Amount extends BaseValueObject
 {
-    /** @var int */
-    private $value;
-
     /**
      * Amount constructor.
      *
diff --git a/src/ValueObject/BaseValueObject.php b/src/ValueObject/BaseValueObject.php
index e4440eb..20a0d94 100644
--- a/src/ValueObject/BaseValueObject.php
+++ b/src/ValueObject/BaseValueObject.php
@@ -2,13 +2,27 @@
 
 namespace ThePay\ApiClient\ValueObject;
 
+use InvalidArgumentException;
+
+/**
+ * @template TValue of mixed
+ */
 abstract class BaseValueObject implements ValueObject
 {
     /**
-     * BaseValueObject constructor.
-     * @param mixed $value
+     * @var TValue
+     */
+    protected $value;
+
+    /**
+     * @param TValue|mixed $value
+     *
+     * @throws InvalidArgumentException
      */
-    abstract public function __construct($value);
+    public function __construct($value)
+    {
+        $this->value = static::filter($value);
+    }
 
     /**
      * @param mixed $value
@@ -30,4 +44,33 @@ public function equals(ValueObject $object)
 
         return $this->getValue() === $object->getValue();
     }
+
+    /**
+     * @return TValue
+     */
+    public function getValue()
+    {
+        return $this->value;
+    }
+
+    /**
+     * @internal should be abstract
+     *
+     * @param TValue|mixed $value
+     *
+     * @return TValue
+     *
+     * @throws InvalidArgumentException
+     */
+    protected static function filter($value)
+    {
+        throw self::invalidValue('expected');
+    }
+
+    protected static function invalidValue(string $expected, ?string $actual = null): InvalidArgumentException
+    {
+        return new InvalidArgumentException(
+            'Value ' . ($actual === null ? '' : '"' . $actual . '" ') . 'is not ' . $expected,
+        );
+    }
 }
diff --git a/src/ValueObject/CountryCode.php b/src/ValueObject/CountryCode.php
index dc9275e..9fad216 100644
--- a/src/ValueObject/CountryCode.php
+++ b/src/ValueObject/CountryCode.php
@@ -2,11 +2,11 @@
 
 namespace ThePay\ApiClient\ValueObject;
 
+/**
+ * @extends BaseValueObject<string>
+ */
 final class CountryCode extends BaseValueObject
 {
-    /** @var string */
-    private $value;
-
     /**
      * @param string $value
      */
diff --git a/src/ValueObject/CurrencyCode.php b/src/ValueObject/CurrencyCode.php
index 653de62..5276f78 100644
--- a/src/ValueObject/CurrencyCode.php
+++ b/src/ValueObject/CurrencyCode.php
@@ -4,11 +4,11 @@
 
 use InvalidArgumentException;
 
+/**
+ * @extends BaseValueObject<string>
+ */
 final class CurrencyCode extends BaseValueObject
 {
-    /** @var string */
-    private $value;
-
     /**
      * CurrencyCode constructor.
      *
diff --git a/src/ValueObject/EmailAddress.php b/src/ValueObject/EmailAddress.php
new file mode 100644
index 0000000..6c62647
--- /dev/null
+++ b/src/ValueObject/EmailAddress.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace ThePay\ApiClient\ValueObject;
+
+use Egulias\EmailValidator\EmailValidator;
+use Egulias\EmailValidator\Validation\RFCValidation;
+
+class EmailAddress extends NonEmptyString
+{
+    public static function filter($value)
+    {
+        $nonEmptyString = parent::filter($value);
+
+        if ((new EmailValidator())->isValid($nonEmptyString, new RFCValidation()) === false) {
+            throw self::invalidValue('e-mail address', $nonEmptyString);
+        }
+
+        return $nonEmptyString;
+    }
+}
diff --git a/src/ValueObject/EnumValueObject.php b/src/ValueObject/EnumValueObject.php
index 529c0c8..a12e831 100644
--- a/src/ValueObject/EnumValueObject.php
+++ b/src/ValueObject/EnumValueObject.php
@@ -2,11 +2,11 @@
 
 namespace ThePay\ApiClient\ValueObject;
 
+/**
+ * @extends BaseValueObject<string>
+ */
 abstract class EnumValueObject extends BaseValueObject
 {
-    /** @var string */
-    protected $value;
-
     /**
      * @param string $value
      */
diff --git a/src/ValueObject/Identifier.php b/src/ValueObject/Identifier.php
index ed86a22..ee77934 100644
--- a/src/ValueObject/Identifier.php
+++ b/src/ValueObject/Identifier.php
@@ -4,11 +4,11 @@
 
 use InvalidArgumentException;
 
+/**
+ * @extends BaseValueObject<string>
+ */
 final class Identifier extends BaseValueObject
 {
-    /** @var string */
-    private $value;
-
     /**
      * Uid constructor.
      *
diff --git a/src/ValueObject/LanguageCode.php b/src/ValueObject/LanguageCode.php
index f384464..6a5348a 100644
--- a/src/ValueObject/LanguageCode.php
+++ b/src/ValueObject/LanguageCode.php
@@ -4,11 +4,11 @@
 
 use InvalidArgumentException;
 
+/**
+ * @extends BaseValueObject<string>
+ */
 final class LanguageCode extends BaseValueObject
 {
-    /** @var string */
-    private $value;
-
     /**
      * CurrencyCode constructor.
      *
diff --git a/src/ValueObject/NonEmptyString.php b/src/ValueObject/NonEmptyString.php
new file mode 100644
index 0000000..6e54759
--- /dev/null
+++ b/src/ValueObject/NonEmptyString.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace ThePay\ApiClient\ValueObject;
+
+class NonEmptyString extends StringValue
+{
+    protected static function filter($value)
+    {
+        $string = parent::filter($value);
+
+        if (trim($string) === '') {
+            throw self::invalidValue('non-empty string', $string);
+        }
+
+        return $string;
+    }
+}
diff --git a/src/ValueObject/PhoneNumber.php b/src/ValueObject/PhoneNumber.php
index 311d9f2..ba94ecd 100644
--- a/src/ValueObject/PhoneNumber.php
+++ b/src/ValueObject/PhoneNumber.php
@@ -4,6 +4,9 @@
 
 use InvalidArgumentException;
 
+/**
+ * @extends BaseValueObject<string>
+ */
 final class PhoneNumber extends BaseValueObject
 {
     /** @var string */
diff --git a/src/ValueObject/SecureUrl.php b/src/ValueObject/SecureUrl.php
index ed9ff78..9193d0a 100644
--- a/src/ValueObject/SecureUrl.php
+++ b/src/ValueObject/SecureUrl.php
@@ -2,6 +2,9 @@
 
 namespace ThePay\ApiClient\ValueObject;
 
+/**
+ * @extends BaseValueObject<string>
+ */
 final class SecureUrl extends BaseValueObject
 {
     /** @var string */
diff --git a/src/ValueObject/StringValue.php b/src/ValueObject/StringValue.php
index da0401a..50443bb 100644
--- a/src/ValueObject/StringValue.php
+++ b/src/ValueObject/StringValue.php
@@ -2,33 +2,22 @@
 
 namespace ThePay\ApiClient\ValueObject;
 
-final class StringValue extends BaseValueObject
+/**
+ * @extends BaseValueObject<string>
+ */
+class StringValue extends BaseValueObject
 {
-    /** @var string */
-    private $value;
-
-    public function __construct($value)
-    {
-        if ( ! is_string($value)) {
-            throw new \InvalidArgumentException('type of value: ' . (string) $value . ' is not string');
-        }
-
-        $this->value = $value;
-    }
-
-    /**
-     * @return string
-     */
     public function __toString()
     {
         return $this->value;
     }
 
-    /**
-     * @return string
-     */
-    public function getValue()
+    protected static function filter($value)
     {
-        return $this->value;
+        if ( ! is_string($value)) {
+            throw self::invalidValue('string');
+        }
+
+        return $value;
     }
 }
diff --git a/src/ValueObject/Url.php b/src/ValueObject/Url.php
index dc6d970..cb5831c 100644
--- a/src/ValueObject/Url.php
+++ b/src/ValueObject/Url.php
@@ -4,6 +4,9 @@
 
 use InvalidArgumentException;
 
+/**
+ * @extends BaseValueObject<string>
+ */
 final class Url extends BaseValueObject
 {
     /** @var string */
diff --git a/tests/ValueObject/BaseValueObjectTestCase.php b/tests/ValueObject/BaseValueObjectTestCase.php
new file mode 100644
index 0000000..b5f90f1
--- /dev/null
+++ b/tests/ValueObject/BaseValueObjectTestCase.php
@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+namespace ThePay\ApiClient\Tests\ValueObject;
+
+use PHPUnit\Framework\TestCase;
+use ThePay\ApiClient\ValueObject\BaseValueObject;
+
+abstract class BaseValueObjectTestCase extends TestCase
+{
+    /**
+     * @dataProvider validValuesDataProvider
+     *
+     * @param mixed $value
+     */
+    public function testCreatesWorkingInstanceWithValidValue($value): void
+    {
+        $className = static::getClassName();
+        $a = $className::create($value);
+        $b = new $className($value);
+
+        self::assertTrue($a->equals($b));
+        self::assertTrue($b->equals($a));
+        self::assertSame($value, $a->getValue());
+        self::assertSame((string) $value, (string) $a);
+    }
+
+    /**
+     * @dataProvider invalidValuesAndMessagesDataProvider
+     *
+     * @param mixed $value
+     */
+    public function testThrowsWithInvalidValue($value, string $message): void
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionMessage($message);
+
+        $className = static::getClassName();
+        $className::create($value);
+    }
+
+    /**
+     * @return class-string<BaseValueObject>
+     */
+    abstract protected static function getClassName(): string;
+
+    /**
+     * @return array<array<mixed>>|array<string, array<mixed>
+     */
+    abstract public static function validValuesDataProvider(): array;
+
+    /**
+     * @return array<array<mixed|string>>|array<string, array<mixed|string>
+     */
+    abstract public static function invalidValuesAndMessagesDataProvider(): array;
+}
diff --git a/tests/ValueObject/EmailAddressTest.php b/tests/ValueObject/EmailAddressTest.php
new file mode 100644
index 0000000..8f0da5a
--- /dev/null
+++ b/tests/ValueObject/EmailAddressTest.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace ThePay\ApiClient\Tests\ValueObject;
+
+use ThePay\ApiClient\ValueObject\EmailAddress;
+
+final class EmailAddressTest extends BaseValueObjectTestCase
+{
+    protected static function getClassName(): string
+    {
+        return EmailAddress::class;
+    }
+
+    public static function validValuesDataProvider(): array
+    {
+        return [
+            ['user@example.com'],
+            ['user+tag@example.com'],
+            ['idn@测试假域名.com'],
+        ];
+    }
+
+    public static function invalidValuesAndMessagesDataProvider(): array
+    {
+        return array_merge(
+            NonEmptyStringTest::invalidValuesAndMessagesDataProvider(),
+            [
+                ['foo', 'Value "foo" is not e-mail address'],
+            ],
+        );
+    }
+}
diff --git a/tests/ValueObject/NonEmptyStringTest.php b/tests/ValueObject/NonEmptyStringTest.php
new file mode 100644
index 0000000..f700803
--- /dev/null
+++ b/tests/ValueObject/NonEmptyStringTest.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace ThePay\ApiClient\Tests\ValueObject;
+
+use ThePay\ApiClient\ValueObject\NonEmptyString;
+
+final class NonEmptyStringTest extends BaseValueObjectTestCase
+{
+    protected static function getClassName(): string
+    {
+        return NonEmptyString::class;
+    }
+
+    public static function validValuesDataProvider(): array
+    {
+        return [
+            ['string'],
+        ];
+    }
+
+    public static function invalidValuesAndMessagesDataProvider(): array
+    {
+        return array_merge(
+            StringValueTest::invalidValuesAndMessagesDataProvider(),
+            [
+                ['', 'Value "" is not non-empty string'],
+                [' ', 'Value " " is not non-empty string'],
+            ],
+        );
+    }
+}
diff --git a/tests/ValueObject/StringValueTest.php b/tests/ValueObject/StringValueTest.php
new file mode 100644
index 0000000..06e9124
--- /dev/null
+++ b/tests/ValueObject/StringValueTest.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace ThePay\ApiClient\Tests\ValueObject;
+
+use ThePay\ApiClient\ValueObject\StringValue;
+
+final class StringValueTest extends BaseValueObjectTestCase
+{
+    protected static function getClassName(): string
+    {
+        return StringValue::class;
+    }
+
+    public static function validValuesDataProvider(): array
+    {
+        return [
+            ['string'],
+        ];
+    }
+
+    public static function invalidValuesAndMessagesDataProvider(): array
+    {
+        return [
+            [null, 'Value is not string'],
+        ];
+    }
+}