diff --git a/packages/cnpj-dv/CHANGELOG.md b/packages/cnpj-dv/CHANGELOG.md index 9296044..d4155df 100644 --- a/packages/cnpj-dv/CHANGELOG.md +++ b/packages/cnpj-dv/CHANGELOG.md @@ -1,5 +1,11 @@ # lacus/cnpj-dv +## 1.1.0 + +### New Features + +- a90dd0a89b18a19bbb8ad72200d65df01c465fdb Created **`getName()`** to all package-specific errors and exceptions. Now `CnpjCheckDigitsException`, `CnpjCheckDigitsTypeError` and all their subclasses can return their class names without namespaces. This change is an API alignment across all **BR Utils** initiatives. + ## 1.0.0 ### 🚀 Stable Version Released! diff --git a/packages/cnpj-dv/README.pt.md b/packages/cnpj-dv/README.pt.md index 2b5cee8..93ffeae 100644 --- a/packages/cnpj-dv/README.pt.md +++ b/packages/cnpj-dv/README.pt.md @@ -57,7 +57,7 @@ A classe `CnpjCheckDigits` aceita múltiplos formatos de entrada: **String:** dígitos e/ou letras crus, ou CNPJ formatado (ex.: `91.415.732/0007-93`, `MG.KGM.J9X/0001-68`). Caracteres não alfanuméricos são removidos; letras minúsculas viram maiúsculas. -**Array de strings:** cada elemento deve ser string; os valores são concatenados e interpretados como uma única string (ex.: `['9','1','4',…]`, `['9141','5732','0007']`, `['MG','KGM','J9X','0001']`). Elementos que não são string não são permitidos. +**Array de strings:** cada elemento deve ser string; os valores são concatenados e interpretados como uma única string (ex.: `['9','1','4',…]`, `['9141','5732','0007']`, `['MG','KGM','J9X','0001']`). Elementos que não são strings não são permitidos. ### Erros e exceções diff --git a/packages/cnpj-dv/src/CnpjCheckDigits.php b/packages/cnpj-dv/src/CnpjCheckDigits.php index b79f8a2..2e9d83d 100644 --- a/packages/cnpj-dv/src/CnpjCheckDigits.php +++ b/packages/cnpj-dv/src/CnpjCheckDigits.php @@ -35,7 +35,6 @@ class CnpjCheckDigits /** Maximum number of characters accepted as input for the CNPJ check digits calculation. */ public const CNPJ_MAX_LENGTH = CNPJ_MAX_LENGTH; - /** @var list */ private array $cnpjChars; private ?int $cachedFirstDigit = null; diff --git a/packages/cnpj-dv/src/Exceptions/CnpjCheckDigitsException.php b/packages/cnpj-dv/src/Exceptions/CnpjCheckDigitsException.php index 5e92d3b..7806d87 100644 --- a/packages/cnpj-dv/src/Exceptions/CnpjCheckDigitsException.php +++ b/packages/cnpj-dv/src/Exceptions/CnpjCheckDigitsException.php @@ -5,6 +5,7 @@ namespace Lacus\BrUtils\Cnpj\Exceptions; use Exception; +use ReflectionClass; /** * Base exception for all `cnpj-dv` rules-related errors. @@ -16,4 +17,18 @@ */ abstract class CnpjCheckDigitsException extends Exception { + public function __construct(string $message) + { + parent::__construct($message); + } + + /** + * Get the short class name of the exception instance. + */ + public function getName(): string + { + $thisReflection = new ReflectionClass($this); + + return $thisReflection->getShortName(); + } } diff --git a/packages/cnpj-dv/src/Exceptions/CnpjCheckDigitsTypeError.php b/packages/cnpj-dv/src/Exceptions/CnpjCheckDigitsTypeError.php index 9a50a41..9216ba3 100644 --- a/packages/cnpj-dv/src/Exceptions/CnpjCheckDigitsTypeError.php +++ b/packages/cnpj-dv/src/Exceptions/CnpjCheckDigitsTypeError.php @@ -4,7 +4,7 @@ namespace Lacus\BrUtils\Cnpj\Exceptions; -use Throwable; +use ReflectionClass; use TypeError; /** @@ -24,12 +24,20 @@ public function __construct( string $actualType, string $expectedType, string $message, - int $code = 0, - ?Throwable $previous = null, ) { - parent::__construct($message, $code, $previous); + parent::__construct($message); $this->actualInput = $actualInput; $this->actualType = $actualType; $this->expectedType = $expectedType; } + + /** + * Get the short class name of the error instance. + */ + public function getName(): string + { + $thisReflection = new ReflectionClass($this); + + return $thisReflection->getShortName(); + } } diff --git a/packages/cnpj-dv/tests/Specs/Exceptions.spec.php b/packages/cnpj-dv/tests/Specs/Exceptions.spec.php index 301932f..565219a 100644 --- a/packages/cnpj-dv/tests/Specs/Exceptions.spec.php +++ b/packages/cnpj-dv/tests/Specs/Exceptions.spec.php @@ -4,6 +4,7 @@ namespace Lacus\BrUtils\Cnpj\Tests\Specs; +use Exception; use Lacus\BrUtils\Cnpj\Exceptions\CnpjCheckDigitsException; use Lacus\BrUtils\Cnpj\Exceptions\CnpjCheckDigitsInputInvalidException; use Lacus\BrUtils\Cnpj\Exceptions\CnpjCheckDigitsInputLengthException; @@ -11,60 +12,52 @@ use Lacus\BrUtils\Cnpj\Exceptions\CnpjCheckDigitsTypeError; use TypeError; -final class TestCnpjCheckDigitsTypeError extends CnpjCheckDigitsTypeError -{ - public function __construct() +describe('CnpjCheckDigitsTypeError', function () { + final class TestTypeError extends CnpjCheckDigitsTypeError { - parent::__construct(123, 'number', 'string', 'some error'); } -} - -final class TestCnpjCheckDigitsException extends CnpjCheckDigitsException -{ -} -describe('CnpjCheckDigitsTypeError', function () { describe('when instantiated through a subclass', function () { it('is an instance of TypeError', function () { - $error = new TestCnpjCheckDigitsTypeError(); + $error = new TestTypeError(123, 'number', 'string', 'some error'); expect($error)->toBeInstanceOf(TypeError::class); }); it('is an instance of CnpjCheckDigitsTypeError', function () { - $error = new TestCnpjCheckDigitsTypeError(); + $error = new TestTypeError(123, 'number', 'string', 'some error'); expect($error)->toBeInstanceOf(CnpjCheckDigitsTypeError::class); }); - it('has the correct class name', function () { - $error = new TestCnpjCheckDigitsTypeError(); - - expect($error::class)->toBe(TestCnpjCheckDigitsTypeError::class); - }); - it('sets the `actualInput` property', function () { - $error = new TestCnpjCheckDigitsTypeError(); + $error = new TestTypeError(123, 'number', 'string', 'some error'); expect($error->actualInput)->toBe(123); }); it('sets the `actualType` property', function () { - $error = new TestCnpjCheckDigitsTypeError(); + $error = new TestTypeError(123, 'number', 'string', 'some error'); expect($error->actualType)->toBe('number'); }); it('sets the `expectedType` property', function () { - $error = new TestCnpjCheckDigitsTypeError(); + $error = new TestTypeError(123, 'number', 'string', 'some error'); expect($error->expectedType)->toBe('string'); }); - it('has a `message` property', function () { - $error = new TestCnpjCheckDigitsTypeError(); + it('has the correct message', function () { + $exception = new TestTypeError(123, 'number', 'string', 'some error'); - expect($error->getMessage())->toBe('some error'); + expect($exception->getMessage())->toBe('some error'); + }); + + it('has the correct name', function () { + $exception = new TestTypeError(123, 'number', 'string', 'some error'); + + expect($exception->getName())->toBe('TestTypeError'); }); }); }); @@ -83,17 +76,10 @@ final class TestCnpjCheckDigitsException extends CnpjCheckDigitsException expect($error)->toBeInstanceOf(CnpjCheckDigitsTypeError::class); }); - it('has the correct class name', function () { - $error = new CnpjCheckDigitsInputTypeError(123, 'string'); - - expect($error::class)->toBe(CnpjCheckDigitsInputTypeError::class); - }); - it('sets the `actualInput` property', function () { - $input = 123; - $error = new CnpjCheckDigitsInputTypeError($input, 'string'); + $error = new CnpjCheckDigitsInputTypeError(123, 'string'); - expect($error->actualInput)->toBe($input); + expect($error->actualInput)->toBe(123); }); it('sets the `actualType` property', function () { @@ -108,43 +94,56 @@ final class TestCnpjCheckDigitsException extends CnpjCheckDigitsException expect($error->expectedType)->toBe('string or string[]'); }); - it('generates a message describing the error', function () { + it('has the correct message', function () { $actualInput = 123; $actualType = 'integer number'; $expectedType = 'string[]'; $actualMessage = "CNPJ input must be of type {$expectedType}. Got {$actualType}."; - $error = new CnpjCheckDigitsInputTypeError($actualInput, $expectedType); + $error = new CnpjCheckDigitsInputTypeError( + $actualInput, + $expectedType, + ); expect($error->getMessage())->toBe($actualMessage); }); + + it('has the correct name', function () { + $error = new CnpjCheckDigitsInputTypeError(123, 'string'); + + expect($error->getName())->toBe('CnpjCheckDigitsInputTypeError'); + }); }); }); describe('CnpjCheckDigitsException', function () { + final class TestException extends CnpjCheckDigitsException + { + } + describe('when instantiated through a subclass', function () { it('is an instance of Exception', function () { - $exception = new TestCnpjCheckDigitsException('some error'); + $exception = new TestException('some error'); - expect($exception)->toBeInstanceOf(\Exception::class); + expect($exception)->toBeInstanceOf(Exception::class); }); it('is an instance of CnpjCheckDigitsException', function () { - $exception = new TestCnpjCheckDigitsException('some error'); + $exception = new TestException('some error'); expect($exception)->toBeInstanceOf(CnpjCheckDigitsException::class); }); - it('has the correct class name', function () { - $exception = new TestCnpjCheckDigitsException('some error'); + it('has the correct message', function () { + $exception = new TestException('some exception'); - expect($exception::class)->toBe(TestCnpjCheckDigitsException::class); + expect($exception->getMessage())->toBe('some exception'); }); - it('has a `message` property', function () { - $exception = new TestCnpjCheckDigitsException('some error'); + it('has the correct name', function () { + $exception = new TestException('some error'); - expect($exception->getMessage())->toBe('some error'); + expect($exception->getName())->toBe('TestException'); }); }); }); @@ -154,7 +153,7 @@ final class TestCnpjCheckDigitsException extends CnpjCheckDigitsException it('is an instance of Exception', function () { $exception = new CnpjCheckDigitsInputLengthException('1.2.3.4.5', '12345', 12, 14); - expect($exception)->toBeInstanceOf(\Exception::class); + expect($exception)->toBeInstanceOf(Exception::class); }); it('is an instance of CnpjCheckDigitsException', function () { @@ -163,12 +162,6 @@ final class TestCnpjCheckDigitsException extends CnpjCheckDigitsException expect($exception)->toBeInstanceOf(CnpjCheckDigitsException::class); }); - it('has the correct class name', function () { - $exception = new CnpjCheckDigitsInputLengthException('1.2.3.4.5', '12345', 12, 14); - - expect($exception::class)->toBe(CnpjCheckDigitsInputLengthException::class); - }); - it('sets the `actualInput` property', function () { $exception = new CnpjCheckDigitsInputLengthException('1.2.3.4.5', '12345', 12, 14); @@ -193,7 +186,7 @@ final class TestCnpjCheckDigitsException extends CnpjCheckDigitsException expect($exception->maxExpectedLength)->toBe(14); }); - it('generates a message describing the exception', function () { + it('has the correct message', function () { $actualInput = '1.2.3.4.5'; $evaluatedInput = '12345'; $minExpectedLength = 12; @@ -209,6 +202,12 @@ final class TestCnpjCheckDigitsException extends CnpjCheckDigitsException expect($exception->getMessage())->toBe($actualMessage); }); + + it('has the correct name', function () { + $exception = new CnpjCheckDigitsInputLengthException('1.2.3.4.5', '12345', 12, 14); + + expect($exception->getName())->toBe('CnpjCheckDigitsInputLengthException'); + }); }); }); @@ -217,7 +216,7 @@ final class TestCnpjCheckDigitsException extends CnpjCheckDigitsException it('is an instance of Exception', function () { $exception = new CnpjCheckDigitsInputInvalidException('1.2.3.4.5', 'repeated digits'); - expect($exception)->toBeInstanceOf(\Exception::class); + expect($exception)->toBeInstanceOf(Exception::class); }); it('is an instance of CnpjCheckDigitsException', function () { @@ -226,12 +225,6 @@ final class TestCnpjCheckDigitsException extends CnpjCheckDigitsException expect($exception)->toBeInstanceOf(CnpjCheckDigitsException::class); }); - it('has the correct class name', function () { - $exception = new CnpjCheckDigitsInputInvalidException('1.2.3.4.5', 'repeated digits'); - - expect($exception::class)->toBe(CnpjCheckDigitsInputInvalidException::class); - }); - it('sets the `actualInput` property', function () { $exception = new CnpjCheckDigitsInputInvalidException('1.2.3.4.5', 'repeated digits'); @@ -244,14 +237,23 @@ final class TestCnpjCheckDigitsException extends CnpjCheckDigitsException expect($exception->reason)->toBe('repeated digits'); }); - it('generates a message describing the exception', function () { + it('has the correct message', function () { $actualInput = '1.2.3.4.5'; $reason = 'repeated digits'; $actualMessage = 'CNPJ input "'.$actualInput.'" is invalid. '.$reason; - $exception = new CnpjCheckDigitsInputInvalidException($actualInput, $reason); + $exception = new CnpjCheckDigitsInputInvalidException( + $actualInput, + $reason, + ); expect($exception->getMessage())->toBe($actualMessage); }); + + it('has the correct name', function () { + $exception = new CnpjCheckDigitsInputInvalidException('1.2.3.4.5', 'repeated digits'); + + expect($exception->getName())->toBe('CnpjCheckDigitsInputInvalidException'); + }); }); }); diff --git a/packages/cnpj-fmt/CHANGELOG.md b/packages/cnpj-fmt/CHANGELOG.md new file mode 100644 index 0000000..c8a532d --- /dev/null +++ b/packages/cnpj-fmt/CHANGELOG.md @@ -0,0 +1,49 @@ +# lacus/cnpj-fmt + +## 2.0.0 + +### 🎉 v2 at a glance 🎊 + +- 🆕 **Alphanumeric CNPJ** — Full support for the new [14-character alphanumeric CNPJ](https://www.gov.br/receitafederal/pt-br/assuntos/noticias/2023/julho/cnpj-alfa-numerico) (digits and letters); input is sanitized and uppercased before formatting. +- 🛡️ **Structured errors** — Typed exceptions (`CnpjFormatterTypeError`, `CnpjFormatterException` and their subclasses variants) for clearer error handling. + +### BREAKING CHANGES + +- **Letters no longer stripped** from the input. With the new alphanumeric CNPJ format, letters are kept in the input sanitization and validated on the length of processed data. +- **Namespace**: In the process of normalizing the namespaces of **BR Utils** resources, the package's public API moved from `Lacus\CnpjFmt\` to `Lacus\BrUtils\Cnpj\`. Therefore update `use` statements and autoload expectations for `CnpjFormatter`, `CnpjFormatterOptions`, and `cnpj_fmt` accordingly. +- **Drop support to PHP v8.1**: Minimum version for the package is now **PHP 8.2** (`^8.2`). It may even run forcedly in earlier versions, but it's not recommended to keep running stale versions of PHP in production. +- **Input type**: `CnpjFormatter::format()` and `cnpj_fmt()` accept a **string or a list of strings** (arrays are concatenated). Passing a non-string / non–`string[]` value throws **`CnpjFormatterInputTypeError`**. Prior major version only accepted `string`, so no actual change is really needed in this topic. +- **`onFail` callback** signature is now `Closure(mixed $value, CnpjFormatterException $exception): string`. The default implementation returns an **empty string** on failure; v1 defaulted to returning the **original input string** for invalid length. Length failures are now represented by **`CnpjFormatterInputLengthException`** (not `InvalidArgumentException`). +- **`CnpjFormatterOptions::merge()`** method no longer exists. Now, to create a new version of `CnpjFormatterOptions` merged with other customized options, just construct a instance of the class passing the argument **`overrides`**, which accepts an array of options, with the reference instance and the attributes you want to override. +- **Options of `CnpjFormatterOptions`** are now accessible as properties, instead of getters and setters. +- Migrated tests from PhpUnit to **Pest**. + +### New features + +- **Alphanumeric CNPJ**: Full support for the new alphanumeric CNPJ format (14 characters from `0`–`9` and `A`–`Z` after normalization). +- **`encode` option**: Optional URL encoding of the formatted CNPJ (via `lacus/utils` `UrlUtils::encodeUriComponent`), similar in spirit to `encodeURIComponent`. +- **HTML escaping**: `escape` uses `HtmlUtils::escape` from **`lacus/utils`** instead of `htmlspecialchars` directly. +- **`CnpjFormatter` constructor**: Optional first argument can be a **`CnpjFormatterOptions`** instance (shared by reference), or options can be passed as named parameters; v1 only accepted flat option parameters in a fixed order. +- **`format()` per-call options**: Second argument may be a **`CnpjFormatterOptions`** instance or an associative array, merged with named parameters over instance defaults. +- **Explicit error model**: `CnpjFormatterTypeError` / `CnpjFormatterException` hierarchies and concrete classes (`CnpjFormatterInputTypeError`, `CnpjFormatterOptionsTypeError`, `CnpjFormatterOptionsHiddenRangeInvalidException`, `CnpjFormatterOptionsForbiddenKeyCharacterException`, etc.) for typed errors and clearer handling. +- **`CnpjFormatterOptions::DISALLOWED_KEY_CHARACTERS`**: Reserved characters for `hiddenKey`, `dotKey`, `slashKey`, and `dashKey` (internal masking pipeline). +- **`CnpjFormatterOptions::getDefaultOnFail()`**: Shared default failure callback. + +### Improvements + +- **New PT-BR documentation**: New [README in Brazilian Portuguese](./README.pt.md). + +## 1.0.0 + +### Stable v1 API + +The first major release of **lacus/cnpj-fmt** under namespace **`Lacus\CnpjFmt`**, focused on **numeric** CNPJ formatting (14 digits). + +- **`CnpjFormatter`**: Formats a CNPJ string into the usual `XX.XXX.XXX/XXXX-XX` pattern (with configurable separators). +- **`CnpjFormatterOptions`**: Options for `escape`, `hidden`, `hiddenKey`, `hiddenStart`, `hiddenEnd`, `dotKey`, `slashKey`, `dashKey`, and `onFail`; **`merge()`** for per-call overrides from `format()`. +- **`cnpj_fmt()`**: Helper that instantiates `CnpjFormatter` and calls `format()` with the same option parameters. +- **`CNPJ_LENGTH`**: Global constant `14` in `cnpj-fmt.php` (aligned with `Lacus\CnpjFmt` autoload). +- **Invalid length**: Invoked `onFail` with **`InvalidArgumentException`** as the second argument; default callback returned the **original input string**. +- **Numeric-only input**: Stripped non-digits; required exactly **14 digits** after stripping. +- **PHP**: PHP **≥ 8.1**; no `lacus/utils` requirement. +- **Tests**: PHPUnit with shared test cases trait. diff --git a/packages/cnpj-fmt/README.md b/packages/cnpj-fmt/README.md index 1d1c11a..e6ad0ca 100644 --- a/packages/cnpj-fmt/README.md +++ b/packages/cnpj-fmt/README.md @@ -7,14 +7,30 @@ [![Last Update Date](https://img.shields.io/github/last-commit/LacusSolutions/br-utils-php)](https://github.com/LacusSolutions/br-utils-php) [![Project License](https://img.shields.io/github/license/LacusSolutions/br-utils-php)](https://github.com/LacusSolutions/br-utils-php/blob/main/LICENSE) -Utility function/class to format CNPJ (Brazilian employer ID). +> 🚀 **Full support for the [new alphanumeric CNPJ format](https://github.com/user-attachments/files/23937961/calculodvcnpjalfanaumerico.pdf).** + +> 🌎 [Acessar documentação em português](https://github.com/LacusSolutions/br-utils-php/blob/main/packages/cnpj-fmt/README.pt.md) + +A PHP utility to format CNPJ (Brazilian Business Tax ID). ## PHP Support -| ![PHP 8.1](https://img.shields.io/badge/PHP-8.1-777BB4?logo=php&logoColor=white) | ![PHP 8.2](https://img.shields.io/badge/PHP-8.2-777BB4?logo=php&logoColor=white) | ![PHP 8.3](https://img.shields.io/badge/PHP-8.3-777BB4?logo=php&logoColor=white) | ![PHP 8.4](https://img.shields.io/badge/PHP-8.4-777BB4?logo=php&logoColor=white) | -|--- | --- | --- | --- | +| ![PHP 8.2](https://img.shields.io/badge/PHP-8.2-777BB4?logo=php&logoColor=white) | ![PHP 8.3](https://img.shields.io/badge/PHP-8.3-777BB4?logo=php&logoColor=white) | ![PHP 8.4](https://img.shields.io/badge/PHP-8.4-777BB4?logo=php&logoColor=white) | ![PHP 8.5](https://img.shields.io/badge/PHP-8.5-777BB4?logo=php&logoColor=white) | +| --- | --- | --- | --- | | Passing ✔ | Passing ✔ | Passing ✔ | Passing ✔ | +## Features + +- ✅ **Alphanumeric CNPJ**: Full support for the new alphanumeric CNPJ format (introduced in 2026) +- ✅ **Flexible input**: Accepts `string` or `list`; array elements are concatenated in order +- ✅ **Format agnostic**: Strips non-alphanumeric characters from string input and uppercases letters +- ✅ **Custom delimiters**: `dotKey`, `slashKey`, and `dashKey` may be empty, single- or multi-character strings +- ✅ **Masking**: Optional hiding of a character range with a configurable replacement string (`hidden`, `hiddenKey`, `hiddenStart`, `hiddenEnd`) +- ✅ **HTML & URL output**: Optional `escape` (HTML entities) and `encode` (URI component encoding, similar to JavaScript `encodeURIComponent`) +- ✅ **Length errors without throwing**: Invalid length after sanitization is handled via `onFail` (default returns an empty string) +- ✅ **Minimal dependencies**: Only [`lacus/utils`](https://packagist.org/packages/lacus/utils) +- ✅ **Error handling**: Type errors for wrong API use; option validation via dedicated exceptions + ## Installation ```bash @@ -26,75 +42,154 @@ $ composer require lacus/cnpj-fmt ```php format('03603568000195'); // '03.603.568/0001-95' +$formatter->format('12ABC34500DE99'); // '12.ABC.345/00DE-99' ``` ## Usage -### Object-Oriented Usage +The main entry points are the class `CnpjFormatter`, the options value object `CnpjFormatterOptions`, and the helper `cnpj_fmt()`. + +### `CnpjFormatter` + +- **`__construct`**: Optional default formatting options. The first parameter may be `null` or a `CnpjFormatterOptions` instance (that exact instance is stored; mutating it later affects subsequent `format` calls that do not pass per-call options). You may also pass option fields as named parameters (`hidden`, `hiddenKey`, `dotKey`, …). If the first argument is not a `CnpjFormatterOptions` instance, a new `CnpjFormatterOptions` is built from those named values. Example: `new CnpjFormatter(hidden: true, slashKey: '|')`. +- **`getOptions()`**: Returns the instance’s `CnpjFormatterOptions` (same object as used internally). +- **`format`**: `format(string|list $cnpjInput, ?CnpjFormatterOptions|array $options, …named options…): string` + + Input is normalized by removing non-alphanumeric characters and uppercasing. If the sanitized length is not exactly **14**, the **`onFail`** callback is invoked with the original input and a `CnpjFormatterInputLengthException`; its return value is the result (nothing is thrown for length). + + If the input is not a `string` or a `list` of strings, **`CnpjFormatterInputTypeError`** is thrown. + + Per-call options are merged over the instance defaults for that call only (instance defaults are unchanged). You can pass a `CnpjFormatterOptions` instance or an associative array as the second argument, in addition to named parameters; later overrides win. + +### `CnpjFormatterOptions` + +Holds all formatter settings. Construct with named parameters, optional `overrides` (list of arrays and/or other `CnpjFormatterOptions` instances, merged in order). Exposes properties via magic `__get` / `__set` (`hidden`, `hiddenKey`, `hiddenStart`, `hiddenEnd`, `dotKey`, `slashKey`, `dashKey`, `escape`, `encode`, `onFail`). + +- **`getAll()`**: Returns a shallow array snapshot of all options. +- **`set(...)`**: Updates multiple fields at once; returns `$this`. +- **`setHiddenRange(?int $hiddenStart, ?int $hiddenEnd)`**: Validates indices in **`[0, 13]`** (inclusive); if `hiddenStart > hiddenEnd`, values are swapped. `null` arguments fall back to defaults (`DEFAULT_HIDDEN_START` / `DEFAULT_HIDDEN_END`). +- **`getDefaultOnFail()`**: Returns the package default `onFail` closure (returns `''` for invalid length). + +**`hiddenStart` / `hiddenEnd`**: Indices refer to the **14-character normalized CNPJ string** (before inserting punctuation). The inclusive range is replaced internally by placeholders, then `hiddenKey` is substituted (supports multi-character keys and empty string). + +**Key options** (`hiddenKey`, `dotKey`, `slashKey`, `dashKey`): Must be strings and must not contain any character in `CnpjFormatterOptions::DISALLOWED_KEY_CHARACTERS` (reserved for internal formatting). + +### Functional helper + +`cnpj_fmt()` builds a new `CnpjFormatter` from the same constructor parameters (starting at the optional options / named args) and calls `format($cnpjInput)` once. Use named arguments for options: e.g. `cnpj_fmt($cnpj, hidden: true)` or `cnpj_fmt($cnpj, slashKey: '|')`. ```php -$formatter = new CnpjFormatter(); $cnpj = '03603568000195'; -echo $formatter->format($cnpj); // returns '03.603.568/0001-95' +cnpj_fmt($cnpj); // '03.603.568/0001-95' +cnpj_fmt($cnpj, hidden: true); // masked with defaults +cnpj_fmt( // '03603568|0001_95' + $cnpj, + dotKey: '', + slashKey: '|', + dashKey: '_', +); +``` + +### Object-oriented examples -// With options -echo $formatter->format( +```php +$formatter = new CnpjFormatter(); +$cnpj = '03603568000195'; + +$formatter->format($cnpj); // '03.603.568/0001-95' +$formatter->format( // '03.603.###/####-##' $cnpj, hidden: true, hiddenKey: '#', hiddenStart: 5, hiddenEnd: 13 -); // returns '03.603.###/####-##' +); ``` -The options can be provided to the constructor or the `format()` method. If passed to the constructor, the options will be attached to the `CnpjFormatter` instance. When passed to the `format()` method, it only applies the options to that specific call. +Default options on the instance; per-call overrides: ```php -$cnpj = '03603568000195'; $formatter = new CnpjFormatter(hidden: true); -echo $formatter->format($cnpj); // '03.603.***/****-**' -echo $formatter->format($cnpj, hidden: false); // '03.603.568/0001-95' merges the options to the instance's -echo $formatter->format($cnpj); // '03.603.***/****-**' uses only the instance options +$formatter->format($cnpj); // uses instance masking +$formatter->format($cnpj, hidden: false); // this call only: unmasked +$formatter->format($cnpj); // back to instance defaults ``` -### Imperative programming +### Input formats -The helper function `cnpj_fmt()` is just a functional abstraction. Internally it creates an instance of `CnpjFormatter` and calls the `format()` method right away. +**String:** Raw digits and/or letters, or already formatted CNPJ (e.g. `12.345.678/0009-10`, `12.ABC.345/00DE-99`). Non-alphanumeric characters are removed; lowercase letters are uppercased. -```php -$cnpj = '03603568000195'; +**Array of strings:** Each element must be a string; values are concatenated (e.g. per digit, grouped segments, or mixed with punctuation — all are stripped during normalization). Non-string elements are not allowed. + +### Formatting options + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `hidden` | `?bool` | `false` | When `true`, replaces the inclusive index range `[hiddenStart, hiddenEnd]` on the normalized 14-character string before punctuation is applied | +| `hiddenKey` | `?string` | `'*'` | Replacement for each hidden position (may be multi-character or empty); must not use disallowed key characters | +| `hiddenStart` | `?int` | `5` | Start index `0`–`13` (inclusive) | +| `hiddenEnd` | `?int` | `13` | End index `0`–`13` (inclusive); if `hiddenStart > hiddenEnd`, they are swapped | +| `dotKey` | `?string` | `'.'` | Separator between groups `XX` / `XXX` / `XXX` | +| `slashKey` | `?string` | `'/'` | Separator before the branch block | +| `dashKey` | `?string` | `'-'` | Separator before the last two characters | +| `escape` | `?bool` | `false` | When `true`, HTML-escapes the final string (`HtmlUtils::escape`) | +| `encode` | `?bool` | `false` | When `true`, URL-encodes the final string (`UrlUtils::encodeUriComponent`) | +| `onFail` | `?\Closure` | see below | `Closure(mixed $value, CnpjFormatterException $e): string` — used when sanitized length ≠ 14 | -echo cnpj_fmt($cnpj); // returns '03.603.568/0001-95' +Default **`onFail`** returns an empty string. The exception passed for length failures is **`CnpjFormatterInputLengthException`** (`actualInput`, `evaluatedInput`, `expectedLength`). -echo cnpj_fmt($cnpj, hidden: true); // returns '03.603.***/****-**' +### Errors & exceptions -echo cnpj_fmt($cnpj, dotKey: '', slashKey: '|', dashKey: '_'); // returns '03603568|0001_95' +- **Wrong input type** (not `string` or `list`): **`CnpjFormatterInputTypeError`** — extends **`CnpjFormatterTypeError`** (extends PHP `TypeError`). +- **Invalid option types or values when constructing or merging options**: **`CnpjFormatterOptionsTypeError`**, **`CnpjFormatterOptionsHiddenRangeInvalidException`**, **`CnpjFormatterOptionsForbiddenKeyCharacterException`** — extend **`CnpjFormatterTypeError`** or **`CnpjFormatterException`** as appropriate. + +Length mismatch does **not** throw from `format()`; handle it inside **`onFail`**. + +```php +use Lacus\BrUtils\Cnpj\CnpjFormatter; +use Lacus\BrUtils\Cnpj\Exceptions\CnpjFormatterInputLengthException; +use Lacus\BrUtils\Cnpj\Exceptions\CnpjFormatterInputTypeError; + +try { + (new CnpjFormatter())->format(12345); +} catch (CnpjFormatterInputTypeError $e) { + echo $e->getMessage(); +} + +$out = (new CnpjFormatter())->format( + 'short', + onFail: static fn ($value, CnpjFormatterInputLengthException $e) => 'invalid' +); ``` -### Formatting Options +### Other available resources -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `escape` | `?bool` | `false` | Whether to HTML escape the result | -| `hidden` | `?bool` | `false` | Whether to hide digits with a mask | -| `hiddenKey` | `?string` | `'*'` | Character to replace hidden digits | -| `hiddenStart` | `?int` | `5` | Starting index for hidden range (0-13) | -| `hiddenEnd` | `?int` | `13` | Ending index for hidden range (0-13) | -| `dotKey` | `?string` | `'.'` | String to replace dot characters | -| `slashKey` | `?string` | `'/'` | String to replace slash character | -| `dashKey` | `?string` | `'-'` | String to replace dash character | -| `onFail` | `?callable` | `fn($v) => $v` | Fallback function for invalid input | +- **`CNPJ_LENGTH`**: `14` — `CnpjFormatterOptions::CNPJ_LENGTH`, and global `Lacus\BrUtils\Cnpj\CNPJ_LENGTH` when the autoloaded `cnpj-fmt.php` file is loaded. +- **`CnpjFormatterOptions::DISALLOWED_KEY_CHARACTERS`**: Characters forbidden in `hiddenKey`, `dotKey`, `slashKey`, `dashKey`. +- **`CnpjFormatterOptions::getDefaultOnFail()`**: Shared default failure callback. ## Contribution & Support -We welcome contributions! Please see our [Contributing Guidelines](https://github.com/LacusSolutions/br-utils-php/blob/main/CONTRIBUTING.md) for details. But if you find this project helpful, please consider: +We welcome contributions! Please see our [Contributing Guidelines](https://github.com/LacusSolutions/br-utils-php/blob/main/CONTRIBUTING.md) for details. If you find this project helpful, please consider: - ⭐ Starring the repository - 🤝 Contributing to the codebase @@ -103,7 +198,7 @@ We welcome contributions! Please see our [Contributing Guidelines](https://githu ## License -This project is licensed under the MIT License - see the [LICENSE](https://github.com/LacusSolutions/br-utils-php/blob/main/LICENSE) file for details. +This project is licensed under the MIT License — see the [LICENSE](https://github.com/LacusSolutions/br-utils-php/blob/main/LICENSE) file for details. ## Changelog diff --git a/packages/cnpj-fmt/README.pt.md b/packages/cnpj-fmt/README.pt.md new file mode 100644 index 0000000..f25d8f0 --- /dev/null +++ b/packages/cnpj-fmt/README.pt.md @@ -0,0 +1,198 @@ +![cnpj-fmt para PHP](https://br-utils.vercel.app/img/cover_cnpj-fmt.jpg) + +> 🚀 **Suporte total ao [novo formato alfanumérico de CNPJ](https://github.com/user-attachments/files/23937961/calculodvcnpjalfanaumerico.pdf).** + +> 🌎 [Access documentation in English](https://github.com/LacusSolutions/br-utils-php/blob/main/packages/cnpj-fmt/README.md) + +Utilitário em PHP para formatar CNPJ (Cadastro Nacional da Pessoa Jurídica) como valor alfanumérico de 14 caracteres, com opções de máscara, escape HTML e codificação para URL. + +## Recursos + +- ✅ **CNPJ alfanumérico**: Suporte completo ao novo formato alfanumérico de CNPJ (a partir de 2026) +- ✅ **Entrada flexível**: Aceita `string` ou `list`; elementos do array são concatenados na ordem +- ✅ **Agnóstico ao formato**: Remove caracteres não alfanuméricos da entrada em string e converte letras para maiúsculas +- ✅ **Delimitadores personalizáveis**: `dotKey`, `slashKey` e `dashKey` podem ser vazios ou strings de um ou vários caracteres +- ✅ **Mascaramento**: Ocultação opcional de um intervalo de índices com string de substituição configurável (`hidden`, `hiddenKey`, `hiddenStart`, `hiddenEnd`) +- ✅ **Saída HTML e URL**: `escape` opcional (entidades HTML) e `encode` opcional (codificação tipo componente de URI, semelhante ao `encodeURIComponent` do JavaScript) +- ✅ **Erro de tamanho sem exceção**: Comprimento inválido após sanitização é tratado via `onFail` (o padrão retorna string vazia) +- ✅ **Dependências mínimas**: Apenas [`lacus/utils`](https://packagist.org/packages/lacus/utils) +- ✅ **Tratamento de erros**: Erros de tipo para uso incorreto da API; validação de opções com exceções específicas + +## Instalação + +```bash +# usando Composer +$ composer require lacus/cnpj-fmt +``` + +## Importação + +```php +format('03603568000195'); // '03.603.568/0001-95' +$formatter->format('12ABC34500DE99'); // '12.ABC.345/00DE-99' +``` + +## Utilização + +Os pontos principais são a classe `CnpjFormatter`, o objeto de valor `CnpjFormatterOptions` e o helper `cnpj_fmt()`. + +### `CnpjFormatter` + +- **`__construct`**: Opções padrão de formatação. O primeiro parâmetro pode ser `null` ou uma instância de `CnpjFormatterOptions` (essa instância é armazenada; alterações posteriores afetam chamadas a `format` que não passarem opções por chamada). Também é possível passar campos como argumentos nomeados (`hidden`, `hiddenKey`, `dotKey`, …). Se o primeiro argumento não for uma instância de `CnpjFormatterOptions`, é criado um novo `CnpjFormatterOptions` a partir desses valores nomeados. Exemplo: `new CnpjFormatter(hidden: true, slashKey: '|')`. +- **`getOptions()`**: Retorna o `CnpjFormatterOptions` da instância (o mesmo objeto usado internamente). +- **`format`**: `format(string|list $cnpjInput, ?CnpjFormatterOptions|array $options, …opções nomeadas…): string` + + A entrada é normalizada removendo caracteres não alfanuméricos e convertendo para maiúsculas. Se o comprimento após sanitização não for exatamente **14**, o callback **`onFail`** é chamado com a entrada original e uma `CnpjFormatterInputLengthException`; o valor de retorno do callback é o resultado (nada é lançado por comprimento). + + Se a entrada não for `string` nem `list` de strings, é lançada **`CnpjFormatterInputTypeError`**. + + As opções por chamada são mescladas sobre os padrões da instância apenas naquela chamada (os padrões da instância não mudam). É possível passar uma instância de `CnpjFormatterOptions` ou um array associativo como segundo argumento, além de parâmetros nomeados; sobrescritas posteriores prevalecem. + +### `CnpjFormatterOptions` + +Armazena todas as configurações do formatador. Construa com parâmetros nomeados e `overrides` opcional (lista de arrays e/ou outras instâncias de `CnpjFormatterOptions`, mescladas em ordem). Expõe propriedades via `__get` / `__set` mágicos (`hidden`, `hiddenKey`, `hiddenStart`, `hiddenEnd`, `dotKey`, `slashKey`, `dashKey`, `escape`, `encode`, `onFail`). + +- **`getAll()`**: Retorna um array superficial com todas as opções. +- **`set(...)`**: Atualiza vários campos de uma vez; retorna `$this`. +- **`setHiddenRange(?int $hiddenStart, ?int $hiddenEnd)`**: Valida índices em **`[0, 13]`** (inclusivos); se `hiddenStart > hiddenEnd`, os valores são trocados. Argumentos `null` usam os padrões (`DEFAULT_HIDDEN_START` / `DEFAULT_HIDDEN_END`). +- **`getDefaultOnFail()`**: Retorna o closure padrão de `onFail` do pacote (retorna `''` para comprimento inválido). + +**`hiddenStart` / `hiddenEnd`**: Os índices referem-se à **string CNPJ normalizada de 14 caracteres** (antes de inserir pontuação). O intervalo inclusivo é substituído internamente por placeholders e depois por `hiddenKey` (permite chaves com vários caracteres ou string vazia). + +**Opções de chave** (`hiddenKey`, `dotKey`, `slashKey`, `dashKey`): Devem ser strings e não podem conter caracteres em `CnpjFormatterOptions::DISALLOWED_KEY_CHARACTERS` (reservados para a lógica interna). + +### Helper funcional + +`cnpj_fmt()` instancia um novo `CnpjFormatter` com os mesmos parâmetros do construtor (a partir das opções opcionais / argumentos nomeados) e chama `format($cnpjInput)` uma vez. Use argumentos nomeados para as opções: por exemplo `cnpj_fmt($cnpj, hidden: true)` ou `cnpj_fmt($cnpj, slashKey: '|')`. + +```php +$cnpj = '03603568000195'; + +cnpj_fmt($cnpj); // '03.603.568/0001-95' +cnpj_fmt($cnpj, hidden: true); // mascarado com padrões +cnpj_fmt( // '03603568|0001_95' + $cnpj, + dotKey: '', + slashKey: '|', + dashKey: '_', +); +``` + +### Exemplos orientados a objeto + +```php +$formatter = new CnpjFormatter(); +$cnpj = '03603568000195'; + +$formatter->format($cnpj); // '03.603.568/0001-95' +$formatter->format( // '03.603.###/####-##' + $cnpj, + hidden: true, + hiddenKey: '#', + hiddenStart: 5, + hiddenEnd: 13, +); +``` + +Opções padrão na instância; sobrescritas por chamada: + +```php +$formatter = new CnpjFormatter(hidden: true); + +$formatter->format($cnpj); // usa mascaramento da instância +$formatter->format($cnpj, hidden: false); // só esta chamada: sem máscara +$formatter->format($cnpj); // volta aos padrões da instância +``` + +### Formatos de entrada + +**String:** dígitos e/ou letras crus, ou CNPJ já formatado (ex.: `12.345.678/0009-10`, `12.ABC.345/00DE-99`). Caracteres não alfanuméricos são removidos; letras minúsculas viram maiúsculas. + +**Array de strings:** cada elemento deve ser string; os valores são concatenados (por dígito, grupos ou misturados com pontuação — tudo é removido na normalização). Elementos que não são strings não são permitidos. + +### Opções de formatação + +| Parâmetro | Tipo | Padrão | Descrição | +|-----------|------|--------|-----------| +| `hidden` | `?bool` | `false` | Se `true`, substitui o intervalo inclusivo `[hiddenStart, hiddenEnd]` na string normalizada de 14 caracteres antes de aplicar a pontuação | +| `hiddenKey` | `?string` | `'*'` | Substituição para cada posição oculta (pode ser multi-caractere ou vazia); não pode usar caracteres de chave proibidos | +| `hiddenStart` | `?int` | `5` | Índice inicial `0`–`13` (inclusivo) | +| `hiddenEnd` | `?int` | `13` | Índice final `0`–`13` (inclusivo); se `hiddenStart > hiddenEnd`, são trocados | +| `dotKey` | `?string` | `'.'` | Separador entre os grupos `XX` / `XXX` / `XXX` | +| `slashKey` | `?string` | `'/'` | Separador antes do bloco da filial | +| `dashKey` | `?string` | `'-'` | Separador antes dos dois últimos caracteres | +| `escape` | `?bool` | `false` | Se `true`, aplica escape HTML na string final (`HtmlUtils::escape`) | +| `encode` | `?bool` | `false` | Se `true`, codifica a string final para URL (`UrlUtils::encodeUriComponent`) | +| `onFail` | `?\Closure` | ver abaixo | `Closure(mixed $value, CnpjFormatterException $e): string` — usado quando o comprimento sanitizado ≠ 14 | + +O **`onFail`** padrão retorna string vazia. A exceção passada em falhas de comprimento é **`CnpjFormatterInputLengthException`** (`actualInput`, `evaluatedInput`, `expectedLength`). + +### Erros e exceções + +- **Tipo de entrada incorreto** (não é `string` nem `list`): **`CnpjFormatterInputTypeError`** — estende **`CnpjFormatterTypeError`** (estende `TypeError` do PHP). +- **Tipos ou valores de opção inválidos ao construir ou mesclar opções**: **`CnpjFormatterOptionsTypeError`**, **`CnpjFormatterOptionsHiddenRangeInvalidException`**, **`CnpjFormatterOptionsForbiddenKeyCharacterException`** — estendem **`CnpjFormatterTypeError`** ou **`CnpjFormatterException`** conforme o caso. + +Incompatibilidade de comprimento **não** é lançada por `format()`; trate em **`onFail`**. + +```php +use Lacus\BrUtils\Cnpj\CnpjFormatter; +use Lacus\BrUtils\Cnpj\Exceptions\CnpjFormatterInputLengthException; +use Lacus\BrUtils\Cnpj\Exceptions\CnpjFormatterInputTypeError; + +try { + (new CnpjFormatter())->format(12345); +} catch (CnpjFormatterInputTypeError $e) { + // entrada deve ser string ou string[] +} + +$out = (new CnpjFormatter())->format( + 'short', + onFail: static fn ($value, CnpjFormatterInputLengthException $e) => 'invalid' +); +``` + +### Outros recursos disponíveis + +- **`CNPJ_LENGTH`**: `14` — `CnpjFormatterOptions::CNPJ_LENGTH`, e constante global `Lacus\BrUtils\Cnpj\CNPJ_LENGTH` quando `cnpj-fmt.php` é carregado pelo autoload do Composer. +- **`CnpjFormatterOptions::DISALLOWED_KEY_CHARACTERS`**: Caracteres proibidos em `hiddenKey`, `dotKey`, `slashKey`, `dashKey`. +- **`CnpjFormatterOptions::getDefaultOnFail()`**: Callback padrão de falha compartilhado. + +## Contribuição e suporte + +Contribuições são bem-vindas! Consulte as [Diretrizes de contribuição](https://github.com/LacusSolutions/br-utils-php/blob/main/CONTRIBUTING.md). Se o projeto for útil para você, considere: + +- ⭐ Dar uma estrela no repositório +- 🤝 Contribuir com código +- 💡 [Sugerir novas funcionalidades](https://github.com/LacusSolutions/br-utils-php/issues) +- 🐛 [Reportar bugs](https://github.com/LacusSolutions/br-utils-php/issues) + +## Licença + +Este projeto está sob a licença MIT — veja o arquivo [LICENSE](https://github.com/LacusSolutions/br-utils-php/blob/main/LICENSE). + +## Changelog + +Veja o [CHANGELOG](https://github.com/LacusSolutions/br-utils-php/blob/main/packages/cnpj-fmt/CHANGELOG.md) para alterações e histórico de versões. + +--- + +Feito com ❤️ por [Lacus Solutions](https://github.com/LacusSolutions) diff --git a/packages/cnpj-fmt/composer.json b/packages/cnpj-fmt/composer.json index 24aa929..590c655 100644 --- a/packages/cnpj-fmt/composer.json +++ b/packages/cnpj-fmt/composer.json @@ -1,7 +1,7 @@ { "name": "lacus/cnpj-fmt", "type": "library", - "description": "Utility function to format CNPJ (Brazilian employer ID)", + "description": "Utility to format CNPJ (Brazilian Business Tax ID)", "license": "MIT", "authors": [ { @@ -26,23 +26,26 @@ }, "homepage": "https://cnpj-utils.vercel.app/", "scripts": { - "test": "phpunit", - "test:watch": "phpunit-watcher watch", - "test-coverage": "phpunit --coverage-html coverage" + "test": "pest", + "test:watch": "pest --watch", + "test-coverage": "pest --coverage-html coverage" }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true + } }, "require": { - "php": ">=8.1" + "php": "^8.2", + "lacus/utils": "^1.0" }, "require-dev": { - "phpunit/phpunit": "^10.5", - "spatie/phpunit-watcher": "~1.24" + "pestphp/pest": "^3.8" }, "autoload": { "psr-4": { - "Lacus\\CnpjFmt\\": "src/" + "Lacus\\BrUtils\\Cnpj\\": "src/" }, "files": [ "src/cnpj-fmt.php" @@ -50,7 +53,7 @@ }, "autoload-dev": { "psr-4": { - "Lacus\\CnpjFmt\\Tests\\": "tests/" + "Lacus\\BrUtils\\Cnpj\\Tests\\": "tests/" } } } diff --git a/packages/cnpj-fmt/phpunit.xml b/packages/cnpj-fmt/phpunit.xml index 933db21..52553e5 100644 --- a/packages/cnpj-fmt/phpunit.xml +++ b/packages/cnpj-fmt/phpunit.xml @@ -12,7 +12,7 @@ > - tests/ + tests/ @@ -28,6 +28,6 @@ - + diff --git a/packages/cnpj-fmt/src/CnpjFormatter.php b/packages/cnpj-fmt/src/CnpjFormatter.php index 2bdde9a..abaf9b6 100644 --- a/packages/cnpj-fmt/src/CnpjFormatter.php +++ b/packages/cnpj-fmt/src/CnpjFormatter.php @@ -2,106 +2,260 @@ declare(strict_types=1); -namespace Lacus\CnpjFmt; +namespace Lacus\BrUtils\Cnpj; use Closure; -use InvalidArgumentException; +use Lacus\BrUtils\Cnpj\Exceptions\CnpjFormatterException; +use Lacus\BrUtils\Cnpj\Exceptions\CnpjFormatterInputLengthException; +use Lacus\BrUtils\Cnpj\Exceptions\CnpjFormatterInputTypeError; +use Lacus\BrUtils\Cnpj\Exceptions\CnpjFormatterOptionsForbiddenKeyCharacterException; +use Lacus\BrUtils\Cnpj\Exceptions\CnpjFormatterOptionsHiddenRangeInvalidException; +use Lacus\BrUtils\Cnpj\Exceptions\CnpjFormatterOptionsTypeError; +use Lacus\Utils\HtmlUtils; +use Lacus\Utils\UrlUtils; +/** + * Formatter for CNPJ (Cadastro Nacional da Pessoa Jurídica) identifiers. It + * normalizes and optionally masks, HTML-escapes, or URL-encodes 14-character + * alphanumeric CNPJ input. Accepts a string or array of strings; + * non-alphanumeric characters are stripped and the result is uppercased. + * Invalid input type is handled by throwing; invalid length is handled via the + * configured `onFail` callback instead of throwing. + */ class CnpjFormatter { - private CnpjFormatterOptions $options; + /** + * A rarely-used 1-length character that is replaced with `hiddenKey` when + * `hidden` is `true`. + */ + private const HIDDEN_KEY_PLACEHOLDER = CnpjFormatterOptions::DISALLOWED_KEY_CHARACTERS[0]; + /** + * The default options used by this formatter instance. + */ + private readonly CnpjFormatterOptions $options; + + /** + * Creates a new `CnpjFormatter` with optional default options. + * + * Default options apply to every call to `format` unless overridden by the + * per-call `options` argument. Options control masking, HTML escaping, URL + * encoding, and the callback used when formatting fails. + * + * When `defaultOptions` is a `CnpjFormatterOptions` instance, that instance + * is used directly (no copy is created). Mutating it later (e.g. via the + * `getOptions` return value or the original reference) affects future `format` calls + * that do not pass per-call options. When a plain array or nothing is + * passed, a new `CnpjFormatterOptions` instance is created from it. + * + * @param ?CnpjFormatterOptions $options + * @param ?bool $hidden + * @param ?string $hiddenKey + * @param ?int $hiddenStart + * @param ?int $hiddenEnd + * @param ?string $dotKey + * @param ?string $slashKey + * @param ?string $dashKey + * @param ?bool $escape + * @param ?bool $encode + * @param ?Closure(mixed, CnpjFormatterException): string $onFail + * + * @throws CnpjFormatterOptionsTypeError If any option has an invalid type. + * @throws CnpjFormatterOptionsHiddenRangeInvalidException If `hiddenStart` + * or `hiddenEnd` are out of valid range. + * @throws CnpjFormatterOptionsForbiddenKeyCharacterException If any key + * option contains a disallowed character. + */ public function __construct( - ?bool $escape = null, - ?bool $hidden = null, - ?string $hiddenKey = null, - ?int $hiddenStart = null, - ?int $hiddenEnd = null, - ?string $dotKey = null, - ?string $slashKey = null, - ?string $dashKey = null, - ?Closure $onFail = null, + ?CnpjFormatterOptions $options = null, + $hidden = null, + $hiddenKey = null, + $hiddenStart = null, + $hiddenEnd = null, + $dotKey = null, + $slashKey = null, + $dashKey = null, + $escape = null, + $encode = null, + $onFail = null, ) { - $this->options = new CnpjFormatterOptions( - $escape, - $hidden, - $hiddenKey, - $hiddenStart, - $hiddenEnd, - $dotKey, - $slashKey, - $dashKey, - $onFail, - ); + $this->options = $options instanceof CnpjFormatterOptions + ? $options + : new CnpjFormatterOptions( + hidden: $hidden, + hiddenKey: $hiddenKey, + hiddenStart: $hiddenStart, + hiddenEnd: $hiddenEnd, + dotKey: $dotKey, + slashKey: $slashKey, + dashKey: $dashKey, + escape: $escape, + encode: $encode, + onFail: $onFail, + overrides: [$options], + ); + } + + /** + * Returns the default options used by this formatter when per-call options + * are not provided. + * + * The returned object is the same instance used internally; mutating it (e.g. + * via setters on `CnpjFormatterOptions`) affects future `format` calls that + * do not pass `options`. + */ + public function getOptions(): CnpjFormatterOptions + { + return $this->options; } + /** + * Formats a CNPJ value into a normalized 14-character alphanumeric string. + * + * Input is normalized by stripping non-alphanumeric characters and converting + * to uppercase. If the result length is not exactly 14, or if the input is + * not a string or array of strings, the configured `onFail` callback is + * invoked with the original value and an error; its return value is used as + * the result. + * + * When valid, the result may be further transformed according to options: + * + * - If `hidden` is `true`, characters between `hiddenStart` and `hiddenEnd` + * (inclusive) are replaced with `hiddenKey`. + * - If `escape` is `true`, HTML special characters are escaped. + * - If `encode` is `true`, the string is passed through URL encoding (similar to + * JavaScript's `encodeURIComponent`). + * + * Per-call `options` are merged over the instance default options for this + * call only; the instance defaults are unchanged. + * + * @param string|list $cnpjInput + * @param ?CnpjFormatterOptions $options + * @param ?bool $hidden + * @param ?string $hiddenKey + * @param ?int $hiddenStart + * @param ?int $hiddenEnd + * @param ?string $dotKey + * @param ?string $slashKey + * @param ?string $dashKey + * @param ?bool $escape + * @param ?bool $encode + * @param ?Closure(mixed, CnpjFormatterException): string $onFail + * + * @throws CnpjFormatterOptionsTypeError If any option has an invalid type. + * @throws CnpjFormatterOptionsHiddenRangeInvalidException If `hiddenStart` + * or `hiddenEnd` are out of valid range. + * @throws CnpjFormatterOptionsForbiddenKeyCharacterException If any key + * option contains a disallowed character. + * @throws CnpjFormatterInputTypeError If the input is not a string or array of strings. + */ public function format( - string $cnpjString, - ?bool $escape = null, - ?bool $hidden = null, - ?string $hiddenKey = null, - ?int $hiddenStart = null, - ?int $hiddenEnd = null, - ?string $dotKey = null, - ?string $slashKey = null, - ?string $dashKey = null, - ?Closure $onFail = null, + $cnpjInput, + $options = null, + $hidden = null, + $hiddenKey = null, + $hiddenStart = null, + $hiddenEnd = null, + $dotKey = null, + $slashKey = null, + $dashKey = null, + $escape = null, + $encode = null, + $onFail = null, ): string { - $actualOptions = $this->getOptions()->merge( - $escape, - $hidden, - $hiddenKey, - $hiddenStart, - $hiddenEnd, - $dotKey, - $slashKey, - $dashKey, - $onFail, + $actualInput = $this->toStringInput($cnpjInput); + $actualOptions = new CnpjFormatterOptions( + ...$this->options->getAll(), + overrides: [ + [ + 'hidden' => $hidden, + 'hiddenKey' => $hiddenKey, + 'hiddenStart' => $hiddenStart, + 'hiddenEnd' => $hiddenEnd, + 'dotKey' => $dotKey, + 'slashKey' => $slashKey, + 'dashKey' => $dashKey, + 'escape' => $escape, + 'encode' => $encode, + 'onFail' => $onFail, + ], + $options ?? [], + ], ); - $cnpjNumbersString = preg_replace('/[^0-9]/', '', $cnpjString) ?? ''; - $cnpjNumbersArray = str_split($cnpjNumbersString); + $alphanumericOnly = preg_replace('/[^0-9A-Za-z]/', '', $actualInput) ?? ''; + $formattedCnpj = strtoupper($alphanumericOnly); - if (count($cnpjNumbersArray) !== CNPJ_LENGTH) { - $error = new InvalidArgumentException( - "Parameter \"{$cnpjString}\" does not contain " - . CNPJ_LENGTH - . " digits." + if (mb_strlen($formattedCnpj, 'UTF-8') !== CnpjFormatterOptions::CNPJ_LENGTH) { + $exception = new CnpjFormatterInputLengthException( + $cnpjInput, + $formattedCnpj, + CnpjFormatterOptions::CNPJ_LENGTH, ); - return $actualOptions->getOnFail()($cnpjString, $error); + return ($actualOptions->onFail)($cnpjInput, $exception); } - if ($actualOptions->isHidden()) { - $hiddenStart = $actualOptions->getHiddenStart(); - $hiddenEnd = $actualOptions->getHiddenEnd(); - $hiddenKey = $actualOptions->getHiddenKey(); + if ($actualOptions->hidden) { + $startingPart = mb_substr($formattedCnpj, 0, $actualOptions->hiddenStart, 'UTF-8'); + $endingPart = mb_substr($formattedCnpj, $actualOptions->hiddenEnd + 1, null, 'UTF-8'); + $hiddenPartLength = $actualOptions->hiddenEnd - $actualOptions->hiddenStart + 1; + $hiddenPart = str_repeat(self::HIDDEN_KEY_PLACEHOLDER, $hiddenPartLength); - for ($i = $hiddenStart; $i <= $hiddenEnd; $i++) { - $cnpjNumbersArray[$i] = $hiddenKey; - } + $formattedCnpj = $startingPart . $hiddenPart . $endingPart; } - $dotKey = $actualOptions->getDotKey(); - $dashKey = $actualOptions->getDashKey(); - $slashKey = $actualOptions->getSlashKey(); - - array_splice($cnpjNumbersArray, 12, 0, $dashKey); - array_splice($cnpjNumbersArray, 8, 0, $slashKey); - array_splice($cnpjNumbersArray, 5, 0, $dotKey); - array_splice($cnpjNumbersArray, 2, 0, $dotKey); + $formattedCnpj = + mb_substr($formattedCnpj, 0, 2, 'UTF-8') + . $actualOptions->dotKey + . mb_substr($formattedCnpj, 2, 3, 'UTF-8') + . $actualOptions->dotKey + . mb_substr($formattedCnpj, 5, 3, 'UTF-8') + . $actualOptions->slashKey + . mb_substr($formattedCnpj, 8, 4, 'UTF-8') + . $actualOptions->dashKey + . mb_substr($formattedCnpj, 12, 2, 'UTF-8'); + $formattedCnpj = str_replace( + self::HIDDEN_KEY_PLACEHOLDER, + $actualOptions->hiddenKey, + $formattedCnpj, + ); - $prettyCnpj = implode('', $cnpjNumbersArray); + if ($actualOptions->escape) { + $formattedCnpj = HtmlUtils::escape($formattedCnpj); + } - if ($actualOptions->isEscaped()) { - return htmlspecialchars($prettyCnpj, ENT_QUOTES, 'UTF-8'); + if ($actualOptions->encode) { + $formattedCnpj = UrlUtils::encodeUriComponent($formattedCnpj); } - return $prettyCnpj; + return $formattedCnpj; } - public function getOptions(): CnpjFormatterOptions + /** + * Normalizes the input to a string. + * + * @param mixed $cnpjInput + * + * @throws CnpjFormatterInputTypeError If the input is not a string or array + * of strings. + */ + private function toStringInput(mixed $cnpjInput): string { - return $this->options; + if (is_string($cnpjInput)) { + return $cnpjInput; + } + + if (is_array($cnpjInput)) { + foreach ($cnpjInput as $item) { + if (!is_string($item)) { + throw new CnpjFormatterInputTypeError($cnpjInput, 'string or string[]'); + } + } + + return implode('', $cnpjInput); + } + + throw new CnpjFormatterInputTypeError($cnpjInput, 'string or string[]'); } } diff --git a/packages/cnpj-fmt/src/CnpjFormatterOptions.php b/packages/cnpj-fmt/src/CnpjFormatterOptions.php index 9a3bf60..89ddd14 100644 --- a/packages/cnpj-fmt/src/CnpjFormatterOptions.php +++ b/packages/cnpj-fmt/src/CnpjFormatterOptions.php @@ -2,178 +2,768 @@ declare(strict_types=1); -namespace Lacus\CnpjFmt; +namespace Lacus\BrUtils\Cnpj; use Closure; -use Exception; use InvalidArgumentException; +use Lacus\BrUtils\Cnpj\Exceptions\CnpjFormatterException; +use Lacus\BrUtils\Cnpj\Exceptions\CnpjFormatterOptionsForbiddenKeyCharacterException; +use Lacus\BrUtils\Cnpj\Exceptions\CnpjFormatterOptionsHiddenRangeInvalidException; +use Lacus\BrUtils\Cnpj\Exceptions\CnpjFormatterOptionsTypeError; +/** + * Class to store the options for the CNPJ formatter. This class provides a + * centralized way to configure how CNPJ numbers are formatted, including + * delimiters, hidden character ranges, HTML escaping, URL encoding, and error + * handling callbacks. + * + * @property bool $hidden + * @property string $hiddenKey + * @property int $hiddenStart + * @property int $hiddenEnd + * @property string $dotKey + * @property string $slashKey + * @property string $dashKey + * @property bool $escape + * @property bool $encode + * @property Closure(mixed, CnpjFormatterException): string $onFail + */ class CnpjFormatterOptions { - private bool $escape; - private bool $hidden; - private string $hiddenKey; - private int $hiddenStart; - private int $hiddenEnd; - private string $dotKey; - private string $slashKey; - private string $dashKey; - private Closure $onFail; + /** + * The standard length of a CNPJ (Cadastro Nacional da Pessoa Jurídica) + * identifier (14 alphanumeric characters). + */ + public const CNPJ_LENGTH = 14; + + /** + * Minimum valid index for the hidden range (inclusive). Must be between 0 and + * CNPJ_LENGTH - 1. + */ + private const MIN_HIDDEN_RANGE = 0; + + /** + * Maximum valid index for the hidden range (inclusive). Must be between 0 and + * CNPJ_LENGTH - 1. + */ + private const MAX_HIDDEN_RANGE = self::CNPJ_LENGTH - 1; + + /** + * Default value for the `hidden` option. When `false`, all CNPJ characters + * are displayed. + */ + public const DEFAULT_HIDDEN = false; + + /** + * Default string used to replace hidden CNPJ characters. + */ + public const DEFAULT_HIDDEN_KEY = '*'; + + /** + * Default start index (inclusive) for hiding CNPJ characters. Characters from + * this index onwards will be replaced with the `hiddenKey` value. + */ + public const DEFAULT_HIDDEN_START = 5; + + /** + * Default end index (inclusive) for hiding CNPJ characters. Characters up to + * and including this index will be replaced with the `hiddenKey` value. + */ + public const DEFAULT_HIDDEN_END = 13; + + /** + * Default string used as the dot delimiter in formatted CNPJ. Used to + * separate the first groups of characters (XX.XXX.XXX). + */ + public const DEFAULT_DOT_KEY = '.'; + /** + * Default string used as the slash delimiter in formatted CNPJ. Used to + * separate the first group of characters from the branch identifier + * (XXXXXXXX/XXXX). + */ + public const DEFAULT_SLASH_KEY = '/'; + + /** + * Default string used as the dash delimiter in formatted CNPJ. Used to + * separate the branch identifier from the check digits at the end (XXXX-XX). + */ + public const DEFAULT_DASH_KEY = '-'; + + /** + * Default value for the `escape` option. When `false`, HTML special + * characters are not escaped. + */ + public const DEFAULT_ESCAPE = false; + + /** + * Default value for the `encode` option. When `false`, the CNPJ string is not + * URL-encoded. + */ + public const DEFAULT_ENCODE = false; + + /** + * @var (Closure(mixed, CnpjFormatterException): string)|null + */ + private static ?Closure $defaultOnFailCallback = null; + + /** + * @return Closure(mixed, CnpjFormatterException): string + */ + public static function getDefaultOnFail(): Closure + { + if (self::$defaultOnFailCallback === null) { + self::$defaultOnFailCallback = static fn (mixed $value, CnpjFormatterException $exception): string => ''; + } + + return self::$defaultOnFailCallback; + } + + /** + * Characters that are not allowed in key options (`hiddenKey`, `dotKey`, + * `slashKey`, `dashKey`). They are reserved for internal formatting logic. + * + * For now, it's only used to replace the hidden key placeholder in the + * CnpjFormatter class. However, this set of characters is reserved for future + * use already. + * + * @var list + */ + public const DISALLOWED_KEY_CHARACTERS = [ + "\u{00e5}", + "\u{00eb}", + "\u{00ef}", + "\u{00f6}", + ]; + + /** + * @var array{ + * hidden: bool, + * hiddenKey: string, + * hiddenStart: int, + * hiddenEnd: int, + * dotKey: string, + * slashKey: string, + * dashKey: string, + * escape: bool, + * encode: bool, + * onFail: Closure(mixed, CnpjFormatterException): string + * } + */ + private array $options = []; // @phpstan-ignore-line property.defaultValue + + /** + * Creates a new instance of `CnpjFormatterOptions`. + * + * Options can be provided in multiple ways: + * + * 1. As a single options array or another `CnpjFormatterOptions` instance. + * 2. As multiple override objects that are merged in order (later overrides take + * precedence) + * + * All options are optional and will default to their predefined values if not + * provided. The `hiddenStart` and `hiddenEnd` options are validated to ensure + * they are within the valid range [0, CNPJ_LENGTH - 1] and will be swapped if + * `hiddenStart > hiddenEnd`. + * + * @param ?bool $hidden + * @param ?string $hiddenKey + * @param ?int $hiddenStart + * @param ?int $hiddenEnd + * @param ?string $dotKey + * @param ?string $slashKey + * @param ?string $dashKey + * @param ?bool $escape + * @param ?bool $encode + * @param ?Closure(mixed, CnpjFormatterException): string $onFail + * @param list $overrides + * + * @throws CnpjFormatterOptionsTypeError If any option has an invalid type. + * @throws CnpjFormatterOptionsHiddenRangeInvalidException If `hiddenStart` + * or `hiddenEnd` are out of valid range. + * @throws CnpjFormatterOptionsForbiddenKeyCharacterException If any key + * option (`hiddenKey`, `dotKey`, `slashKey`, `dashKey`) contains a + * disallowed character. + */ public function __construct( - ?bool $escape = null, - ?bool $hidden = null, - ?string $hiddenKey = null, - ?int $hiddenStart = null, - ?int $hiddenEnd = null, - ?string $dotKey = null, - ?string $slashKey = null, - ?string $dashKey = null, - ?Closure $onFail = null, + $hidden = null, + $hiddenKey = null, + $hiddenStart = null, + $hiddenEnd = null, + $dotKey = null, + $slashKey = null, + $dashKey = null, + $escape = null, + $encode = null, + $onFail = null, + ?array $overrides = [], ) { - $this->setEscape($escape ?? false); - $this->setHide($hidden ?? false); - $this->setHiddenKey($hiddenKey ?? '*'); - $this->setHiddenRange($hiddenStart ?? 5, $hiddenEnd ?? 13); - $this->setDotKey($dotKey ?? '.'); - $this->setSlashKey($slashKey ?? '/'); - $this->setDashKey($dashKey ?? '-'); - $this->setOnFail($onFail ?? function (string $value): string { - return $value; - }); - } - - public function merge( - ?bool $escape = null, - ?bool $hidden = null, - ?string $hiddenKey = null, - ?int $hiddenStart = null, - ?int $hiddenEnd = null, - ?string $dotKey = null, - ?string $slashKey = null, - ?string $dashKey = null, - ?Closure $onFail = null, - ): self { - return new self( - $escape ?? $this->isEscaped(), - $hidden ?? $this->isHidden(), - $hiddenKey ?? $this->getHiddenKey(), - $hiddenStart ?? $this->getHiddenStart(), - $hiddenEnd ?? $this->getHiddenEnd(), - $dotKey ?? $this->getDotKey(), - $slashKey ?? $this->getSlashKey(), - $dashKey ?? $this->getDashKey(), - $onFail ?? $this->getOnFail(), - ); + $this->setHidden($hidden); + $this->setHiddenKey($hiddenKey); + $this->setHiddenRange($hiddenStart, $hiddenEnd); + $this->setDotKey($dotKey); + $this->setSlashKey($slashKey); + $this->setDashKey($dashKey); + $this->setEscape($escape); + $this->setEncode($encode); + $this->setOnFail($onFail); + + foreach (($overrides ?? []) as $override) { + if ($override === null) { + continue; + } + + if ($override instanceof CnpjFormatterOptions) { + $this->set(...$override->getAll()); + } elseif (is_array($override)) { + $this->set( + hidden: $override['hidden'] ?? null, + hiddenKey: $override['hiddenKey'] ?? null, + hiddenStart: $override['hiddenStart'] ?? null, + hiddenEnd: $override['hiddenEnd'] ?? null, + dotKey: $override['dotKey'] ?? null, + slashKey: $override['slashKey'] ?? null, + dashKey: $override['dashKey'] ?? null, + escape: $override['escape'] ?? null, + encode: $override['encode'] ?? null, + onFail: $override['onFail'] ?? null, + ); + } + } } - public function setEscape(bool $value): void + /** + * Property-style access to the options. + */ + public function __get(string $name): mixed { - $this->escape = $value; + return match ($name) { + 'hidden' => $this->getHidden(), + 'hiddenKey' => $this->getHiddenKey(), + 'hiddenStart' => $this->getHiddenStart(), + 'hiddenEnd' => $this->getHiddenEnd(), + 'dotKey' => $this->getDotKey(), + 'slashKey' => $this->getSlashKey(), + 'dashKey' => $this->getDashKey(), + 'escape' => $this->getEscape(), + 'encode' => $this->getEncode(), + 'onFail' => $this->getOnFail(), + default => throw new InvalidArgumentException("Unknown property: {$name}"), + }; } - public function isEscaped(): bool + /** + * Property-style mutation to the options. + */ + public function __set(string $name, mixed $value): void { - return $this->escape; + match ($name) { + 'hidden' => $this->setHidden($value), // @phpstan-ignore-line argument.type + 'hiddenKey' => $this->setHiddenKey($value), // @phpstan-ignore-line method.notFound + 'hiddenStart' => $this->setHiddenStart($value), // @phpstan-ignore-line method.notFound + 'hiddenEnd' => $this->setHiddenEnd($value), // @phpstan-ignore-line method.notFound + 'dotKey' => $this->setDotKey($value), // @phpstan-ignore-line method.notFound + 'slashKey' => $this->setSlashKey($value), // @phpstan-ignore-line method.notFound + 'dashKey' => $this->setDashKey($value), // @phpstan-ignore-line method.notFound + 'escape' => $this->setEscape($value), // @phpstan-ignore-line argument.type + 'encode' => $this->setEncode($value), // @phpstan-ignore-line argument.type + 'onFail' => $this->setOnFail($value), // @phpstan-ignore-line argument.type + default => throw new InvalidArgumentException("Unknown property: {$name}"), + }; } - public function setHide(bool $value): void + /** + * Returns a shallow copy of all current options. This is useful for creating + * snapshots of the current configuration. + * + * @return array{ + * hidden: bool, + * hiddenKey: string, + * hiddenStart: int, + * hiddenEnd: int, + * dotKey: string, + * slashKey: string, + * dashKey: string, + * escape: bool, + * encode: bool, + * onFail: Closure(mixed, CnpjFormatterException): string + * } + */ + public function getAll(): array { - $this->hidden = $value; + return [...$this->options]; } - public function isHidden(): bool + /** + * Gets whether hidden character replacement is enabled. When `true`, + * characters within the `hiddenStart` to `hiddenEnd` range will be replaced + * with the `hiddenKey` character. + */ + private function getHidden(): bool { - return $this->hidden; + return $this->options['hidden']; } - public function setHiddenKey(string $value): void + /** + * Sets whether hidden character replacement is enabled. When set to `true`, + * characters within the `hiddenStart` to `hiddenEnd` range will be replaced + * with the `hiddenKey` character. The value is converted to a boolean, so + * truthy/falsy values are handled appropriately. + * + * @param bool|null $value + */ + private function setHidden($value): void { - $this->hiddenKey = $value; + $actualHidden = $value ?? self::DEFAULT_HIDDEN; + $actualHidden = (bool) $actualHidden; + + $this->options['hidden'] = $actualHidden; } - public function getHiddenKey(): string + /** + * Gets the string used to replace hidden CNPJ characters. This string is used + * when `hidden` is `true` to mask characters in the range from `hiddenStart` + * to `hiddenEnd` (inclusive). + */ + private function getHiddenKey(): string { - return $this->hiddenKey; + return $this->options['hiddenKey']; } - public function setHiddenRange(int $start, int $end): void + /** + * Sets the string used to replace hidden CNPJ characters. This string is used + * when `hidden` is `true` to mask characters in the range from `hiddenStart` + * to `hiddenEnd` (inclusive). + * + * @param string|null $value + * + * @throws CnpjFormatterOptionsTypeError If the value is not a string. + * @throws CnpjFormatterOptionsForbiddenKeyCharacterException If the value + * contains any disallowed key character. + */ + private function setHiddenKey($value): void { - $min = 0; - $max = CNPJ_LENGTH - 1; + $actualHiddenKey = $value ?? self::DEFAULT_HIDDEN_KEY; - if ($start < $min || $start > $max) { - throw new InvalidArgumentException( - "Option \"hiddenStart\" must be an integer between {$min} and {$max}." - ); - } + $this->assertIsString('hiddenKey', $actualHiddenKey); + $this->assertHasAllowedCharacters('hiddenKey', $actualHiddenKey); - if ($end < $min || $end > $max) { - throw new InvalidArgumentException( - "Option \"hiddenRange.end\" must be an integer between {$min} and {$max}." - ); - } + $this->options['hiddenKey'] = $actualHiddenKey; + } - if ($start > $end) { - $aux = $start; - $start = $end; - $end = $aux; - } + /** + * Gets the start index (inclusive) for hiding CNPJ characters. This is the + * first position in the CNPJ string where characters will be replaced with + * the `hiddenKey` string when `hidden` is `true`. Must be between `0` and + * `13` (`CNPJ_LENGTH - 1`). + */ + private function getHiddenStart(): int + { + return $this->options['hiddenStart']; + } - $this->hiddenStart = $start; - $this->hiddenEnd = $end; + /** + * Sets the start index (inclusive) for hiding CNPJ characters. This is the + * first position in the CNPJ string where characters will be replaced with + * the `hiddenKey` when `hidden` is `true`. The value is validated and will be + * swapped with `hiddenEnd` if necessary to ensure `hiddenStart <= hiddenEnd`. + * + * @param int|null $value + * + * @throws CnpjFormatterOptionsTypeError If the value is not an integer. + * @throws CnpjFormatterOptionsHiddenRangeInvalidException If `hiddenStart` + * or `hiddenEnd` are out of valid range. + */ + private function setHiddenStart($value): void + { + $this->setHiddenRange($value, $this->options['hiddenEnd']); } - public function getHiddenStart(): int + /** + * Gets the end index (inclusive) for hiding CNPJ characters. This is the last + * position in the CNPJ string where characters will be replaced with the + * `hiddenKey` string when `hidden` is `true`. Must be between `0` and `13` + * (`CNPJ_LENGTH - 1`). + */ + private function getHiddenEnd(): int { - return $this->hiddenStart; + return $this->options['hiddenEnd']; } - public function getHiddenEnd(): int + /** + * Sets the end index (inclusive) for hiding CNPJ characters. This is the last + * position in the CNPJ string where characters will be replaced with the + * `hiddenKey` when `hidden` is `true`. The value is validated and will be + * swapped with `hiddenStart` if necessary to ensure `hiddenStart <= + * hiddenEnd`. + * + * @param int|null $value + * + * @throws CnpjFormatterOptionsTypeError If the value is not an integer. + * @throws CnpjFormatterOptionsHiddenRangeInvalidException If `hiddenStart` + * or `hiddenEnd` are out of valid range. + */ + private function setHiddenEnd($value): void { - return $this->hiddenEnd; + $this->setHiddenRange($this->options['hiddenStart'], $value); } - public function setDotKey(string $value): void + /** + * Gets the string used as the dot delimiter. This string is used to separate + * the first groups of characters in the formatted CNPJ (e.g., `"."` in + * "12.345.678/0001-90"). + */ + private function getDotKey(): string { - $this->dotKey = $value; + return $this->options['dotKey']; } - public function getDotKey(): string + /** + * Sets the string used as the dot delimiter. This string is used to separate + * the first groups of characters in the formatted CNPJ (e.g., `"."` in + * `"12.345.678/0001-90"`). + * + * @param string|null $value + * + * @throws CnpjFormatterOptionsTypeError If the value is not a string. + * @throws CnpjFormatterOptionsForbiddenKeyCharacterException If the value + * contains any disallowed key character. + */ + private function setDotKey($value): void + { + $actualDotKey = $value ?? self::DEFAULT_DOT_KEY; + + $this->assertIsString('dotKey', $actualDotKey); + $this->assertHasAllowedCharacters('dotKey', $actualDotKey); + + $this->options['dotKey'] = $actualDotKey; + } + + /** + * Gets the string used as the slash delimiter. This string is used to + * separate the first group of characters from the branch identifier in the + * formatted CNPJ (e.g., `"/"` in `"12.345.678/0001-90"`). + */ + private function getSlashKey(): string + { + return $this->options['slashKey']; + } + + /** + * Sets the string used as the slash delimiter. This string is used to + * separate the first group of characters from the branch identifier in the + * formatted CNPJ (e.g., `"/"` in `"12.345.678/0001-90"`). + * + * @param string|null $value + * + * @throws CnpjFormatterOptionsTypeError If the value is not a string. + * @throws CnpjFormatterOptionsForbiddenKeyCharacterException If the value + * contains any disallowed key character. + */ + private function setSlashKey($value): void + { + $actualSlashKey = $value ?? self::DEFAULT_SLASH_KEY; + + $this->assertIsString('slashKey', $actualSlashKey); + $this->assertHasAllowedCharacters('slashKey', $actualSlashKey); + + $this->options['slashKey'] = $actualSlashKey; + } + + /** + * Gets the string used as the dash delimiter. This string is used to separate + * the check digits at the end in the formatted CNPJ (e.g., `"-"` in + * `"12.345.678/0001-90"`). + */ + private function getDashKey(): string + { + return $this->options['dashKey']; + } + + /** + * Sets the string used as the dash delimiter. This string is used to separate + * the check digits at the end in the formatted CNPJ (e.g., `"-"` in + * `"12.345.678/0001-90"`). + * + * @param string|null $value + * + * @throws CnpjFormatterOptionsTypeError If the value is not a string. + * @throws CnpjFormatterOptionsForbiddenKeyCharacterException If the value + * contains any disallowed key character. + */ + private function setDashKey($value): void + { + $actualDashKey = $value ?? self::DEFAULT_DASH_KEY; + + $this->assertIsString('dashKey', $actualDashKey); + $this->assertHasAllowedCharacters('dashKey', $actualDashKey); + + $this->options['dashKey'] = $actualDashKey; + } + + /** + * Gets whether HTML escaping is enabled. When `true`, HTML special characters + * (like `<`, `>`, `&`, etc.) in the formatted CNPJ string will be escaped. + * This is useful when using custom delimiters that may contain HTML + * characters or when displaying CNPJ in HTML. + */ + private function getEscape(): bool + { + return $this->options['escape']; + } + + /** + * Sets whether HTML escaping is enabled. When set to `true`, HTML special + * characters (like `<`, `>`, `&`, etc.) in the formatted CNPJ string will be + * escaped. This is useful when using custom delimiters that may contain HTML + * characters or when displaying CNPJ in HTML. The value is converted to a + * boolean, so truthy/falsy values are handled appropriately. + * + * @param bool|null $value + */ + private function setEscape($value): void + { + $actualEscape = $value ?? self::DEFAULT_ESCAPE; + $actualEscape = (bool) $actualEscape; + + $this->options['escape'] = $actualEscape; + } + + /** + * Gets whether URL encoding is enabled. When `true`, the formatted CNPJ + * string will be URL-encoded, making it safe to use in URL query parameters + * or path segments. + */ + private function getEncode(): bool + { + return $this->options['encode']; + } + + /** + * Sets whether URL encoding is enabled. When set to `true`, the formatted + * CNPJ string will be URL-encoded, making it safe to use in URL query + * parameters or path segments. The value is converted to a boolean, so + * truthy/falsy values are handled appropriately. + * + * @param bool|null $value + */ + private function setEncode($value): void { - return $this->dotKey; + $actualEncode = $value ?? self::DEFAULT_ENCODE; + $actualEncode = (bool) $actualEncode; + + $this->options['encode'] = $actualEncode; } - public function setSlashKey(string $value): void + /** + * Gets the callback function executed when formatting fails. This function is + * called when the formatter encounters an error (e.g., invalid input, invalid + * options). It receives the input value and an exception object, and + * should return a string to use as the fallback output. + * + * @return Closure(mixed, CnpjFormatterException): string + */ + private function getOnFail(): Closure { - $this->slashKey = $value; + return $this->options['onFail']; } - public function getSlashKey(): string + /** + * Sets the callback function executed when formatting fails. This function is + * called when the formatter encounters an error (e.g., invalid input, invalid + * options). It receives the input value and an exception object, and + * should return a string to use as the fallback output. + * + * @param (Closure(mixed, CnpjFormatterException): string)|null $value + * + * @throws CnpjFormatterOptionsTypeError If the value is not a Closure. + */ + private function setOnFail($value): void { - return $this->slashKey; + $actualOnFail = $value ?? self::getDefaultOnFail(); + + $this->assertIsClosure('onFail', $actualOnFail); + + $this->options['onFail'] = $actualOnFail; } - public function setDashKey(string $value): void + /** + * Sets the hiddenStart and hiddenEnd options with proper validation and + * sanitization. This method validates that both indices are integers within + * the valid range [`0`, `CNPJ_LENGTH - 1`]. If `hiddenStart > hiddenEnd`, the + * values are automatically swapped to ensure a valid range. This method is + * used internally by the `hiddenStart` and `hiddenEnd` setters to maintain + * consistency. + * + * @param int|null $hiddenStart + * @param int|null $hiddenEnd + * + * @throws CnpjFormatterOptionsTypeError If either value is not an integer. + * @throws CnpjFormatterOptionsHiddenRangeInvalidException If `hiddenStart` + * or `hiddenEnd` are out of valid range. + */ + public function setHiddenRange($hiddenStart, $hiddenEnd): self { - $this->dashKey = $value; + $actualHiddenStart = $hiddenStart ?? self::DEFAULT_HIDDEN_START; + $actualHiddenEnd = $hiddenEnd ?? self::DEFAULT_HIDDEN_END; + + $this->assertIsInt('hiddenStart', $actualHiddenStart); + $this->assertIsInt('hiddenEnd', $actualHiddenEnd); + $this->assertIsBetweenHiddenRangeBounds('hiddenStart', $actualHiddenStart); + $this->assertIsBetweenHiddenRangeBounds('hiddenEnd', $actualHiddenEnd); + + if ($actualHiddenStart > $actualHiddenEnd) { + [$actualHiddenStart, $actualHiddenEnd] = [$actualHiddenEnd, $actualHiddenStart]; + } + + $this->options['hiddenStart'] = $actualHiddenStart; + $this->options['hiddenEnd'] = $actualHiddenEnd; + + return $this; } - public function getDashKey(): string + /** + * Sets multiple options at once. This method allows you to update multiple + * options in a single call. Only the provided options are updated; options + * not included in the object retain their current values. You can pass either + * a partial options array or another `CnpjFormatterOptions` instance. + * + * @param ?bool $hidden + * @param ?string $hiddenKey + * @param ?int $hiddenStart + * @param ?int $hiddenEnd + * @param ?string $dotKey + * @param ?string $slashKey + * @param ?string $dashKey + * @param ?bool $escape + * @param ?bool $encode + * @param ?Closure(mixed, CnpjFormatterException): string $onFail + * + * @throws CnpjFormatterOptionsTypeError If any option has an invalid type. + * @throws CnpjFormatterOptionsHiddenRangeInvalidException If `hiddenStart` + * or `hiddenEnd` are out of valid range. + * @throws CnpjFormatterOptionsForbiddenKeyCharacterException If any key + * option (`hiddenKey`, `dotKey`, `slashKey`, `dashKey`) contains a + * disallowed character. + */ + public function set( + $hidden = null, + $hiddenKey = null, + $hiddenStart = null, + $hiddenEnd = null, + $dotKey = null, + $slashKey = null, + $dashKey = null, + $escape = null, + $encode = null, + $onFail = null, + ): self { + $this->setHidden($hidden ?? $this->getHidden()); + $this->setHiddenKey($hiddenKey ?? $this->getHiddenKey()); + $this->setDotKey($dotKey ?? $this->getDotKey()); + $this->setHiddenRange($hiddenStart ?? $this->getHiddenStart(), $hiddenEnd ?? $this->getHiddenEnd()); + $this->setSlashKey($slashKey ?? $this->getSlashKey()); + $this->setDashKey($dashKey ?? $this->getDashKey()); + $this->setEscape($escape ?? $this->getEscape()); + $this->setEncode($encode ?? $this->getEncode()); + $this->setOnFail($onFail ?? $this->getOnFail()); + + return $this; + } + + /** + * Throws if the given value is not a string. + * + * @param 'hiddenKey'|'dotKey'|'slashKey'|'dashKey' $optionName + * + * @throws CnpjFormatterOptionsTypeError If the option value is not a string. + */ + private function assertIsString(string $optionName, mixed $value): void + { + if (!is_string($value)) { + throw new CnpjFormatterOptionsTypeError($optionName, $value, 'string'); + } + } + + /** + * Throws if the given string contains any disallowed key character. + * + * @param 'hiddenKey'|'dotKey'|'slashKey'|'dashKey' $optionName + * + * @throws CnpjFormatterOptionsForbiddenKeyCharacterException If `value` + * contains any character from `DISALLOWED_KEY_CHARACTERS`. + */ + private function assertHasAllowedCharacters(string $optionName, string $value): void + { + $forbiddenChars = self::DISALLOWED_KEY_CHARACTERS; + + foreach ($forbiddenChars as $ch) { + if (str_contains($value, $ch)) { + throw new CnpjFormatterOptionsForbiddenKeyCharacterException( + $optionName, + $value, + $forbiddenChars, + ); + } + } + } + + /** + * Throws if the given value is not an integer. + * + * @param 'hiddenStart'|'hiddenEnd' $optionName + * + * @throws CnpjFormatterOptionsTypeError If the option value is not an integer. + */ + private function assertIsInt(string $optionName, mixed $value): void { - return $this->dashKey; + if (!is_int($value)) { + throw new CnpjFormatterOptionsTypeError($optionName, $value, 'integer'); + } } - public function setOnFail(Closure $callback): void + /** + * Throws if the given value is not between the hidden range bounds. + * + * @param 'hiddenStart'|'hiddenEnd' $optionName + * + * @throws CnpjFormatterOptionsHiddenRangeInvalidException If `hiddenStart` + * or `hiddenEnd` are out of valid range. + */ + private function assertIsBetweenHiddenRangeBounds(string $optionName, int $value): void { - $this->onFail = $callback; + if ($value < self::MIN_HIDDEN_RANGE || $value > self::MAX_HIDDEN_RANGE) { + throw new CnpjFormatterOptionsHiddenRangeInvalidException( + $optionName, + $value, + self::MIN_HIDDEN_RANGE, + self::MAX_HIDDEN_RANGE, + ); + } } /** - * @return Closure(string, Exception): string + * Throws if the given value is not a Closure. + * + * @param 'onFail' $optionName + * + * @throws CnpjFormatterOptionsTypeError If the option value is not a Closure. */ - public function getOnFail(): Closure + private function assertIsClosure(string $optionName, mixed $value): void { - return $this->onFail; + if (!$value instanceof Closure) { + throw new CnpjFormatterOptionsTypeError($optionName, $value, 'function'); + } } } diff --git a/packages/cnpj-fmt/src/Exceptions/CnpjFormatterException.php b/packages/cnpj-fmt/src/Exceptions/CnpjFormatterException.php new file mode 100644 index 0000000..db0ecc2 --- /dev/null +++ b/packages/cnpj-fmt/src/Exceptions/CnpjFormatterException.php @@ -0,0 +1,34 @@ +getShortName(); + } +} diff --git a/packages/cnpj-fmt/src/Exceptions/CnpjFormatterInputLengthException.php b/packages/cnpj-fmt/src/Exceptions/CnpjFormatterInputLengthException.php new file mode 100644 index 0000000..eec000d --- /dev/null +++ b/packages/cnpj-fmt/src/Exceptions/CnpjFormatterInputLengthException.php @@ -0,0 +1,37 @@ + */ + public readonly string|array $actualInput; + public readonly string $evaluatedInput; + public readonly int $expectedLength; + + /** + * @param string|list $actualInput + */ + public function __construct(string|array $actualInput, string $evaluatedInput, int $expectedLength) + { + $fmtActualInput = is_string($actualInput) + ? "\"{$actualInput}\"" + : json_encode($actualInput, JSON_THROW_ON_ERROR); + $fmtEvaluatedInput = $actualInput === $evaluatedInput + ? (string) strlen($evaluatedInput) + : strlen($evaluatedInput) . ' in "' . $evaluatedInput . '"'; + + parent::__construct("CNPJ input {$fmtActualInput} does not contain {$expectedLength} characters. Got {$fmtEvaluatedInput}."); + $this->actualInput = $actualInput; + $this->evaluatedInput = $evaluatedInput; + $this->expectedLength = $expectedLength; + } +} diff --git a/packages/cnpj-fmt/src/Exceptions/CnpjFormatterInputTypeError.php b/packages/cnpj-fmt/src/Exceptions/CnpjFormatterInputTypeError.php new file mode 100644 index 0000000..43d7052 --- /dev/null +++ b/packages/cnpj-fmt/src/Exceptions/CnpjFormatterInputTypeError.php @@ -0,0 +1,27 @@ + */ + public readonly array $forbiddenCharacters; + + /** + * @param 'hiddenKey'|'dotKey'|'slashKey'|'dashKey' $optionName + * @param list $forbiddenCharacters + */ + public function __construct(string $optionName, string $actualInput, array $forbiddenCharacters) + { + $joined = implode('", "', $forbiddenCharacters); + + parent::__construct("Value \"{$actualInput}\" for CNPJ formatting option \"{$optionName}\" contains disallowed characters (\"{$joined}\")."); + $this->optionName = $optionName; + $this->actualInput = $actualInput; + $this->forbiddenCharacters = $forbiddenCharacters; + } +} diff --git a/packages/cnpj-fmt/src/Exceptions/CnpjFormatterOptionsHiddenRangeInvalidException.php b/packages/cnpj-fmt/src/Exceptions/CnpjFormatterOptionsHiddenRangeInvalidException.php new file mode 100644 index 0000000..a793d57 --- /dev/null +++ b/packages/cnpj-fmt/src/Exceptions/CnpjFormatterOptionsHiddenRangeInvalidException.php @@ -0,0 +1,36 @@ +optionName = $optionName; + $this->actualInput = $actualInput; + $this->minExpectedValue = $minExpectedValue; + $this->maxExpectedValue = $maxExpectedValue; + } +} diff --git a/packages/cnpj-fmt/src/Exceptions/CnpjFormatterOptionsTypeError.php b/packages/cnpj-fmt/src/Exceptions/CnpjFormatterOptionsTypeError.php new file mode 100644 index 0000000..589c382 --- /dev/null +++ b/packages/cnpj-fmt/src/Exceptions/CnpjFormatterOptionsTypeError.php @@ -0,0 +1,33 @@ +optionName = $optionName; + } +} diff --git a/packages/cnpj-fmt/src/Exceptions/CnpjFormatterTypeError.php b/packages/cnpj-fmt/src/Exceptions/CnpjFormatterTypeError.php new file mode 100644 index 0000000..4d01f54 --- /dev/null +++ b/packages/cnpj-fmt/src/Exceptions/CnpjFormatterTypeError.php @@ -0,0 +1,43 @@ +actualInput = $actualInput; + $this->actualType = $actualType; + $this->expectedType = $expectedType; + } + + /** + * Get the short class name of the error instance. + */ + public function getName(): string + { + $thisReflection = new ReflectionClass($this); + + return $thisReflection->getShortName(); + } +} diff --git a/packages/cnpj-fmt/src/cnpj-fmt.php b/packages/cnpj-fmt/src/cnpj-fmt.php index cc96be4..bdbc699 100644 --- a/packages/cnpj-fmt/src/cnpj-fmt.php +++ b/packages/cnpj-fmt/src/cnpj-fmt.php @@ -2,26 +2,60 @@ declare(strict_types=1); -namespace Lacus\CnpjFmt; +namespace Lacus\BrUtils\Cnpj; use Closure; +use Lacus\BrUtils\Cnpj\Exceptions\CnpjFormatterException; +/** + * The standard length of a CNPJ (Cadastro Nacional da Pessoa Jurídica) + * identifier (14 alphanumeric characters). Matches {@see CnpjFormatterOptions::CNPJ_LENGTH}. + */ const CNPJ_LENGTH = 14; +/** + * Helper function to simplify the usage of the {@see CnpjFormatter} class. + * + * Formats a CNPJ string according to the given options. With no options, + * returns the traditional CNPJ format (e.g. `12.345.678/0009-10`). Invalid + * input length is handled by the configured `onFail` callback instead of + * throwing. + * + * @param string|list $cnpjInput + * @param ?CnpjFormatterOptions $options + * @param ?bool $hidden + * @param ?string $hiddenKey + * @param ?int $hiddenStart + * @param ?int $hiddenEnd + * @param ?string $dotKey + * @param ?string $slashKey + * @param ?string $dashKey + * @param ?bool $escape + * @param ?bool $encode + * @param ?Closure(mixed, CnpjFormatterException): string $onFail + * + * @throws \Lacus\BrUtils\Cnpj\Exceptions\CnpjFormatterOptionsTypeError If any option has an invalid type. + * @throws \Lacus\BrUtils\Cnpj\Exceptions\CnpjFormatterOptionsHiddenRangeInvalidException If `hiddenStart` or + * `hiddenEnd` are out of valid range. + * @throws \Lacus\BrUtils\Cnpj\Exceptions\CnpjFormatterOptionsForbiddenKeyCharacterException If any key option + * contains a disallowed character. + */ function cnpj_fmt( - string $cnpjString, - ?bool $escape = null, - ?bool $hidden = null, - ?string $hiddenKey = null, - ?int $hiddenStart = null, - ?int $hiddenEnd = null, - ?string $dotKey = null, - ?string $slashKey = null, - ?string $dashKey = null, - ?Closure $onFail = null, + $cnpjInput, + $options = null, + $hidden = null, + $hiddenKey = null, + $hiddenStart = null, + $hiddenEnd = null, + $dotKey = null, + $slashKey = null, + $dashKey = null, + $escape = null, + $encode = null, + $onFail = null, ): string { $formatter = new CnpjFormatter( - $escape, + $options, $hidden, $hiddenKey, $hiddenStart, @@ -29,8 +63,10 @@ function cnpj_fmt( $dotKey, $slashKey, $dashKey, + $escape, + $encode, $onFail, ); - return $formatter->format($cnpjString); + return $formatter->format($cnpjInput); } diff --git a/packages/cnpj-fmt/tests/CnpjFormatterClassTest.php b/packages/cnpj-fmt/tests/CnpjFormatterClassTest.php deleted file mode 100644 index 5cd929b..0000000 --- a/packages/cnpj-fmt/tests/CnpjFormatterClassTest.php +++ /dev/null @@ -1,63 +0,0 @@ -formatter = new CnpjFormatter(); - } - - protected function format( - string $cnpjString, - ?bool $escape = null, - ?bool $hidden = null, - ?string $hiddenKey = null, - ?int $hiddenStart = null, - ?int $hiddenEnd = null, - ?string $dotKey = null, - ?string $slashKey = null, - ?string $dashKey = null, - ?Closure $onFail = null, - ): string { - return $this->formatter->format( - $cnpjString, - $escape, - $hidden, - $hiddenKey, - $hiddenStart, - $hiddenEnd, - $dotKey, - $slashKey, - $dashKey, - $onFail, - ); - } - - public function testObjectOrientedGetOptions(): void - { - $options = $this->formatter->getOptions(); - - $this->assertInstanceOf(CnpjFormatterOptions::class, $options); - $this->assertFalse($options->isEscaped()); - $this->assertFalse($options->isHidden()); - $this->assertEquals('*', $options->getHiddenKey()); - $this->assertEquals(5, $options->getHiddenStart()); - $this->assertEquals(13, $options->getHiddenEnd()); - $this->assertEquals('.', $options->getDotKey()); - $this->assertEquals('/', $options->getSlashKey()); - $this->assertEquals('-', $options->getDashKey()); - } -} diff --git a/packages/cnpj-fmt/tests/CnpjFormatterFunctionTest.php b/packages/cnpj-fmt/tests/CnpjFormatterFunctionTest.php deleted file mode 100644 index dcb0012..0000000 --- a/packages/cnpj-fmt/tests/CnpjFormatterFunctionTest.php +++ /dev/null @@ -1,42 +0,0 @@ -assertFalse($options->isEscaped()); - $this->assertFalse($options->isHidden()); - $this->assertEquals('*', $options->getHiddenKey()); - $this->assertEquals(5, $options->getHiddenStart()); - $this->assertEquals(13, $options->getHiddenEnd()); - $this->assertEquals('.', $options->getDotKey()); - $this->assertEquals('/', $options->getSlashKey()); - $this->assertEquals('-', $options->getDashKey()); - $this->assertIsCallable($options->getOnFail()); - } - public function testConstructorWithAllNullParams(): void - { - $options = new CnpjFormatterOptions( - null, - null, - null, - null, - null, - null, - null, - null, - null, - ); - - $this->assertFalse($options->isEscaped()); - $this->assertFalse($options->isHidden()); - $this->assertEquals('*', $options->getHiddenKey()); - $this->assertEquals(5, $options->getHiddenStart()); - $this->assertEquals(13, $options->getHiddenEnd()); - $this->assertEquals('.', $options->getDotKey()); - $this->assertEquals('/', $options->getSlashKey()); - $this->assertEquals('-', $options->getDashKey()); - $this->assertIsCallable($options->getOnFail()); - } - - public function testConstructorWithAllParams(): void - { - $onFailCallback = function (string $value): string { - return 'ERROR: ' . $value; - }; - - $options = new CnpjFormatterOptions( - true, - true, - '#', - 1, - 8, - '|', - '_', - '~', - $onFailCallback, - ); - - $this->assertTrue($options->isEscaped()); - $this->assertTrue($options->isHidden()); - $this->assertEquals('#', $options->getHiddenKey()); - $this->assertEquals(1, $options->getHiddenStart()); - $this->assertEquals(8, $options->getHiddenEnd()); - $this->assertEquals('|', $options->getDotKey()); - $this->assertEquals('_', $options->getSlashKey()); - $this->assertEquals('~', $options->getDashKey()); - $this->assertSame($onFailCallback, $options->getOnFail()); - } - - public function testMergeWithPartialOverrides(): void - { - $originalOptions = new CnpjFormatterOptions( - false, - false, - '*', - 3, - 10, - '.', - '/', - '-', - ); - - $mergedOptions = $originalOptions->merge( - true, // override - null, // keep original - '#', // override - null, // keep original - null, // keep original - '_', // override - '|', // override - null, // keep original - ); - - $this->assertTrue($mergedOptions->isEscaped()); - $this->assertFalse($mergedOptions->isHidden()); - $this->assertEquals('#', $mergedOptions->getHiddenKey()); - $this->assertEquals(3, $mergedOptions->getHiddenStart()); - $this->assertEquals(10, $mergedOptions->getHiddenEnd()); - $this->assertEquals('_', $mergedOptions->getDotKey()); - $this->assertEquals('|', $mergedOptions->getSlashKey()); - $this->assertEquals('-', $mergedOptions->getDashKey()); - } - - public function testSetEscape(): void - { - $options = new CnpjFormatterOptions(); - - $options->setEscape(true); - $this->assertTrue($options->isEscaped()); - - $options->setEscape(false); - $this->assertFalse($options->isEscaped()); - } - - public function testSetHide(): void - { - $options = new CnpjFormatterOptions(); - - $options->setHide(true); - $this->assertTrue($options->isHidden()); - - $options->setHide(false); - $this->assertFalse($options->isHidden()); - } - - public function testSetHiddenKey(): void - { - $options = new CnpjFormatterOptions(); - - $options->setHiddenKey('X'); - $this->assertEquals('X', $options->getHiddenKey()); - - $options->setHiddenKey('?'); - $this->assertEquals('?', $options->getHiddenKey()); - } - - public function testSetHiddenRangeWithValidValues(): void - { - $options = new CnpjFormatterOptions(); - - $options->setHiddenRange(0, 10); - $this->assertEquals(0, $options->getHiddenStart()); - $this->assertEquals(10, $options->getHiddenEnd()); - - $options->setHiddenRange(5, 7); - $this->assertEquals(5, $options->getHiddenStart()); - $this->assertEquals(7, $options->getHiddenEnd()); - } - - public function testSetHiddenRangeWithSwappedValues(): void - { - $options = new CnpjFormatterOptions(); - - // Test that start > end gets swapped - $options->setHiddenRange(8, 2); - $this->assertEquals(2, $options->getHiddenStart()); - $this->assertEquals(8, $options->getHiddenEnd()); - } - - public function testSetHiddenRangeWithInvalidStart(): void - { - $options = new CnpjFormatterOptions(); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Option "hiddenStart" must be an integer between 0 and 13.'); - - $options->setHiddenRange(-1, 5); - } - - public function testSetHiddenRangeWithInvalidEnd(): void - { - $options = new CnpjFormatterOptions(); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Option "hiddenRange.end" must be an integer between 0 and 13.'); - - $options->setHiddenRange(5, 14); - } - - public function testSetHiddenRangeWithStartTooHigh(): void - { - $options = new CnpjFormatterOptions(); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Option "hiddenStart" must be an integer between 0 and 13.'); - - $options->setHiddenRange(14, 5); - } - - public function testSetDotKey(): void - { - $options = new CnpjFormatterOptions(); - - $options->setDotKey('|'); - $this->assertEquals('|', $options->getDotKey()); - - $options->setDotKey(' '); - $this->assertEquals(' ', $options->getDotKey()); - } - - public function testSetSlashKey(): void - { - $options = new CnpjFormatterOptions(); - - $options->setSlashKey('|'); - $this->assertEquals('|', $options->getSlashKey()); - - $options->setSlashKey('@'); - $this->assertEquals('@', $options->getSlashKey()); - } - - public function testSetDashKey(): void - { - $options = new CnpjFormatterOptions(); - - $options->setDashKey('~'); - $this->assertEquals('~', $options->getDashKey()); - - $options->setDashKey('_'); - $this->assertEquals('_', $options->getDashKey()); - } - - public function testSetOnFailWithValidCallback(): void - { - $options = new CnpjFormatterOptions(); - - $callback = function (string $value): string { - return 'ERROR: ' . $value; - }; - - $options->setOnFail($callback); - $this->assertSame($callback, $options->getOnFail()); - } - - public function testSetOnFailWithInvalidCallback(): void - { - $options = new CnpjFormatterOptions(); - - $this->expectException(exception: TypeError::class); - $this->expectExceptionMessage('must be of type Closure, string given'); - - $options->setOnFail('not a callback'); - } - - public function testSetOnFailWithArray(): void - { - $options = new CnpjFormatterOptions(); - - $this->expectException(TypeError::class); - $this->expectExceptionMessage('must be of type Closure, array given'); - - $options->setOnFail(['not', 'callable']); - } - - public function testSetOnFailWithNull(): void - { - $options = new CnpjFormatterOptions(); - - $this->expectException(TypeError::class); - $this->expectExceptionMessage('must be of type Closure, null given'); - - $options->setOnFail(null); - } - - public function testSetOnFailWithInt(): void - { - $options = new CnpjFormatterOptions(); - - $this->expectException(TypeError::class); - $this->expectExceptionMessage('must be of type Closure, int given'); - - $options->setOnFail(123); - } - - public function testBoundaryValuesForHiddenRange(): void - { - $options = new CnpjFormatterOptions(); - - // Test minimum values - $options->setHiddenRange(0, 0); - $this->assertEquals(0, $options->getHiddenStart()); - $this->assertEquals(0, $options->getHiddenEnd()); - - // Test maximum values - $options->setHiddenRange(10, 10); - $this->assertEquals(10, $options->getHiddenStart()); - $this->assertEquals(10, $options->getHiddenEnd()); - } - - public function testDefaultOnFailCallbackBehavior(): void - { - $options = new CnpjFormatterOptions(); - $callback = $options->getOnFail(); - - $result = $callback('test input'); - - $this->assertEquals('test input', $result); - } - - public function testMergeReturnsNewInstance(): void - { - $originalOptions = new CnpjFormatterOptions(null, null, null, null, null, null, null, null); - $mergedOptions = $originalOptions->merge(null, null, null, null, null, null, null, null); - - $this->assertNotSame($originalOptions, $mergedOptions); - $this->assertInstanceOf(CnpjFormatterOptions::class, $mergedOptions); - } - - public function testMergeWithAllNullsPreservesOriginalValues(): void - { - $originalOptions = new CnpjFormatterOptions( - true, - true, - '#', - 1, - 8, - '|', - '@', - '~', - function (string $value): string { - return 'ERROR: ' . $value; - }, - ); - - $mergedOptions = $originalOptions->merge(null, null, null, null, null, null, null, null); - - $this->assertTrue($mergedOptions->isEscaped()); - $this->assertTrue($mergedOptions->isHidden()); - $this->assertEquals('#', $mergedOptions->getHiddenKey()); - $this->assertEquals(1, $mergedOptions->getHiddenStart()); - $this->assertEquals(8, $mergedOptions->getHiddenEnd()); - $this->assertEquals('|', $mergedOptions->getDotKey()); - $this->assertEquals('@', $mergedOptions->getSlashKey()); - $this->assertEquals('~', $mergedOptions->getDashKey()); - } - - public function testConstructorWithMixedNullAndValidValues(): void - { - $onFailCallback = function (string $value): string { - return 'CUSTOM: ' . $value; - }; - - $options = new CnpjFormatterOptions( - true, - null, // should default to false - null, // should default to '*' - 4, - null, // should default to 10 - null, // should default to '.' - null, // should default to '/' - '~', - $onFailCallback, - ); - - $this->assertTrue($options->isEscaped()); - $this->assertFalse($options->isHidden()); - $this->assertEquals('*', $options->getHiddenKey()); - $this->assertEquals(4, $options->getHiddenStart()); - $this->assertEquals(13, $options->getHiddenEnd()); - $this->assertEquals('.', $options->getDotKey()); - $this->assertEquals('/', $options->getSlashKey()); - $this->assertEquals('~', $options->getDashKey()); - $this->assertSame($onFailCallback, $options->getOnFail()); - } -} diff --git a/packages/cnpj-fmt/tests/CnpjFormatterTestCases.php b/packages/cnpj-fmt/tests/CnpjFormatterTestCases.php deleted file mode 100644 index 3f2e628..0000000 --- a/packages/cnpj-fmt/tests/CnpjFormatterTestCases.php +++ /dev/null @@ -1,387 +0,0 @@ -format('03.603.568/0001-95'); - - $this->assertEquals('03.603.568/0001-95', $cnpj); - } - - public function testCnpjWithoutFormattingFormatsToDotsAndDash(): void - { - $cnpj = $this->format('03603568000195'); - - $this->assertEquals('03.603.568/0001-95', $cnpj); - } - - public function testCnpjWithDashesFormatsToDotsAndDash(): void - { - $cnpj = $this->format('03-603-568-0001-95'); - - $this->assertEquals('03.603.568/0001-95', $cnpj); - } - - public function testCnpjWithSpacesFormatsToDotsAndDash(): void - { - $cnpj = $this->format('03 603 568 0001 95'); - - $this->assertEquals('03.603.568/0001-95', $cnpj); - } - - public function testCnpjWithTrailingSpaceFormatsToDotsAndDash(): void - { - $cnpj = $this->format('03603568000195 '); - - $this->assertEquals('03.603.568/0001-95', $cnpj); - } - - public function testCnpjWithLeadingSpaceFormatsToDotsAndDash(): void - { - $cnpj = $this->format(' 03603568000195'); - - $this->assertEquals('03.603.568/0001-95', $cnpj); - } - - public function testCnpjWithIndividualDotsFormatsToDotsAndDash(): void - { - $cnpj = $this->format('0.3.6.0.3.5.6.8.0.0.0.1.9.5'); - - $this->assertEquals('03.603.568/0001-95', $cnpj); - } - - public function testCnpjWithIndividualDashesFormatsToDotsAndDash(): void - { - $cnpj = $this->format('0-3-6-0-3-5-6-8-0-0-0-1-9-5'); - - $this->assertEquals('03.603.568/0001-95', $cnpj); - } - - public function testCnpjWithIndividualSpacesFormatsToDotsAndDash(): void - { - $cnpj = $this->format('0 3 6 0 3 5 6 8 0 0 0 1 9 5'); - - $this->assertEquals('03.603.568/0001-95', $cnpj); - } - - public function testCnpjWithLettersFormatsToDotsAndDash(): void - { - $cnpj = $this->format('03603568000195abc'); - - $this->assertEquals('03.603.568/0001-95', $cnpj); - } - - public function testCnpjWithMixedCharactersFormatsCorrectly(): void - { - $cnpj = $this->format('036035680001 dv 95'); - - $this->assertEquals('03.603.568/0001-95', $cnpj); - } - - public function testCnpjWithSlashFormatsToDotsAndDash(): void - { - $cnpj = $this->format('03/603/568/0001/95'); - - $this->assertEquals('03.603.568/0001-95', $cnpj); - } - - public function testCnpjWithSpacesAndSlashFormatsToDotsAndDash(): void - { - $cnpj = $this->format('03 603 568 / 0001 95'); - - $this->assertEquals('03.603.568/0001-95', $cnpj); - } - - public function testCnpjWithSlashAndDashMixedFormatsToDotsAndDash(): void - { - $cnpj = $this->format('03-603-568-0001/95'); - - $this->assertEquals('03.603.568/0001-95', $cnpj); - } - - public function testCnpjWithLettersAndNumbersFormatsToDotsAndDash(): void - { - $cnpj = $this->format('03603568slash0001dash95'); - - $this->assertEquals('03.603.568/0001-95', $cnpj); - } - - public function testCnpjWithDvTextFormatsToDotsAndDash(): void - { - $cnpj = $this->format('036035680001 dv 95'); - - $this->assertEquals('03.603.568/0001-95', $cnpj); - } - - public function testCnpjFormatsToCustomDelimitersWithoutDots(): void - { - $cnpj = $this->format( - '03603568000195', - dotKey: '' - ); - - $this->assertEquals('03603568/0001-95', $cnpj); - } - - public function testCnpjFormatsToCustomDelimitersWithSlashAsColon(): void - { - $cnpj = $this->format( - '03603568000195', - slashKey: ':' - ); - - $this->assertEquals('03.603.568:0001-95', $cnpj); - } - - public function testCnpjFormatsToCustomDelimitersWithDashAsDot(): void - { - $cnpj = $this->format( - '03603568000195', - dashKey: '.' - ); - - $this->assertEquals('03.603.568/0001.95', $cnpj); - } - - public function testCnpjFormatsToNoDelimiters(): void - { - $cnpj = $this->format( - '03.603.568/0001-95', - dotKey: '', - slashKey: '', - dashKey: '' - ); - - $this->assertEquals('03603568000195', $cnpj); - } - - public function testCnpjFormatsToCustomDelimitersWithEscape(): void - { - $cnpj = $this->format( - '03603568000195', - escape: true, - dotKey: '<', - slashKey: '&', - dashKey: '>' - ); - - $this->assertEquals('03<603<568&0001>95', $cnpj); - } - - public function testCnpjFormatsToHiddenFormat(): void - { - $cnpj = $this->format( - '03603568000195', - hidden: true - ); - - $this->assertEquals('03.603.***/****-**', $cnpj); - } - - public function testCnpjFormatsToHiddenFormatWithStartRange(): void - { - $cnpj = $this->format( - '03603568000195', - hidden: true, - hiddenStart: 8 - ); - - $this->assertEquals('03.603.568/****-**', $cnpj); - } - - public function testCnpjFormatsToHiddenFormatWithEndRange(): void - { - $cnpj = $this->format( - '03603568000195', - hidden: true, - hiddenEnd: 11 - ); - - $this->assertEquals('03.603.***/****-95', $cnpj); - } - - public function testCnpjFormatsToHiddenFormatWithStartAndEndRange(): void - { - $cnpj = $this->format( - '03603568000195', - hidden: true, - hiddenStart: 0, - hiddenEnd: 7 - ); - - $this->assertEquals('**.***.***/0001-95', $cnpj); - } - - public function testCnpjFormatsToHiddenFormatWithReversedRange(): void - { - $cnpj = $this->format( - '03603568000195', - hidden: true, - hiddenStart: 11, - hiddenEnd: 2 - ); - - $this->assertEquals('03.***.***/****-95', $cnpj); - } - - public function testCnpjFormatsToHiddenFormatWithCustomKey(): void - { - $cnpj = $this->format( - '03603568000195', - hidden: true, - hiddenKey: '#' - ); - - $this->assertEquals('03.603.###/####-##', $cnpj); - } - - public function testCnpjFormatsToHiddenFormatWithCustomKeyAndRange(): void - { - $cnpj = $this->format( - '03603568000195', - hidden: true, - hiddenKey: '#', - hiddenStart: 8 - ); - - $this->assertEquals('03.603.568/####-##', $cnpj); - } - - public function testInvalidInputFallsBackToOnFailCallback(): void - { - $cnpj = $this->format( - 'abc', - onFail: function ($value) { - return strtoupper($value); - } - ); - - $this->assertEquals('ABC', $cnpj); - } - - public function testOptionWithRangeStartMinusOneThrowsException(): void - { - $this->expectException(InvalidArgumentException::class); - - $this->format( - '03603568000195', - hidden: true, - hiddenStart: -1 - ); - } - - public function testOptionWithRangeStartGreaterThan13ThrowsException(): void - { - $this->expectException(InvalidArgumentException::class); - - $this->format( - '03603568000195', - hidden: true, - hiddenStart: 14 - ); - } - - public function testOptionWithRangeEndMinusOneThrowsException(): void - { - $this->expectException(InvalidArgumentException::class); - - $this->format( - '03603568000195', - hidden: true, - hiddenEnd: -1 - ); - } - - public function testOptionWithRangeEndGreaterThan13ThrowsException(): void - { - $this->expectException(InvalidArgumentException::class); - - $this->format( - '03603568000195', - hidden: true, - hiddenEnd: 14 - ); - } - - public function testCnpjFormatsToHiddenFormatWithCustomKeyAndStartRange(): void - { - $cnpj = $this->format( - '03603568000195', - hidden: true, - hiddenKey: '#', - hiddenStart: 8 - ); - - $this->assertEquals('03.603.568/####-##', $cnpj); - } - - public function testCnpjFormatsToHiddenFormatWithCustomKeyAndEndRange(): void - { - $cnpj = $this->format( - '03603568000195', - hidden: true, - hiddenKey: '#', - hiddenEnd: 11 - ); - - $this->assertEquals('03.603.###/####-95', $cnpj); - } - - public function testCnpjFormatsToHiddenFormatWithCustomKeyAndBothRanges(): void - { - $cnpj = $this->format( - '03603568000195', - hidden: true, - hiddenKey: '#', - hiddenStart: 0, - hiddenEnd: 7 - ); - - $this->assertEquals('##.###.###/0001-95', $cnpj); - } - - public function testCnpjFormatsToHiddenFormatWithCustomKeyAndReversedRange(): void - { - $cnpj = $this->format( - '03603568000195', - hidden: true, - hiddenKey: '#', - hiddenStart: 11, - hiddenEnd: 2 - ); - - $this->assertEquals('03.###.###/####-95', $cnpj); - } - - public function testOptionWithOnFailAsNotFunctionThrowsException(): void - { - $this->expectException(TypeError::class); - - $this->format( - '03603568000195', - onFail: 'testing' - ); - } -} diff --git a/packages/cnpj-fmt/tests/Pest.php b/packages/cnpj-fmt/tests/Pest.php new file mode 100644 index 0000000..1f38341 --- /dev/null +++ b/packages/cnpj-fmt/tests/Pest.php @@ -0,0 +1,13 @@ +in(__DIR__ . DIRECTORY_SEPARATOR . 'Specs'); diff --git a/packages/cnpj-fmt/tests/Specs/CnpjFormatter.spec.php b/packages/cnpj-fmt/tests/Specs/CnpjFormatter.spec.php new file mode 100644 index 0000000..860add3 --- /dev/null +++ b/packages/cnpj-fmt/tests/Specs/CnpjFormatter.spec.php @@ -0,0 +1,478 @@ +getOptions()->getAll())->toBe($defaultOptions->getAll()); + }); + }); + + describe('when called with arguments', function () { + it('uses the provided options instance', function () { + $options = new CnpjFormatterOptions(); + + $formatter = new CnpjFormatter($options); + + expect($formatter->getOptions())->toBe($options); + }); + + it('overrides the default options with the provided ones (named arguments)', function () { + $options = [ + 'hidden' => true, + 'slashKey' => '|', + 'dotKey' => '_', + 'encode' => true, + ]; + + $formatter = new CnpjFormatter(...$options); + + expect($formatter->getOptions()->getAll())->toMatchArray($options); + }); + + it('overrides the default options with the provided ones (`CnpjFormatterOptions` instance)', function () { + $options = new CnpjFormatterOptions( + hidden: true, + slashKey: '|', + dotKey: '_', + encode: true, + ); + + $formatter = new CnpjFormatter($options); + + expect($formatter->getOptions()->getAll())->toBe($options->getAll()); + }); + }); + }); + + describe('`format` method', function () { + $format = null; + + beforeEach(function () use (&$format) { + $formatter = new CnpjFormatter(); + + $format = Closure::fromCallable([$formatter, 'format']); + }); + + describe('when input is a string with only digits', function () use (&$format) { + it('handles the input with no formatting', function () use (&$format) { + $result = $format('12345678000910'); + + expect($result)->toBe('12.345.678/0009-10'); + }); + + it('handles the input with standard formatting', function () use (&$format) { + $result = $format('12.345.678/0009-10'); + + expect($result)->toBe('12.345.678/0009-10'); + }); + + it('handles the input with custom formatting', function () use (&$format) { + $result = $format('12 345 678 | 0009 _ 10'); + + expect($result)->toBe('12.345.678/0009-10'); + }); + }); + + describe('when input is a string with only letters', function () use (&$format) { + it('handles the input with no formatting', function () use (&$format) { + $result = $format('ABCDEFGHIJKLMN'); + + expect($result)->toBe('AB.CDE.FGH/IJKL-MN'); + }); + + it('handles the input with standard formatting', function () use (&$format) { + $result = $format('AB.CDE.FGH/IJKL-MN'); + + expect($result)->toBe('AB.CDE.FGH/IJKL-MN'); + }); + + it('handles the input with custom formatting', function () use (&$format) { + $result = $format('AB CDE FGH | IJKL _ MN'); + + expect($result)->toBe('AB.CDE.FGH/IJKL-MN'); + }); + + it('converts lowercase letters to uppercase', function () use (&$format) { + $result = $format('AbCdEfGhIjKlMn'); + + expect($result)->toBe('AB.CDE.FGH/IJKL-MN'); + }); + }); + + describe('when input is a string with mixed digits and letters characters', function () use (&$format) { + it('handles the input with no formatting', function () use (&$format) { + $result = $format('12ABC34500DE00'); + + expect($result)->toBe('12.ABC.345/00DE-00'); + }); + + it('handles the input with standard formatting', function () use (&$format) { + $result = $format('12.ABC.345/00DE-00'); + + expect($result)->toBe('12.ABC.345/00DE-00'); + }); + + it('handles the input with custom formatting', function () use (&$format) { + $result = $format('12 ABC 345 | 00DE _ 00'); + + expect($result)->toBe('12.ABC.345/00DE-00'); + }); + + it('converts lowercase letters to uppercase', function () use (&$format) { + $result = $format('12abcDEF00eF00'); + + expect($result)->toBe('12.ABC.DEF/00EF-00'); + }); + }); + + describe('when input is an array', function () use (&$format) { + it('handles array of only digits', function () use (&$format) { + $result = $format(['1', '2', '3', '4', '5', '6', '7', '8', '0', '0', '0', '9', '1', '0', ]); + + expect($result)->toBe('12.345.678/0009-10'); + }); + + it('handles array of single item with only digits', function () use (&$format) { + $result = $format(['12345678000910']); + + expect($result)->toBe('12.345.678/0009-10'); + }); + + it('handles array of grouped digits', function () use (&$format) { + $result = $format(['12', '345', '678', '0009', '10']); + + expect($result)->toBe('12.345.678/0009-10'); + }); + + it('handles array of grouped digits and punctuation', function () use (&$format) { + $result = $format(['12', '.', '345', '.', '678', '/', '0009', '-', '10']); + + expect($result)->toBe('12.345.678/0009-10'); + }); + + it('handles array of only letters', function () use (&$format) { + $result = $format(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N']); + + expect($result)->toBe('AB.CDE.FGH/IJKL-MN'); + }); + + it('handles array of single item with only letters', function () use (&$format) { + $result = $format(['ABCDEFGHIJKLMN']); + + expect($result)->toBe('AB.CDE.FGH/IJKL-MN'); + }); + + it('handles array of lowercase letters', function () use (&$format) { + $result = $format(['abcdefghijklmn']); + + expect($result)->toBe('AB.CDE.FGH/IJKL-MN'); + }); + + it('handles array of grouped letters', function () use (&$format) { + $result = $format(['AB', 'CDE', 'FGH', 'IJKL', 'MN']); + + expect($result)->toBe('AB.CDE.FGH/IJKL-MN'); + }); + + it('handles array of grouped letters and punctuation', function () use (&$format) { + $result = $format(['AB', '.', 'CDE', '.', 'FGH', '/', 'IJKL', '-', 'MN']); + + expect($result)->toBe('AB.CDE.FGH/IJKL-MN'); + }); + + it('handles array of mixed digits and letters', function () use (&$format) { + $result = $format(['1', '2', 'a', 'b', 'c', 'D', 'E', 'F', '0', '0', 'g', 'H', '3', '4']); + + expect($result)->toBe('12.ABC.DEF/00GH-34'); + }); + + it('handles array of single item with mixed digits and letters', function () use (&$format) { + $result = $format(['12abcDEF00gH34']); + + expect($result)->toBe('12.ABC.DEF/00GH-34'); + }); + + it('handles array of grouped digits and letters', function () use (&$format) { + $result = $format(['12', 'abc', 'DEF', '00gH', '34']); + + expect($result)->toBe('12.ABC.DEF/00GH-34'); + }); + + it('handles array of grouped digits, letters and punctuation', function () use (&$format) { + $result = $format(['12', '.', 'abc', '.', 'DEF', '/', '00gH', '-', '34']); + + expect($result)->toBe('12.ABC.DEF/00GH-34'); + }); + }); + + describe('when input is not string or array of strings', function () use (&$format) { + it('throws CnpjFormatterInputTypeError on input of null', function () use (&$format) { + try { + $format(null); + expect(false)->toBeTrue('expected exception'); + } catch (CnpjFormatterInputTypeError $error) { + expect($error)->toBeInstanceOf(CnpjFormatterInputTypeError::class); + expect($error->expectedType)->toBe('string or string[]'); + expect($error->actualInput)->toBeNull(); + expect($error->actualType)->toBe('null'); + } + }); + + it('throws CnpjFormatterInputTypeError on integer number', function () use (&$format) { + try { + $format(42); + + expect(false)->toBeTrue('expected exception'); + } catch (CnpjFormatterInputTypeError $error) { + expect($error->expectedType)->toBe('string or string[]'); + expect($error->actualInput)->toBe(42); + expect($error->actualType)->toBe('integer number'); + } + }); + + it('throws CnpjFormatterInputTypeError on float number', function () use (&$format) { + try { + $format(3.14); + + expect(false)->toBeTrue('expected exception'); + } catch (CnpjFormatterInputTypeError $error) { + expect($error->expectedType)->toBe('string or string[]'); + expect($error->actualInput)->toBe(3.14); + expect($error->actualType)->toBe('float number'); + } + }); + + it('throws CnpjFormatterInputTypeError on boolean false', function () use (&$format) { + try { + $format(false); + + expect(false)->toBeTrue('expected exception'); + } catch (CnpjFormatterInputTypeError $error) { + expect($error->expectedType)->toBe('string or string[]'); + expect($error->actualInput)->toBeFalse(); + expect($error->actualType)->toBe('boolean'); + } + }); + + it('throws CnpjFormatterInputTypeError on boolean true', function () use (&$format) { + try { + $format(true); + + expect(false)->toBeTrue('expected exception'); + } catch (CnpjFormatterInputTypeError $error) { + expect($error->expectedType)->toBe('string or string[]'); + expect($error->actualInput)->toBeTrue(); + expect($error->actualType)->toBe('boolean'); + } + }); + + it('throws CnpjFormatterInputTypeError on object', function () use (&$format) { + $input = (object) []; + + try { + $format($input); + + expect(false)->toBeTrue('expected exception'); + } catch (CnpjFormatterInputTypeError $error) { + expect($error->expectedType)->toBe('string or string[]'); + expect($error->actualInput)->toBe($input); + expect($error->actualType)->toBe('object'); + } + }); + + it('throws CnpjFormatterInputTypeError for arrays containing non-strings', function () use (&$format) { + $input = ['12', 34, '56']; + + try { + $format($input); + + expect(false)->toBeTrue('expected exception'); + } catch (CnpjFormatterInputTypeError $error) { + expect($error->expectedType)->toBe('string or string[]'); + expect($error->actualInput)->toBe($input); + } + }); + }); + + describe('when sanitized input length is not 14', function () use (&$format) { + $makeOnFail = static function (int $evaluatedLength): \Closure { + return static function (mixed $value, CnpjFormatterInputLengthException $error) use ($evaluatedLength): string { + expect($error)->toBeInstanceOf(CnpjFormatterInputLengthException::class); + expect(mb_strlen($error->evaluatedInput, 'UTF-8'))->toBe($evaluatedLength); + expect($error->actualInput)->toBe($value); + + return 'ERROR: "' . (is_string($value) ? $value : json_encode($value)) . '"'; + }; + }; + + it('fails with CnpjFormatterInputLengthException on short inputs', function () use (&$format, $makeOnFail) { + $cases = [ + ['1', 1], + ['12', 2], + ['12.A', 3], + ['12.AB', 4], + ['12.ABC', 5], + ['12.ABC.3', 6], + ['12.ABC.34', 7], + ['12.ABC.345', 8], + ['12.ABC.345/0', 9], + ['12.ABC.345/00', 10], + ['12.ABC.345/00D', 11], + ['12.ABC.345/00DE', 12], + ['12.ABC.345/00DE-6', 13], + ['12.ABC.345/00DE-678', 15], + ]; + + foreach ($cases as [$input, $length]) { + $format($input, onFail: $makeOnFail($length)); + } + }); + }); + + describe('when using `hidden` option', function () use (&$format) { + $defaultHiddenLength = CnpjFormatterOptions::DEFAULT_HIDDEN_END - CnpjFormatterOptions::DEFAULT_HIDDEN_START + 1; + $standardCnpjFormatLength = strlen('00.000.000/0000-00'); + + it('replaces some characters with "*" when simply `true`', function () use (&$format, $defaultHiddenLength, $standardCnpjFormatLength) { + $result = $format('12ABC34500DE99', hidden: true); + $hiddenCount = substr_count($result, '*'); + + expect($hiddenCount)->toBe($defaultHiddenLength); + expect(mb_strlen($result, 'UTF-8'))->toBe($standardCnpjFormatLength); + }); + + it('replaces characters with "*" in a given range', function () use (&$format, $standardCnpjFormatLength) { + $result = $format('12ABC34500DE99', hidden: true, hiddenStart: 8, hiddenEnd: 11); + + expect($result)->toBe('12.ABC.345/****-99'); + expect(mb_strlen($result, 'UTF-8'))->toBe($standardCnpjFormatLength); + }); + + it('replaces characters with a custom key', function () use (&$format, $defaultHiddenLength, $standardCnpjFormatLength) { + $result = $format('12ABC34500DE99', hidden: true, hiddenKey: '#'); + $hiddenCount = substr_count($result, '#'); + + expect($result)->not->toContain('*'); + expect($hiddenCount)->toBe($defaultHiddenLength); + expect(mb_strlen($result, 'UTF-8'))->toBe($standardCnpjFormatLength); + }); + + it('replaces characters with a custom zero-width key', function () use (&$format, $defaultHiddenLength, $standardCnpjFormatLength) { + $result = $format('12ABC34500DE99', hidden: true, hiddenKey: ''); + + expect($result)->not->toContain('*'); + expect(mb_strlen($result, 'UTF-8'))->toBe($standardCnpjFormatLength - $defaultHiddenLength); + }); + + it('replaces characters with a custom multi-character key', function () use (&$format, $defaultHiddenLength, $standardCnpjFormatLength) { + $result = $format('12ABC34500DE99', hidden: true, hiddenKey: '[]'); + preg_match_all('/\\[\\]/', $result, $matches); + $bracketPairs = strlen(implode('', $matches[0])) / 2; + + expect($result)->not->toContain('*'); + expect($bracketPairs)->toBe($defaultHiddenLength); + expect(mb_strlen($result, 'UTF-8'))->toBe($standardCnpjFormatLength + $defaultHiddenLength); + }); + }); + + describe('when customizing punctuation', function () use (&$format) { + it('replaces dots with a custom key', function () use (&$format) { + $result = $format('12ABC34500DE99', dotKey: ' '); + + expect($result)->toBe('12 ABC 345/00DE-99'); + }); + + it('replaces dots with a custom zero-width key', function () use (&$format) { + expect($format('12ABC34500DE99', ['dotKey' => '']))->toBe('12ABC345/00DE-99'); + }); + + it('replaces dots with a custom multi-character key', function () use (&$format) { + $result = $format('12ABC34500DE99', dotKey: '[]'); + + expect($result)->toBe('12[]ABC[]345/00DE-99'); + }); + + it('replaces slash with a custom key', function () use (&$format) { + $result = $format('12ABC34500DE99', slashKey: '|'); + + expect($result)->toBe('12.ABC.345|00DE-99'); + }); + + it('replaces slash with a custom zero-width key', function () use (&$format) { + $result = $format('12ABC34500DE99', slashKey: ''); + + expect($result)->toBe('12.ABC.34500DE-99'); + }); + + it('replaces slash with a custom multi-character key', function () use (&$format) { + $result = $format('12ABC34500DE99', slashKey: '[]'); + + expect($result)->toBe('12.ABC.345[]00DE-99'); + }); + + it('replaces dash with a custom key', function () use (&$format) { + $result = $format('12ABC34500DE99', dashKey: '_'); + + expect($result)->toBe('12.ABC.345/00DE_99'); + }); + + it('replaces dash with a custom zero-width key', function () use (&$format) { + $result = $format('12ABC34500DE99', dashKey: ''); + + expect($result)->toBe('12.ABC.345/00DE99'); + }); + + it('replaces dash with a custom multi-character key', function () use (&$format) { + $result = $format('12ABC34500DE99', dashKey: '[]'); + + expect($result)->toBe('12.ABC.345/00DE[]99'); + }); + }); + + describe('when using `escape` option ', function () use (&$format) { + it('escapes HTML special characters', function () use (&$format) { + $result = $format('12ABC34500DE99', dotKey: '&', slashKey: '"', dashKey: '<>', escape: true); + + expect($result)->toBe('12&ABC&345"00DE<>99'); + }); + }); + + describe('when using `encode` option ', function () use (&$format) { + it('URL-encodes the result', function () use (&$format) { + $result = $format('12ABC34500DE99', encode: true); + + expect($result)->toBe('12.ABC.345%2F00DE-99'); + }); + }); + + describe('edge cases', function () use (&$format) { + it('replaces `hiddenKey`, `dotKey`, `slashKey` and `dashKey` use multi-characters value', function () use (&$format) { + $result = $format( + '12ABC34500DE99', + hidden: true, + hiddenStart: 5, + hiddenEnd: 9, + hiddenKey: '[*]', + dotKey: '[.]', + slashKey: '[/]', + dashKey: '[-]', + ); + + expect($result)->toBe('12[.]ABC[.][*][*][*][/][*][*]DE[-]99'); + }); + }); + }); +}); diff --git a/packages/cnpj-fmt/tests/Specs/CnpjFormatterOptions.spec.php b/packages/cnpj-fmt/tests/Specs/CnpjFormatterOptions.spec.php new file mode 100644 index 0000000..6cb5dff --- /dev/null +++ b/packages/cnpj-fmt/tests/Specs/CnpjFormatterOptions.spec.php @@ -0,0 +1,1029 @@ + CnpjFormatterOptions::DEFAULT_HIDDEN, + 'hiddenKey' => CnpjFormatterOptions::DEFAULT_HIDDEN_KEY, + 'hiddenStart' => CnpjFormatterOptions::DEFAULT_HIDDEN_START, + 'hiddenEnd' => CnpjFormatterOptions::DEFAULT_HIDDEN_END, + 'dotKey' => CnpjFormatterOptions::DEFAULT_DOT_KEY, + 'slashKey' => CnpjFormatterOptions::DEFAULT_SLASH_KEY, + 'dashKey' => CnpjFormatterOptions::DEFAULT_DASH_KEY, + 'escape' => CnpjFormatterOptions::DEFAULT_ESCAPE, + 'encode' => CnpjFormatterOptions::DEFAULT_ENCODE, + 'onFail' => CnpjFormatterOptions::getDefaultOnFail(), + ]; + + describe('constructor', function () use ($defaultParameters) { + describe('when called with no parameters', function () use ($defaultParameters) { + it('sets all options to default values', function () use ($defaultParameters) { + $options = new CnpjFormatterOptions(); + + expect($options->getAll())->toBe($defaultParameters); + }); + }); + + describe('when called with all parameters with null values', function () use ($defaultParameters) { + it('sets all options to default values', function () use ($defaultParameters) { + $options = new CnpjFormatterOptions( + hidden: null, + hiddenKey: null, + hiddenStart: null, + hiddenEnd: null, + dotKey: null, + slashKey: null, + dashKey: null, + escape: null, + encode: null, + onFail: null, + ); + + expect($options->getAll())->toBe($defaultParameters); + }); + }); + + describe('when called with all parameters', function () { + it('sets all options to the provided values', function () { + $parameters = [ + 'hidden' => true, + 'hiddenKey' => '#', + 'hiddenStart' => 1, + 'hiddenEnd' => 8, + 'dotKey' => '|', + 'slashKey' => '_', + 'dashKey' => '~', + 'escape' => true, + 'encode' => true, + 'onFail' => function (mixed $value): string { + return "ERROR: {$value}"; + }, + ]; + + $options = new CnpjFormatterOptions(...$parameters); + + expect($options->getAll())->toBe($parameters); + }); + }); + + describe('when called with some parameters', function () use ($defaultParameters) { + it('sets only the provided non-nullish values', function () use ($defaultParameters) { + $options = new CnpjFormatterOptions( + hidden: true, + hiddenKey: '#', + hiddenStart: null, + hiddenEnd: null, + escape: true, + encode: false, + onFail: null, + ); + + expect($options->getAll())->toBe([ + ...$defaultParameters, + 'hidden' => true, + 'hiddenKey' => '#', + 'escape' => true, + 'encode' => false, + ]); + }); + }); + + describe('when called with overrides parameters', function () { + it('uses last param option with 2 params', function () { + $options = new CnpjFormatterOptions( + overrides: [ + ['hiddenKey' => '#'], + ['hiddenKey' => 'X'], + ], + ); + + expect($options->hiddenKey)->toBe('X'); + }); + + it('uses last param option with 1 array and 1 object instance', function () { + $options = new CnpjFormatterOptions( + overrides: [ + ['hiddenKey' => '#'], + new CnpjFormatterOptions(hiddenKey: 'X'), + ], + ); + + expect($options->hiddenKey)->toBe('X'); + }); + + it('uses last param option with 5 params', function () { + $options = new CnpjFormatterOptions( + overrides: [ + ['hiddenKey' => '.'], + new CnpjFormatterOptions(hiddenKey: '_'), + ['hiddenKey' => '#'], + new CnpjFormatterOptions(hiddenKey: 'X'), + ['hiddenKey' => '@'], + ], + ); + + expect($options->hiddenKey)->toBe('@'); + }); + }); + }); + + describe('`hidden` property', function () use ($defaultParameters) { + describe('when setting to a boolean value', function () { + it('sets `hidden` to `true`', function () { + $options = new CnpjFormatterOptions(hidden: false); + + $options->hidden = true; + + expect($options->hidden)->toBeTrue(); + }); + + it('sets `hidden` to `false`', function () { + $options = new CnpjFormatterOptions(hidden: true); + + $options->hidden = false; + + expect($options->hidden)->toBeFalse(); + }); + }); + + describe('when setting to a nullish value', function () use ($defaultParameters) { + it('sets default value for `null`', function () use ($defaultParameters) { + $options = new CnpjFormatterOptions(hidden: !CnpjFormatterOptions::DEFAULT_HIDDEN); + + $options->hidden = null; + + expect($options->hidden)->toBe($defaultParameters['hidden']); + }); + }); + + describe('when setting to a non-boolean value', function () { + it('coerces object value to `true`', function () { + $options = new CnpjFormatterOptions(hidden: false); + + $options->hidden = (object) ['not' => 'a boolean']; + + expect($options->hidden)->toBeTrue(); + }); + + it('coerces truthy string value to `true`', function () { + $options = new CnpjFormatterOptions(hidden: false); + + $options->hidden = 'not a boolean'; + + expect($options->hidden)->toBeTrue(); + }); + + it('coerces truthy number value to `true`', function () { + $options = new CnpjFormatterOptions(hidden: false); + + $options->hidden = 123; + + expect($options->hidden)->toBeTrue(); + }); + + it('coerces empty string value to `false`', function () { + $options = new CnpjFormatterOptions(hidden: false); + + $options->hidden = ''; + + expect($options->hidden)->toBeFalse(); + }); + + it('coerces zero number value to `false`', function () { + $options = new CnpjFormatterOptions(hidden: false); + + $options->hidden = 0; + + expect($options->hidden)->toBeFalse(); + }); + }); + }); + + describe('`hiddenKey` property', function () use ($defaultParameters) { + describe('when setting to a string value', function () { + it('sets `hiddenKey` to the provided value', function () { + $options = new CnpjFormatterOptions(hiddenKey: '*'); + + $options->hiddenKey = 'X'; + + expect($options->hiddenKey)->toBe('X'); + }); + }); + + describe('when setting to a nullish value', function () use ($defaultParameters) { + it('sets default value for `null`', function () use ($defaultParameters) { + $options = new CnpjFormatterOptions(hiddenKey: '#'); + + $options->hiddenKey = null; + + expect($options->hiddenKey)->toBe($defaultParameters['hiddenKey']); + }); + }); + + describe('when setting to a non-string value', function () { + it('throws CnpjFormatterOptionsTypeError with an object', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->hiddenKey = (object) ['not' => 'a string']; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "hiddenKey" must be of type string. Got object.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a number', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->hiddenKey = 123; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "hiddenKey" must be of type string. Got integer number.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a boolean', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->hiddenKey = true; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "hiddenKey" must be of type string. Got boolean.'); + }); + }); + + describe('when setting to a string containing a forbidden character', function () { + it("throws CnpjFormatterOptionsForbiddenKeyCharacterException with %s", function (string $forbiddenChar) { + $options = new CnpjFormatterOptions(); + $forbiddenCharsQuoted = '"' . implode('", "', CnpjFormatterOptions::DISALLOWED_KEY_CHARACTERS) . '"'; + + $msg = 'Value "' . $forbiddenChar . '" for CNPJ formatting option "hiddenKey" contains disallowed characters (' . $forbiddenCharsQuoted . ').'; + + expect(function () use ($options, $forbiddenChar) { + $options->hiddenKey = $forbiddenChar; + })->toThrow(CnpjFormatterOptionsForbiddenKeyCharacterException::class, $msg); + })->with(CnpjFormatterOptions::DISALLOWED_KEY_CHARACTERS); + }); + }); + + describe('`hiddenStart` property', function () use ($defaultParameters) { + describe('when setting to a number value', function () { + it('sets `hiddenStart` to the provided value', function () { + $options = new CnpjFormatterOptions(hiddenStart: 0); + + $options->hiddenStart = 1; + + expect($options->hiddenStart)->toBe(1); + }); + }); + + describe('when setting to an invalid number value range', function () { + it('throws CnpjFormatterOptionsHiddenRangeInvalidException with a negative number', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->hiddenStart = -1; + })->toThrow(CnpjFormatterOptionsHiddenRangeInvalidException::class, 'CNPJ formatting option "hiddenStart" must be an integer between 0 and 13. Got -1.'); + }); + + it('throws CnpjFormatterOptionsHiddenRangeInvalidException with a number greater than 13', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->hiddenStart = 14; + })->toThrow(CnpjFormatterOptionsHiddenRangeInvalidException::class, 'CNPJ formatting option "hiddenStart" must be an integer between 0 and 13. Got 14.'); + }); + }); + + describe('when setting to a nullish value', function () use ($defaultParameters) { + it('sets default value for `null`', function () use ($defaultParameters) { + $options = new CnpjFormatterOptions(hiddenStart: 0); + + $options->hiddenStart = null; + + expect($options->hiddenStart)->toBe($defaultParameters['hiddenStart']); + }); + }); + + describe('when setting to a non-integer value', function () { + it('throws CnpjFormatterOptionsTypeError with an object', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->hiddenStart = (object) ['not' => 'a number']; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "hiddenStart" must be of type integer. Got object.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a string', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->hiddenStart = 'not a number'; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "hiddenStart" must be of type integer. Got string.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a boolean', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->hiddenStart = true; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "hiddenStart" must be of type integer. Got boolean.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a float number', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->hiddenStart = 1.5; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "hiddenStart" must be of type integer. Got float number.'); + }); + }); + }); + + describe('`hiddenEnd` property', function () use ($defaultParameters) { + describe('when setting to a number value', function () { + it('sets `hiddenEnd` to the provided value', function () { + $options = new CnpjFormatterOptions(hiddenEnd: 13); + + $options->hiddenEnd = 12; + + expect($options->hiddenEnd)->toBe(12); + }); + }); + + describe('when setting to an invalid number value range', function () { + it('throws CnpjFormatterOptionsHiddenRangeInvalidException with a negative number', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->hiddenEnd = -1; + })->toThrow(CnpjFormatterOptionsHiddenRangeInvalidException::class, 'CNPJ formatting option "hiddenEnd" must be an integer between 0 and 13. Got -1.'); + }); + + it('throws CnpjFormatterOptionsHiddenRangeInvalidException with a number greater than 13', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->hiddenEnd = 14; + })->toThrow(CnpjFormatterOptionsHiddenRangeInvalidException::class, 'CNPJ formatting option "hiddenEnd" must be an integer between 0 and 13. Got 14.'); + }); + }); + + describe('when setting to a nullish value', function () use ($defaultParameters) { + it('sets default value for `null`', function () use ($defaultParameters) { + $options = new CnpjFormatterOptions(hiddenEnd: 0); + + $options->hiddenEnd = null; + + expect($options->hiddenEnd)->toBe($defaultParameters['hiddenEnd']); + }); + }); + + describe('when setting to a non-integer value', function () { + it('throws CnpjFormatterOptionsTypeError with an object', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->hiddenEnd = (object) ['not' => 'a number']; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "hiddenEnd" must be of type integer. Got object.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a string', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->hiddenEnd = 'not a number'; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "hiddenEnd" must be of type integer. Got string.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a boolean', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->hiddenEnd = true; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "hiddenEnd" must be of type integer. Got boolean.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a float number', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->hiddenEnd = 1.5; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "hiddenEnd" must be of type integer. Got float number.'); + }); + }); + }); + + describe('`dotKey` property', function () use ($defaultParameters) { + describe('when setting to a string value', function () { + it('sets `dotKey` to the provided value', function () { + $options = new CnpjFormatterOptions(dotKey: '.'); + + $options->dotKey = '_'; + + expect($options->dotKey)->toBe('_'); + }); + }); + + describe('when setting to a nullish value', function () use ($defaultParameters) { + it('sets default value for `null`', function () use ($defaultParameters) { + $options = new CnpjFormatterOptions(dotKey: '_'); + + $options->dotKey = null; + + expect($options->dotKey)->toBe($defaultParameters['dotKey']); + }); + }); + + describe('when setting to a non-string value', function () { + it('throws CnpjFormatterOptionsTypeError with an object', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->dotKey = (object) ['not' => 'a string']; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "dotKey" must be of type string. Got object.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a number', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->dotKey = 123; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "dotKey" must be of type string. Got integer number.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a boolean', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->dotKey = true; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "dotKey" must be of type string. Got boolean.'); + }); + }); + + describe('when setting to a string containing a forbidden key character', function () { + it("throws CnpjFormatterOptionsForbiddenKeyCharacterException with %s", function (string $forbiddenChar) { + $options = new CnpjFormatterOptions(); + $forbiddenCharsQuoted = '"' . implode('", "', CnpjFormatterOptions::DISALLOWED_KEY_CHARACTERS) . '"'; + + $msg = 'Value "' . $forbiddenChar . '" for CNPJ formatting option "dotKey" contains disallowed characters (' . $forbiddenCharsQuoted . ').'; + + expect(function () use ($options, $forbiddenChar) { + $options->dotKey = $forbiddenChar; + })->toThrow(CnpjFormatterOptionsForbiddenKeyCharacterException::class, $msg); + })->with(CnpjFormatterOptions::DISALLOWED_KEY_CHARACTERS); + }); + }); + + describe('`slashKey` property', function () use ($defaultParameters) { + describe('when setting to a string value', function () { + it('sets `slashKey` to the provided value', function () { + $options = new CnpjFormatterOptions(slashKey: '.'); + + $options->slashKey = '_'; + + expect($options->slashKey)->toBe('_'); + }); + }); + + describe('when setting to a nullish value', function () use ($defaultParameters) { + it('sets default value for `null`', function () use ($defaultParameters) { + $options = new CnpjFormatterOptions(slashKey: '_'); + + $options->slashKey = null; + + expect($options->slashKey)->toBe($defaultParameters['slashKey']); + }); + }); + + describe('when setting to a non-string value', function () { + it('throws CnpjFormatterOptionsTypeError with an object', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->slashKey = (object) ['not' => 'a string']; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "slashKey" must be of type string. Got object.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a number', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->slashKey = 123; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "slashKey" must be of type string. Got integer number.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a boolean', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->slashKey = true; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "slashKey" must be of type string. Got boolean.'); + }); + }); + + describe('when setting to a string containing a forbidden key character', function () { + it("throws CnpjFormatterOptionsForbiddenKeyCharacterException with %s", function (string $forbiddenChar) { + $options = new CnpjFormatterOptions(); + $forbiddenCharsQuoted = '"' . implode('", "', CnpjFormatterOptions::DISALLOWED_KEY_CHARACTERS) . '"'; + + $msg = 'Value "' . $forbiddenChar . '" for CNPJ formatting option "slashKey" contains disallowed characters (' . $forbiddenCharsQuoted . ').'; + + expect(function () use ($options, $forbiddenChar) { + $options->slashKey = $forbiddenChar; + })->toThrow(CnpjFormatterOptionsForbiddenKeyCharacterException::class, $msg); + })->with(CnpjFormatterOptions::DISALLOWED_KEY_CHARACTERS); + }); + }); + + describe('`dashKey` property', function () use ($defaultParameters) { + describe('when setting to a string value', function () { + it('sets `dashKey` to the provided value', function () { + $options = new CnpjFormatterOptions(dashKey: '.'); + + $options->dashKey = '_'; + + expect($options->dashKey)->toBe('_'); + }); + }); + + describe('when setting to a nullish value', function () use ($defaultParameters) { + it('sets default value for `null`', function () use ($defaultParameters) { + $options = new CnpjFormatterOptions(dashKey: '_'); + + $options->dashKey = null; + + expect($options->dashKey)->toBe($defaultParameters['dashKey']); + }); + }); + + describe('when setting to a non-string value', function () { + it('throws CnpjFormatterOptionsTypeError with an object', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->dashKey = (object) ['not' => 'a string']; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "dashKey" must be of type string. Got object.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a number', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->dashKey = 123; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "dashKey" must be of type string. Got integer number.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a boolean', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->dashKey = true; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "dashKey" must be of type string. Got boolean.'); + }); + }); + + describe('when setting to a string containing a forbidden key character', function () { + it("throws CnpjFormatterOptionsForbiddenKeyCharacterException with %s", function (string $forbiddenChar) { + $options = new CnpjFormatterOptions(); + $forbiddenCharsQuoted = '"' . implode('", "', CnpjFormatterOptions::DISALLOWED_KEY_CHARACTERS) . '"'; + + $msg = 'Value "' . $forbiddenChar . '" for CNPJ formatting option "dashKey" contains disallowed characters (' . $forbiddenCharsQuoted . ').'; + + expect(function () use ($options, $forbiddenChar) { + $options->dashKey = $forbiddenChar; + })->toThrow(CnpjFormatterOptionsForbiddenKeyCharacterException::class, $msg); + })->with(CnpjFormatterOptions::DISALLOWED_KEY_CHARACTERS); + }); + }); + + describe('`escape` property', function () use ($defaultParameters) { + describe('when setting to a boolean value', function () { + it('sets `escape` to `true`', function () { + $options = new CnpjFormatterOptions(escape: false); + + $options->escape = true; + + expect($options->escape)->toBeTrue(); + }); + + it('sets `escape` to `false`', function () { + $options = new CnpjFormatterOptions(escape: true); + + $options->escape = false; + + expect($options->escape)->toBeFalse(); + }); + }); + + describe('when setting to a nullish value', function () use ($defaultParameters) { + it('sets default value for `null`', function () use ($defaultParameters) { + $options = new CnpjFormatterOptions(escape: !CnpjFormatterOptions::DEFAULT_ESCAPE); + + $options->escape = null; + + expect($options->escape)->toBe($defaultParameters['escape']); + }); + }); + + describe('when setting to a non-boolean value', function () { + it('coerces object value to `true`', function () { + $options = new CnpjFormatterOptions(escape: false); + + $options->escape = (object) ['not' => 'a boolean']; + + expect($options->escape)->toBeTrue(); + }); + + it('coerces truthy string value to `true`', function () { + $options = new CnpjFormatterOptions(escape: false); + + $options->escape = 'not a boolean'; + + expect($options->escape)->toBeTrue(); + }); + + it('coerces truthy number value to `true`', function () { + $options = new CnpjFormatterOptions(escape: false); + + $options->escape = 123; + + expect($options->escape)->toBeTrue(); + }); + + it('coerces empty string value to `false`', function () { + $options = new CnpjFormatterOptions(escape: false); + + $options->escape = ''; + + expect($options->escape)->toBeFalse(); + }); + + it('coerces zero number value to `false`', function () { + $options = new CnpjFormatterOptions(escape: false); + + $options->escape = 0; + + expect($options->escape)->toBeFalse(); + }); + }); + }); + + describe('`encode` property', function () use ($defaultParameters) { + describe('when setting to a boolean value', function () { + it('sets `encode` to `true`', function () { + $options = new CnpjFormatterOptions(encode: false); + + $options->encode = true; + + expect($options->encode)->toBeTrue(); + }); + + it('sets `encode` to `false`', function () { + $options = new CnpjFormatterOptions(encode: true); + + $options->encode = false; + + expect($options->encode)->toBeFalse(); + }); + }); + + describe('when setting to a nullish value', function () use ($defaultParameters) { + it('sets default value for `null`', function () use ($defaultParameters) { + $options = new CnpjFormatterOptions(encode: !CnpjFormatterOptions::DEFAULT_ENCODE); + + $options->encode = null; + + expect($options->encode)->toBe($defaultParameters['encode']); + }); + }); + + describe('when setting to a non-boolean value', function () { + it('coerces object value to `true`', function () { + $options = new CnpjFormatterOptions(encode: false); + + $options->encode = (object) ['not' => 'a boolean']; + + expect($options->encode)->toBeTrue(); + }); + + it('coerces truthy string value to `true`', function () { + $options = new CnpjFormatterOptions(encode: false); + + $options->encode = 'not a boolean'; + + expect($options->encode)->toBeTrue(); + }); + + it('coerces truthy number value to `true`', function () { + $options = new CnpjFormatterOptions(encode: false); + + $options->encode = 123; + + expect($options->encode)->toBeTrue(); + }); + + it('coerces empty string value to `false`', function () { + $options = new CnpjFormatterOptions(encode: false); + + $options->encode = ''; + + expect($options->encode)->toBeFalse(); + }); + + it('coerces zero number value to `false`', function () { + $options = new CnpjFormatterOptions(encode: false); + + $options->encode = 0; + + expect($options->encode)->toBeFalse(); + }); + }); + }); + + describe('`onFail` property', function () use ($defaultParameters) { + describe('when using the default callback value', function () { + it('returns empty string', function () { + $exception = new CnpjFormatterInputLengthException('abc', 'abc', 14); + + $result = (CnpjFormatterOptions::getDefaultOnFail())('some value', $exception); + + expect($result)->toBe(''); + }); + }); + + describe('when setting to a callable value', function () { + it('sets `onFail` to the provided callback', function () { + $callback = function (mixed $value, CnpjFormatterException $e): string { + return "ERROR: {$value}"; + }; + $options = new CnpjFormatterOptions(); + + $options->onFail = $callback; + + expect($options->onFail)->toBe($callback); + }); + }); + + describe('when setting to a nullish value', function () use ($defaultParameters) { + it('sets default callback for `null`', function () use ($defaultParameters) { + $callback = function (mixed $value, CnpjFormatterException $e): string { + return "ERROR: {$value}"; + }; + $options = new CnpjFormatterOptions(onFail: $callback); + + $options->__set('onFail', null); + + expect($options->onFail)->toBe($defaultParameters['onFail']); + }); + }); + + describe('when setting to a non-callable value', function () { + it('throws CnpjFormatterOptionsTypeError with an object', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->onFail = (object) ['not' => 'a function']; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "onFail" must be of type function. Got object.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a string', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->onFail = 'not a function'; + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "onFail" must be of type function. Got string.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a number', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->__set('onFail', 123); + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "onFail" must be of type function. Got integer number.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a boolean', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->__set('onFail', true); + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "onFail" must be of type function. Got boolean.'); + }); + }); + }); + + describe('`getAll` method', function () { + it('returns the all properties with expected types', function () { + $all = (new CnpjFormatterOptions())->getAll(); + + expect($all['hidden'])->toBeBool(); + expect($all['hiddenKey'])->toBeString(); + expect($all['hiddenStart'])->toBeInt(); + expect($all['hiddenEnd'])->toBeInt(); + expect($all['dotKey'])->toBeString(); + expect($all['slashKey'])->toBeString(); + expect($all['dashKey'])->toBeString(); + expect($all['escape'])->toBeBool(); + expect($all['encode'])->toBeBool(); + expect($all['onFail'])->toBeInstanceOf(\Closure::class); + }); + }); + + describe('`setHiddenRange` method', function () use ($defaultParameters) { + describe('when called with valid values', function () { + it('sets `hiddenStart` and `hiddenEnd` to the provided values', function () { + $options = new CnpjFormatterOptions(); + + $options->setHiddenRange(0, 10); + + expect($options->hiddenStart)->toBe(0); + expect($options->hiddenEnd)->toBe(10); + }); + + describe('and `hiddenStart` is equal to `hiddenEnd`', function () { + it('sets `hiddenStart` and `hiddenEnd` with 0 accordingly', function () { + $options = new CnpjFormatterOptions(); + + $options->setHiddenRange(0, 0); + + expect($options->hiddenStart)->toBe(0); + expect($options->hiddenEnd)->toBe(0); + }); + + it('sets `hiddenStart` and `hiddenEnd` with 13 accordingly', function () { + $options = new CnpjFormatterOptions(); + + $options->setHiddenRange(13, 13); + + expect($options->hiddenStart)->toBe(13); + expect($options->hiddenEnd)->toBe(13); + }); + }); + + describe('and `hiddenStart` is greater than `hiddenEnd`', function () { + it('automatically swaps start and end values', function () { + $options = new CnpjFormatterOptions(); + + $options->setHiddenRange(8, 2); + + expect($options->hiddenStart)->toBe(2); + expect($options->hiddenEnd)->toBe(8); + }); + }); + }); + + describe('when called with nullish values', function () use ($defaultParameters) { + it('sets default values for `null` in both fields', function () use ($defaultParameters) { + $options = new CnpjFormatterOptions(); + + $options->setHiddenRange(null, null); + + expect($options->hiddenStart)->toBe($defaultParameters['hiddenStart']); + expect($options->hiddenEnd)->toBe($defaultParameters['hiddenEnd']); + }); + + describe('when setting `hiddenStart` to a nullish value', function () use ($defaultParameters) { + it('sets default value for `null`', function () use ($defaultParameters) { + $options = new CnpjFormatterOptions(hiddenStart: 0); + + $options->setHiddenRange(null, 13); + + expect($options->hiddenStart)->toBe($defaultParameters['hiddenStart']); + expect($options->hiddenEnd)->toBe(13); + }); + }); + + describe('when setting `hiddenEnd` to a nullish value', function () use ($defaultParameters) { + it('sets default value for `null`', function () use ($defaultParameters) { + $options = new CnpjFormatterOptions(hiddenEnd: 13); + + $options->setHiddenRange(0, null); + + expect($options->hiddenStart)->toBe(0); + expect($options->hiddenEnd)->toBe($defaultParameters['hiddenEnd']); + }); + }); + }); + + describe('when called with invalid values', function () { + describe('when setting `hiddenStart` to an invalid number value range', function () { + it('throws CnpjFormatterOptionsHiddenRangeInvalidException with a negative number', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->setHiddenRange(-1, 13); + })->toThrow(CnpjFormatterOptionsHiddenRangeInvalidException::class, 'CNPJ formatting option "hiddenStart" must be an integer between 0 and 13. Got -1.'); + }); + + it('throws CnpjFormatterOptionsHiddenRangeInvalidException with a number greater than 13', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->setHiddenRange(14, 13); + })->toThrow(CnpjFormatterOptionsHiddenRangeInvalidException::class, 'CNPJ formatting option "hiddenStart" must be an integer between 0 and 13. Got 14.'); + }); + }); + + describe('when setting `hiddenEnd` to an invalid number value range', function () { + it('throws CnpjFormatterOptionsHiddenRangeInvalidException with a negative number', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->setHiddenRange(0, -1); + })->toThrow(CnpjFormatterOptionsHiddenRangeInvalidException::class, 'CNPJ formatting option "hiddenEnd" must be an integer between 0 and 13. Got -1.'); + }); + + it('throws CnpjFormatterOptionsHiddenRangeInvalidException with a number greater than 13', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + $options->setHiddenRange(0, 14); + })->toThrow(CnpjFormatterOptionsHiddenRangeInvalidException::class, 'CNPJ formatting option "hiddenEnd" must be an integer between 0 and 13. Got 14.'); + }); + }); + + describe('when setting `hiddenStart` to a non-integer value', function () { + /** + * Invokes {@see CnpjFormatterOptions::setHiddenRange} with `mixed` arguments so intentional + * invalid-type tests do not trip static analysis (the method body still validates at runtime). + */ + function cnpj_formatter_invoke_set_hidden_range(CnpjFormatterOptions $options, mixed $hiddenStart, mixed $hiddenEnd): mixed + { + $reflectedMethod = new ReflectionMethod(CnpjFormatterOptions::class, 'setHiddenRange'); + + return $reflectedMethod->invoke($options, $hiddenStart, $hiddenEnd); + } + + it('throws CnpjFormatterOptionsTypeError with an object', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + cnpj_formatter_invoke_set_hidden_range($options, (object) ['not' => 'a number'], 13); + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "hiddenStart" must be of type integer. Got object.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a string', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + cnpj_formatter_invoke_set_hidden_range($options, 'not a number', 13); + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "hiddenStart" must be of type integer. Got string.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a boolean', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + cnpj_formatter_invoke_set_hidden_range($options, true, 13); + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "hiddenStart" must be of type integer. Got boolean.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a float number', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + cnpj_formatter_invoke_set_hidden_range($options, 1.5, 13); + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "hiddenStart" must be of type integer. Got float number.'); + }); + }); + + describe('when setting `hiddenEnd` to a non-integer value', function () { + it('throws CnpjFormatterOptionsTypeError with an object', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + cnpj_formatter_invoke_set_hidden_range($options, 0, (object) ['not' => 'a number']); + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "hiddenEnd" must be of type integer. Got object.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a string', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + cnpj_formatter_invoke_set_hidden_range($options, 0, 'not a number'); + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "hiddenEnd" must be of type integer. Got string.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a boolean', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + cnpj_formatter_invoke_set_hidden_range($options, 0, true); + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "hiddenEnd" must be of type integer. Got boolean.'); + }); + + it('throws CnpjFormatterOptionsTypeError with a float number', function () { + $options = new CnpjFormatterOptions(); + + expect(function () use ($options) { + cnpj_formatter_invoke_set_hidden_range($options, 0, 1.5); + })->toThrow(CnpjFormatterOptionsTypeError::class, 'CNPJ formatting option "hiddenEnd" must be of type integer. Got float number.'); + }); + }); + }); + }); +}); diff --git a/packages/cnpj-fmt/tests/Specs/Exceptions.spec.php b/packages/cnpj-fmt/tests/Specs/Exceptions.spec.php new file mode 100644 index 0000000..b3f4708 --- /dev/null +++ b/packages/cnpj-fmt/tests/Specs/Exceptions.spec.php @@ -0,0 +1,361 @@ +toBeInstanceOf(TypeError::class); + }); + + it('is an instance of CnpjFormatterTypeError', function () { + $error = new TestTypeError(123, 'number', 'string', 'some error'); + + expect($error)->toBeInstanceOf(CnpjFormatterTypeError::class); + }); + + it('sets the `actualInput` property', function () { + $error = new TestTypeError(123, 'number', 'string', 'some error'); + + expect($error->actualInput)->toBe(123); + }); + + it('sets the `actualType` property', function () { + $error = new TestTypeError(123, 'number', 'string', 'some error'); + + expect($error->actualType)->toBe('number'); + }); + + it('sets the `expectedType` property', function () { + $error = new TestTypeError(123, 'number', 'string', 'some error'); + + expect($error->expectedType)->toBe('string'); + }); + + it('has the correct message', function () { + $error = new TestTypeError(123, 'number', 'string', 'some error'); + + expect($error->getMessage())->toBe('some error'); + }); + + it('has the correct name', function () { + $error = new TestTypeError(123, 'number', 'string', 'some error'); + + expect($error->getName())->toBe('TestTypeError'); + }); + }); +}); + +describe('CnpjFormatterInputTypeError', function () { + describe('when instantiated', function () { + it('is an instance of TypeError', function () { + $error = new CnpjFormatterInputTypeError(123, 'string'); + + expect($error)->toBeInstanceOf(TypeError::class); + }); + + it('is an instance of CnpjFormatterTypeError', function () { + $error = new CnpjFormatterInputTypeError(123, 'string'); + + expect($error)->toBeInstanceOf(CnpjFormatterTypeError::class); + }); + + it('sets the `actualInput` property', function () { + $error = new CnpjFormatterInputTypeError(123, 'string'); + + expect($error->actualInput)->toBe(123); + }); + + it('sets the `actualType` property', function () { + $error = new CnpjFormatterInputTypeError(123, 'string'); + + expect($error->actualType)->toBe('integer number'); + }); + + it('sets the `expectedType` property', function () { + $error = new CnpjFormatterInputTypeError(123, 'string or string[]'); + + expect($error->expectedType)->toBe('string or string[]'); + }); + + it('has the correct message', function () { + $actualInput = 123; + $actualType = 'integer number'; + $expectedType = 'string or string[]'; + $message = "CNPJ input must be of type {$expectedType}. Got {$actualType}."; + + $error = new CnpjFormatterInputTypeError($actualInput, $expectedType); + + expect($error->getMessage())->toBe($message); + }); + + it('has the correct name', function () { + $error = new CnpjFormatterInputTypeError(123, 'string or string[]'); + + expect($error->getName())->toBe('CnpjFormatterInputTypeError'); + }); + }); +}); + +describe('CnpjFormatterOptionsTypeError', function () { + describe('when instantiated', function () { + it('is an instance of TypeError', function () { + $error = new CnpjFormatterOptionsTypeError('hidden', 123, 'boolean'); + + expect($error)->toBeInstanceOf(TypeError::class); + }); + + it('is an instance of CnpjFormatterTypeError', function () { + $error = new CnpjFormatterOptionsTypeError('hidden', 123, 'boolean'); + + expect($error)->toBeInstanceOf(CnpjFormatterTypeError::class); + }); + + it('sets the `optionName` property', function () { + $error = new CnpjFormatterOptionsTypeError('hiddenKey', 123, 'boolean'); + + expect($error->optionName)->toBe('hiddenKey'); + }); + + it('sets the `actualInput` property', function () { + $error = new CnpjFormatterOptionsTypeError('hiddenKey', 123, 'boolean'); + + expect($error->actualInput)->toBe(123); + }); + + it('sets the `actualType` property', function () { + $error = new CnpjFormatterOptionsTypeError('hiddenKey', 123, 'boolean'); + + expect($error->actualType)->toBe('integer number'); + }); + + it('sets the `expectedType` property', function () { + $error = new CnpjFormatterOptionsTypeError('hiddenKey', 123, 'boolean'); + + expect($error->expectedType)->toBe('boolean'); + }); + + it('has the correct message', function () { + $optionName = 'hiddenKey'; + $actualInput = 123; + $actualInputType = 'integer number'; + $expectedType = 'boolean'; + $message = "CNPJ formatting option \"{$optionName}\" must be of type {$expectedType}. Got {$actualInputType}."; + + $error = new CnpjFormatterOptionsTypeError($optionName, $actualInput, $expectedType); + + expect($error->getMessage())->toBe($message); + }); + + it('has the correct name', function () { + $error = new CnpjFormatterOptionsTypeError('hiddenKey', 123, 'boolean'); + + expect($error->getName())->toBe('CnpjFormatterOptionsTypeError'); + }); + }); +}); + +describe('CnpjFormatterException', function () { + describe('when instantiated through a subclass', function () { + final class TestException extends CnpjFormatterException + { + } + + it('is an instance of Exception', function () { + $exception = new TestException('some error'); + + expect($exception)->toBeInstanceOf(Exception::class); + }); + + it('is an instance of CnpjFormatterException', function () { + $exception = new TestException('some error'); + + expect($exception)->toBeInstanceOf(CnpjFormatterException::class); + }); + + it('has the correct message', function () { + $exception = new TestException('some exception'); + + expect($exception->getMessage())->toBe('some exception'); + }); + + it('has the correct name', function () { + $exception = new TestException('some error'); + + expect($exception->getName())->toBe('TestException'); + }); + }); +}); + +describe('CnpjFormatterInputLengthException', function () { + describe('when instantiated', function () { + it('is an instance of Exception', function () { + $exception = new CnpjFormatterInputLengthException('1.2.3.4.5', '12345', 14); + + expect($exception)->toBeInstanceOf(Exception::class); + }); + + it('is an instance of CnpjFormatterException', function () { + $exception = new CnpjFormatterInputLengthException('1.2.3.4.5', '12345', 14); + + expect($exception)->toBeInstanceOf(CnpjFormatterException::class); + }); + + it('sets the `actualInput` property', function () { + $exception = new CnpjFormatterInputLengthException('1.2.3.4.5', '12345', 14); + + expect($exception->actualInput)->toBe('1.2.3.4.5'); + }); + + it('sets the `evaluatedInput` property', function () { + $exception = new CnpjFormatterInputLengthException('1.2.3.4.5', '12345', 14); + + expect($exception->evaluatedInput)->toBe('12345'); + }); + + it('sets the `expectedLength` property', function () { + $exception = new CnpjFormatterInputLengthException('1.2.3.4.5', '12345', 14); + + expect($exception->expectedLength)->toBe(14); + }); + + it('has the correct message', function () { + $actualInput = '1.2.3.4.5'; + $evaluatedInput = '12345'; + $expectedLength = 14; + $message = "CNPJ input \"{$actualInput}\" does not contain {$expectedLength} characters. Got " . strlen($evaluatedInput) . " in \"{$evaluatedInput}\"."; + + $exception = new CnpjFormatterInputLengthException($actualInput, $evaluatedInput, $expectedLength); + + expect($exception->getMessage())->toBe($message); + }); + + it('has the correct name', function () { + $exception = new CnpjFormatterInputLengthException('1.2.3.4.5', '12345', 14); + + expect($exception->getName())->toBe('CnpjFormatterInputLengthException'); + }); + }); +}); + +describe('CnpjFormatterOptionsHiddenRangeInvalidException', function () { + describe('when instantiated', function () { + it('is an instance of Exception', function () { + $exception = new CnpjFormatterOptionsHiddenRangeInvalidException('hiddenStart', 123, 0, 13); + + expect($exception)->toBeInstanceOf(Exception::class); + }); + + it('is an instance of CnpjFormatterException', function () { + $exception = new CnpjFormatterOptionsHiddenRangeInvalidException('hiddenStart', 123, 0, 13); + + expect($exception)->toBeInstanceOf(CnpjFormatterException::class); + }); + + it('sets the `optionName` property', function () { + $exception = new CnpjFormatterOptionsHiddenRangeInvalidException('hiddenStart', 123, 0, 13); + + expect($exception->optionName)->toBe('hiddenStart'); + }); + + it('sets the `actualInput` property', function () { + $exception = new CnpjFormatterOptionsHiddenRangeInvalidException('hiddenStart', 123, 0, 13); + + expect($exception->actualInput)->toBe(123); + }); + + it('sets the `minExpectedValue` property', function () { + $exception = new CnpjFormatterOptionsHiddenRangeInvalidException('hiddenStart', 123, 0, 13); + + expect($exception->minExpectedValue)->toBe(0); + }); + + it('sets the `maxExpectedValue` property', function () { + $exception = new CnpjFormatterOptionsHiddenRangeInvalidException('hiddenStart', 123, 0, 13); + + expect($exception->maxExpectedValue)->toBe(13); + }); + + it('has the correct message', function () { + $optionName = 'hiddenStart'; + $actualInput = 123; + $minExpectedValue = 5; + $maxExpectedValue = 13; + $message = "CNPJ formatting option \"{$optionName}\" must be an integer between {$minExpectedValue} and {$maxExpectedValue}. Got {$actualInput}."; + + $exception = new CnpjFormatterOptionsHiddenRangeInvalidException($optionName, $actualInput, $minExpectedValue, $maxExpectedValue); + + expect($exception->getMessage())->toBe($message); + }); + + it('has the correct name', function () { + $exception = new CnpjFormatterOptionsHiddenRangeInvalidException('hiddenStart', 123, 0, 13); + + expect($exception->getName())->toBe('CnpjFormatterOptionsHiddenRangeInvalidException'); + }); + }); +}); + +describe('CnpjFormatterOptionsForbiddenKeyCharacterException', function () { + describe('when instantiated', function () { + it('is an instance of Exception', function () { + $exception = new CnpjFormatterOptionsForbiddenKeyCharacterException('hiddenKey', 'x', ['x']); + + expect($exception)->toBeInstanceOf(Exception::class); + }); + + it('is an instance of CnpjFormatterException', function () { + $exception = new CnpjFormatterOptionsForbiddenKeyCharacterException('hiddenKey', 'x', ['x']); + + expect($exception)->toBeInstanceOf(CnpjFormatterException::class); + }); + + it('sets the `optionName` property', function () { + $exception = new CnpjFormatterOptionsForbiddenKeyCharacterException('hiddenKey', 'x', ['x']); + + expect($exception->optionName)->toBe('hiddenKey'); + }); + + it('sets the `actualInput` property', function () { + $exception = new CnpjFormatterOptionsForbiddenKeyCharacterException('hiddenKey', 'x', ['x']); + + expect($exception->actualInput)->toBe('x'); + }); + + it('sets the `forbiddenCharacters` property', function () { + $exception = new CnpjFormatterOptionsForbiddenKeyCharacterException('hiddenKey', 'x', ['x']); + + expect($exception->forbiddenCharacters)->toBe(['x']); + }); + + it('has the correct message', function () { + $optionName = 'hiddenKey'; + $actualInput = 'x'; + $forbiddenCharacters = implode('", "', ['x', 'Y']); + $message = "Value \"{$actualInput}\" for CNPJ formatting option \"{$optionName}\" contains disallowed characters (\"{$forbiddenCharacters}\")."; + $exception = new CnpjFormatterOptionsForbiddenKeyCharacterException($optionName, $actualInput, ['x', 'Y']); + + expect($exception->getMessage())->toBe($message); + }); + + it('has the correct name', function () { + $exception = new CnpjFormatterOptionsForbiddenKeyCharacterException('hiddenKey', 'x', ['x']); + + expect($exception->getName())->toBe('CnpjFormatterOptionsForbiddenKeyCharacterException'); + }); + }); +}); diff --git a/packages/cnpj-fmt/tests/Specs/cnpj-fmt.spec.php b/packages/cnpj-fmt/tests/Specs/cnpj-fmt.spec.php new file mode 100644 index 0000000..712a140 --- /dev/null +++ b/packages/cnpj-fmt/tests/Specs/cnpj-fmt.spec.php @@ -0,0 +1,25 @@ +format behavior', function () { + $input = '91415732000793'; + $formatter = new CnpjFormatter(); + + expect(cnpj_fmt($input))->toBe($formatter->format($input)); + }); + + it('accepts options and forwards formatting behavior', function () { + $input = '01ABC234000X56'; + $options = ['slashKey' => '|']; + + expect(cnpj_fmt($input, ...$options))->toBe('01.ABC.234|000X-56'); + }); + }); +}); diff --git a/packages/cpf-dv/CHANGELOG.md b/packages/cpf-dv/CHANGELOG.md index 8a390a4..f7cdd5d 100644 --- a/packages/cpf-dv/CHANGELOG.md +++ b/packages/cpf-dv/CHANGELOG.md @@ -1,11 +1,17 @@ # lacus/cpf-dv +## 1.2.0 + +### New Features + +- 4866d4089da3b8b79d7fd3b0b9fe56ad607e4dbd Created **`getName()`** to all package-specific errors and exceptions. Now `CpfCheckDigitsException`, `CpfCheckDigitsTypeError` and all their subclasses can return their class names without namespaces. This change is an API alignment across all **BR Utils** initiatives. + ## 1.1.0 -### Minor Changes +### Refactorings -- d746c9dbf2f9ece8622d009d2e07d4923c2d875a: (refactoring) Dropped duplicate constant declarations. -- 2ee783e2b670819ff751fd1ae76d24026b1486c6: (refactoring) Moved some input parsing logic to dedicate private method inside class `CpfCheckDigits`. +- d746c9dbf2f9ece8622d009d2e07d4923c2d875a: Dropped duplicate constant declarations. +- 2ee783e2b670819ff751fd1ae76d24026b1486c6: Moved some input parsing logic to a dedicated private method inside class `CpfCheckDigits`. ## 1.0.0 diff --git a/packages/cpf-dv/src/Exceptions/CpfCheckDigitsException.php b/packages/cpf-dv/src/Exceptions/CpfCheckDigitsException.php index dd0b621..2d13adf 100644 --- a/packages/cpf-dv/src/Exceptions/CpfCheckDigitsException.php +++ b/packages/cpf-dv/src/Exceptions/CpfCheckDigitsException.php @@ -5,6 +5,7 @@ namespace Lacus\BrUtils\Cpf\Exceptions; use Exception; +use ReflectionClass; /** * Base exception for all `cpf-dv` rules-related errors. @@ -16,4 +17,18 @@ */ abstract class CpfCheckDigitsException extends Exception { + public function __construct(string $message) + { + parent::__construct($message); + } + + /** + * Get the short class name of the exception instance. + */ + public function getName(): string + { + $thisReflection = new ReflectionClass($this); + + return $thisReflection->getShortName(); + } } diff --git a/packages/cpf-dv/src/Exceptions/CpfCheckDigitsTypeError.php b/packages/cpf-dv/src/Exceptions/CpfCheckDigitsTypeError.php index bf7a3a0..e170c03 100644 --- a/packages/cpf-dv/src/Exceptions/CpfCheckDigitsTypeError.php +++ b/packages/cpf-dv/src/Exceptions/CpfCheckDigitsTypeError.php @@ -4,7 +4,7 @@ namespace Lacus\BrUtils\Cpf\Exceptions; -use Throwable; +use ReflectionClass; use TypeError; /** @@ -24,12 +24,20 @@ public function __construct( string $actualType, string $expectedType, string $message, - int $code = 0, - ?Throwable $previous = null, ) { - parent::__construct($message, $code, $previous); + parent::__construct($message); $this->actualInput = $actualInput; $this->actualType = $actualType; $this->expectedType = $expectedType; } + + /** + * Get the short class name of the error instance. + */ + public function getName(): string + { + $thisReflection = new ReflectionClass($this); + + return $thisReflection->getShortName(); + } } diff --git a/packages/cpf-dv/tests/Specs/Exceptions.spec.php b/packages/cpf-dv/tests/Specs/Exceptions.spec.php index 7d97a6b..3187bb7 100644 --- a/packages/cpf-dv/tests/Specs/Exceptions.spec.php +++ b/packages/cpf-dv/tests/Specs/Exceptions.spec.php @@ -4,6 +4,7 @@ namespace Lacus\BrUtils\Cpf\Tests\Specs; +use Exception; use Lacus\BrUtils\Cpf\Exceptions\CpfCheckDigitsException; use Lacus\BrUtils\Cpf\Exceptions\CpfCheckDigitsInputInvalidException; use Lacus\BrUtils\Cpf\Exceptions\CpfCheckDigitsInputLengthException; @@ -11,61 +12,53 @@ use Lacus\BrUtils\Cpf\Exceptions\CpfCheckDigitsTypeError; use TypeError; -final class TestCpfCheckDigitsTypeError extends CpfCheckDigitsTypeError -{ - public function __construct() +describe('CpfCheckDigitsTypeError', function () { + final class TestTypeError extends CpfCheckDigitsTypeError { - parent::__construct(123, 'number', 'string', 'some error'); } -} - -final class TestCpfCheckDigitsException extends CpfCheckDigitsException -{ -} -describe('CpfCheckDigitsTypeError', function () { describe('when instantiated through a subclass', function () { it('is an instance of TypeError', function () { - $error = new TestCpfCheckDigitsTypeError(); + $error = new TestTypeError(123, 'number', 'string', 'some error'); expect($error)->toBeInstanceOf(TypeError::class); }); it('is an instance of CpfCheckDigitsTypeError', function () { - $error = new TestCpfCheckDigitsTypeError(); + $error = new TestTypeError(123, 'number', 'string', 'some error'); expect($error)->toBeInstanceOf(CpfCheckDigitsTypeError::class); }); - it('has the correct class name', function () { - $error = new TestCpfCheckDigitsTypeError(); - - expect($error::class)->toBe(TestCpfCheckDigitsTypeError::class); - }); - it('sets the `actualInput` property', function () { - $error = new TestCpfCheckDigitsTypeError(); + $error = new TestTypeError(123, 'number', 'string', 'some error'); expect($error->actualInput)->toBe(123); }); it('sets the `actualType` property', function () { - $error = new TestCpfCheckDigitsTypeError(); + $error = new TestTypeError(123, 'number', 'string', 'some error'); expect($error->actualType)->toBe('number'); }); it('sets the `expectedType` property', function () { - $error = new TestCpfCheckDigitsTypeError(); + $error = new TestTypeError(123, 'number', 'string or string[]', 'some error'); - expect($error->expectedType)->toBe('string'); + expect($error->expectedType)->toBe('string or string[]'); }); - it('has a `message` property', function () { - $error = new TestCpfCheckDigitsTypeError(); + it('has the correct message', function () { + $error = new TestTypeError(123, 'number', 'string', 'some error'); expect($error->getMessage())->toBe('some error'); }); + + it('has the correct name', function () { + $error = new TestTypeError(123, 'number', 'string', 'some error'); + + expect($error->getName())->toBe('TestTypeError'); + }); }); }); @@ -83,17 +76,10 @@ final class TestCpfCheckDigitsException extends CpfCheckDigitsException expect($error)->toBeInstanceOf(CpfCheckDigitsTypeError::class); }); - it('has the correct class name', function () { - $error = new CpfCheckDigitsInputTypeError(123, 'string'); - - expect($error::class)->toBe(CpfCheckDigitsInputTypeError::class); - }); - it('sets the `actualInput` property', function () { - $input = 123; - $error = new CpfCheckDigitsInputTypeError($input, 'string'); + $error = new CpfCheckDigitsInputTypeError(123, 'string'); - expect($error->actualInput)->toBe($input); + expect($error->actualInput)->toBe(123); }); it('sets the `actualType` property', function () { @@ -108,43 +94,56 @@ final class TestCpfCheckDigitsException extends CpfCheckDigitsException expect($error->expectedType)->toBe('string or string[]'); }); - it('generates a message describing the error', function () { + it('has the correct message', function () { $actualInput = 123; $actualType = 'integer number'; $expectedType = 'string[]'; $actualMessage = "CPF input must be of type {$expectedType}. Got {$actualType}."; - $error = new CpfCheckDigitsInputTypeError($actualInput, $expectedType); + $error = new CpfCheckDigitsInputTypeError( + $actualInput, + $expectedType, + ); expect($error->getMessage())->toBe($actualMessage); }); + + it('has the correct name', function () { + $error = new CpfCheckDigitsInputTypeError(123, 'string'); + + expect($error->getName())->toBe('CpfCheckDigitsInputTypeError'); + }); }); }); describe('CpfCheckDigitsException', function () { + final class TestException extends CpfCheckDigitsException + { + } + describe('when instantiated through a subclass', function () { it('is an instance of Exception', function () { - $exception = new TestCpfCheckDigitsException('some error'); + $exception = new TestException('some error'); - expect($exception)->toBeInstanceOf(\Exception::class); + expect($exception)->toBeInstanceOf(Exception::class); }); it('is an instance of CpfCheckDigitsException', function () { - $exception = new TestCpfCheckDigitsException('some error'); + $exception = new TestException('some error'); expect($exception)->toBeInstanceOf(CpfCheckDigitsException::class); }); - it('has the correct class name', function () { - $exception = new TestCpfCheckDigitsException('some error'); + it('has the correct message', function () { + $exception = new TestException('some error'); - expect($exception::class)->toBe(TestCpfCheckDigitsException::class); + expect($exception->getMessage())->toBe('some error'); }); - it('has a `message` property', function () { - $exception = new TestCpfCheckDigitsException('some error'); + it('has the correct name', function () { + $exception = new TestException('some error'); - expect($exception->getMessage())->toBe('some error'); + expect($exception->getName())->toBe('TestException'); }); }); }); @@ -154,7 +153,7 @@ final class TestCpfCheckDigitsException extends CpfCheckDigitsException it('is an instance of Exception', function () { $exception = new CpfCheckDigitsInputLengthException('1.2.3.4.5', '12345', 12, 14); - expect($exception)->toBeInstanceOf(\Exception::class); + expect($exception)->toBeInstanceOf(Exception::class); }); it('is an instance of CpfCheckDigitsException', function () { @@ -163,12 +162,6 @@ final class TestCpfCheckDigitsException extends CpfCheckDigitsException expect($exception)->toBeInstanceOf(CpfCheckDigitsException::class); }); - it('has the correct class name', function () { - $exception = new CpfCheckDigitsInputLengthException('1.2.3.4.5', '12345', 12, 14); - - expect($exception::class)->toBe(CpfCheckDigitsInputLengthException::class); - }); - it('sets the `actualInput` property', function () { $exception = new CpfCheckDigitsInputLengthException('1.2.3.4.5', '12345', 12, 14); @@ -193,7 +186,7 @@ final class TestCpfCheckDigitsException extends CpfCheckDigitsException expect($exception->maxExpectedLength)->toBe(14); }); - it('generates a message describing the exception', function () { + it('has the correct message', function () { $actualInput = '1.2.3.4.5'; $evaluatedInput = '12345'; $minExpectedLength = 12; @@ -209,6 +202,12 @@ final class TestCpfCheckDigitsException extends CpfCheckDigitsException expect($exception->getMessage())->toBe($actualMessage); }); + + it('has the correct name', function () { + $exception = new CpfCheckDigitsInputLengthException('1.2.3.4.5', '12345', 12, 14); + + expect($exception->getName())->toBe('CpfCheckDigitsInputLengthException'); + }); }); }); @@ -217,7 +216,7 @@ final class TestCpfCheckDigitsException extends CpfCheckDigitsException it('is an instance of Exception', function () { $exception = new CpfCheckDigitsInputInvalidException('1.2.3.4.5', 'repeated digits'); - expect($exception)->toBeInstanceOf(\Exception::class); + expect($exception)->toBeInstanceOf(Exception::class); }); it('is an instance of CpfCheckDigitsException', function () { @@ -226,12 +225,6 @@ final class TestCpfCheckDigitsException extends CpfCheckDigitsException expect($exception)->toBeInstanceOf(CpfCheckDigitsException::class); }); - it('has the correct class name', function () { - $exception = new CpfCheckDigitsInputInvalidException('1.2.3.4.5', 'repeated digits'); - - expect($exception::class)->toBe(CpfCheckDigitsInputInvalidException::class); - }); - it('sets the `actualInput` property', function () { $exception = new CpfCheckDigitsInputInvalidException('1.2.3.4.5', 'repeated digits'); @@ -244,14 +237,23 @@ final class TestCpfCheckDigitsException extends CpfCheckDigitsException expect($exception->reason)->toBe('repeated digits'); }); - it('generates a message describing the exception', function () { + it('has the correct message', function () { $actualInput = '1.2.3.4.5'; $reason = 'repeated digits'; $actualMessage = 'CPF input "'.$actualInput.'" is invalid. '.$reason; - $exception = new CpfCheckDigitsInputInvalidException($actualInput, $reason); + $exception = new CpfCheckDigitsInputInvalidException( + $actualInput, + $reason, + ); expect($exception->getMessage())->toBe($actualMessage); }); + + it('has the correct name', function () { + $exception = new CpfCheckDigitsInputInvalidException('1.2.3.4.5', 'repeated digits'); + + expect($exception->getName())->toBe('CpfCheckDigitsInputInvalidException'); + }); }); });