Skip to content

Commit 0d5c8d2

Browse files
committed
test(core): introduce full branch coverage suite and enforce 100% coverage
- Add exhaustive unit test suite for all exception families - Add constructor matrix tests (policy, escalation, overrides, previous wrapping) - Add deterministic stress tests for escalation and instantiation - Add override guard and immutability edge-case tests - Add global policy and reset behavior coverage - Add exhaustive enum verification tests - Add branch tests for DefaultErrorPolicy and DefaultEscalationPolicy - Add interface compliance validation - Ensure all exception subclasses are instantiable - Introduce strict PHPUnit 11 configuration with coverage source filter - Achieve 100% coverage (classes, methods, lines) - Update README with Quality Status section
1 parent bb7a6d2 commit 0d5c8d2

16 files changed

Lines changed: 1633 additions & 0 deletions

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,17 @@ Detailed documentation is available in the [BOOK/](BOOK/) directory:
112112

113113
---
114114

115+
## ✅ Quality Status
116+
117+
- PHP 8.2+
118+
- PHPUnit 11
119+
- 100% Code Coverage
120+
- Zero Warnings
121+
- Immutable Exception Design
122+
- Deterministic Escalation & Policy Engine
123+
124+
---
125+
115126
## 🪪 License
116127

117128
This library is licensed under the **MIT License**.

phpunit.xml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.0/phpunit.xsd"
4+
bootstrap="vendor/autoload.php"
5+
cacheDirectory=".phpunit.cache"
6+
executionOrder="depends,defects"
7+
requireCoverageMetadata="true"
8+
beStrictAboutOutputDuringTests="true"
9+
failOnRisky="true"
10+
failOnWarning="true"
11+
colors="true">
12+
<testsuites>
13+
<testsuite name="default">
14+
<directory>tests</directory>
15+
</testsuite>
16+
</testsuites>
17+
<source>
18+
<include>
19+
<directory>src</directory>
20+
</include>
21+
</source>
22+
</phpunit>
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Maatify\Exceptions\Tests\Unit\Contracts;
6+
7+
use Maatify\Exceptions\Contracts\ApiAwareExceptionInterface;
8+
use Maatify\Exceptions\Contracts\ErrorCategoryInterface;
9+
use Maatify\Exceptions\Contracts\ErrorCodeInterface;
10+
use Maatify\Exceptions\Exception\MaatifyException;
11+
use PHPUnit\Framework\Attributes\CoversClass;
12+
use PHPUnit\Framework\TestCase;
13+
14+
#[CoversClass(MaatifyException::class)]
15+
final class InterfaceComplianceTest extends TestCase
16+
{
17+
public function testApiAwareExceptionInterfaceContract(): void
18+
{
19+
$exception = new class extends MaatifyException {
20+
protected function defaultCategory(): ErrorCategoryInterface
21+
{
22+
return new class implements ErrorCategoryInterface {
23+
public function getValue(): string { return 'TEST_CATEGORY'; }
24+
};
25+
}
26+
protected function defaultErrorCode(): ErrorCodeInterface
27+
{
28+
return new class implements ErrorCodeInterface {
29+
public function getValue(): string { return 'TEST_CODE'; }
30+
};
31+
}
32+
protected function defaultHttpStatus(): int { return 418; }
33+
protected function defaultIsSafe(): bool { return true; }
34+
protected function defaultIsRetryable(): bool { return true; }
35+
};
36+
37+
$this->assertInstanceOf(ApiAwareExceptionInterface::class, $exception);
38+
39+
$this->assertSame(418, $exception->getHttpStatus());
40+
$this->assertSame('TEST_CODE', $exception->getErrorCode()->getValue());
41+
$this->assertSame('TEST_CATEGORY', $exception->getCategory()->getValue());
42+
$this->assertTrue($exception->isSafe());
43+
$this->assertTrue($exception->isRetryable());
44+
$this->assertSame([], $exception->getMeta());
45+
}
46+
47+
public function testCustomEnumCompatibility(): void
48+
{
49+
// Simulate a custom enum for ErrorCode
50+
$customCode = new class implements ErrorCodeInterface {
51+
public function getValue(): string { return 'CUSTOM_ENUM_VAL'; }
52+
};
53+
54+
$exception = new class($customCode) extends MaatifyException {
55+
public function __construct(ErrorCodeInterface $code) {
56+
// Bypass validation for this test since we are injecting a custom code
57+
// that might not be in the default policy allowed list.
58+
// However, MaatifyException validates immediately.
59+
// To test custom enums, we need to ensure the policy allows it OR bypass policy.
60+
// Since we can't change source, we must assume custom enums are used WITH a policy that allows them.
61+
62+
// So we will pass a permissive policy.
63+
parent::__construct(
64+
'Test',
65+
0,
66+
null,
67+
$code,
68+
null,
69+
null,
70+
null,
71+
[],
72+
new class implements \Maatify\Exceptions\Contracts\ErrorPolicyInterface {
73+
public function validate(ErrorCodeInterface $code, ErrorCategoryInterface $category): void {}
74+
public function severity(ErrorCategoryInterface $category): int { return 10; }
75+
}
76+
);
77+
}
78+
protected function defaultCategory(): ErrorCategoryInterface {
79+
return new class implements ErrorCategoryInterface {
80+
public function getValue(): string { return 'CUSTOM_CAT'; }
81+
};
82+
}
83+
protected function defaultErrorCode(): ErrorCodeInterface {
84+
return new class implements ErrorCodeInterface {
85+
public function getValue(): string { return 'DEFAULT'; }
86+
};
87+
}
88+
protected function defaultHttpStatus(): int { return 500; }
89+
};
90+
91+
$this->assertSame($customCode, $exception->getErrorCode());
92+
}
93+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Maatify\Exceptions\Tests\Unit\Core;
6+
7+
use Maatify\Exceptions\Contracts\ErrorCategoryInterface;
8+
use Maatify\Exceptions\Contracts\ErrorCodeInterface;
9+
use Maatify\Exceptions\Contracts\ErrorPolicyInterface;
10+
use Maatify\Exceptions\Contracts\EscalationPolicyInterface;
11+
use Maatify\Exceptions\Enum\ErrorCategoryEnum;
12+
use Maatify\Exceptions\Enum\ErrorCodeEnum;
13+
use Maatify\Exceptions\Exception\MaatifyException;
14+
use PHPUnit\Framework\Attributes\CoversClass;
15+
use PHPUnit\Framework\Attributes\DataProvider;
16+
use PHPUnit\Framework\TestCase;
17+
18+
#[CoversClass(MaatifyException::class)]
19+
final class ConstructorMatrixTest extends TestCase
20+
{
21+
/**
22+
* @return iterable<string, array{
23+
* string,
24+
* int,
25+
* ?\Throwable,
26+
* ?ErrorCodeInterface,
27+
* ?int,
28+
* ?bool,
29+
* ?bool,
30+
* array<string, mixed>,
31+
* ?ErrorPolicyInterface,
32+
* ?EscalationPolicyInterface
33+
* }>
34+
*/
35+
public static function constructorProvider(): iterable
36+
{
37+
// 1. Minimal
38+
yield 'Minimal' => [
39+
'Msg', 0, null, null, null, null, null, [], null, null
40+
];
41+
42+
// 2. Full Overrides
43+
yield 'Full Overrides' => [
44+
'Msg', 123, new \Exception(), ErrorCodeEnum::DATABASE_CONNECTION_FAILED, 503, true, true, ['k' => 'v'], null, null
45+
];
46+
47+
// 3. Custom Policy
48+
$policy = new class implements ErrorPolicyInterface {
49+
public function validate(ErrorCodeInterface $code, ErrorCategoryInterface $category): void {}
50+
public function severity(ErrorCategoryInterface $category): int { return 100; }
51+
};
52+
yield 'Custom Policy' => [
53+
'Msg', 0, null, ErrorCodeEnum::MAATIFY_ERROR, null, null, null, [], $policy, null
54+
];
55+
56+
// 4. Custom Escalation Policy
57+
$escPolicy = new class implements EscalationPolicyInterface {
58+
public function escalateCategory(ErrorCategoryInterface $c, ErrorCategoryInterface $p, ErrorPolicyInterface $pol): ErrorCategoryInterface { return $c; }
59+
public function escalateHttpStatus(int $c, int $p): int { return $c; }
60+
};
61+
yield 'Custom Escalation' => [
62+
'Msg', 0, null, null, null, null, null, [], null, $escPolicy
63+
];
64+
65+
// 5. Previous ApiAware Exception
66+
$prevApi = new class extends MaatifyException {
67+
protected function defaultCategory(): ErrorCategoryInterface { return ErrorCategoryEnum::SYSTEM; }
68+
protected function defaultErrorCode(): ErrorCodeInterface { return ErrorCodeEnum::MAATIFY_ERROR; }
69+
protected function defaultHttpStatus(): int { return 500; }
70+
};
71+
yield 'Previous ApiAware' => [
72+
'Msg', 0, $prevApi, null, null, null, null, [], null, null
73+
];
74+
}
75+
76+
/**
77+
* @param array<string, mixed> $meta
78+
*/
79+
#[DataProvider('constructorProvider')]
80+
public function testConstructorMatrix(
81+
string $message,
82+
int $code,
83+
?\Throwable $previous,
84+
?ErrorCodeInterface $errorCodeOverride,
85+
?int $httpStatusOverride,
86+
?bool $isSafeOverride,
87+
?bool $isRetryableOverride,
88+
array $meta,
89+
?ErrorPolicyInterface $policy,
90+
?EscalationPolicyInterface $escalationPolicy
91+
): void {
92+
// We need a concrete implementation to instantiate
93+
$exception = new class(
94+
$message,
95+
$code,
96+
$previous,
97+
$errorCodeOverride,
98+
$httpStatusOverride,
99+
$isSafeOverride,
100+
$isRetryableOverride,
101+
$meta,
102+
$policy,
103+
$escalationPolicy
104+
) extends MaatifyException {
105+
protected function defaultCategory(): ErrorCategoryInterface {
106+
return ErrorCategoryEnum::SYSTEM;
107+
}
108+
109+
protected function defaultErrorCode(): ErrorCodeInterface {
110+
return ErrorCodeEnum::MAATIFY_ERROR;
111+
}
112+
113+
protected function defaultHttpStatus(): int {
114+
return 500;
115+
}
116+
};
117+
118+
$this->assertSame($message, $exception->getMessage());
119+
$this->assertSame($code, $exception->getCode());
120+
$this->assertSame($previous, $exception->getPrevious());
121+
122+
if ($errorCodeOverride) {
123+
$this->assertSame($errorCodeOverride, $exception->getErrorCode());
124+
}
125+
126+
if ($httpStatusOverride) {
127+
$this->assertSame($httpStatusOverride, $exception->getHttpStatus());
128+
}
129+
130+
if ($isSafeOverride !== null) {
131+
$this->assertSame($isSafeOverride, $exception->isSafe());
132+
}
133+
134+
if ($isRetryableOverride !== null) {
135+
$this->assertSame($isRetryableOverride, $exception->isRetryable());
136+
}
137+
138+
$this->assertSame($meta, $exception->getMeta());
139+
}
140+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Maatify\Exceptions\Tests\Unit\Core;
6+
7+
use Maatify\Exceptions\Contracts\ErrorCodeInterface;
8+
use Maatify\Exceptions\Enum\ErrorCategoryEnum;
9+
use Maatify\Exceptions\Enum\ErrorCodeEnum;
10+
use Maatify\Exceptions\Exception\MaatifyException;
11+
use PHPUnit\Framework\Attributes\CoversClass;
12+
use PHPUnit\Framework\TestCase;
13+
14+
#[CoversClass(MaatifyException::class)]
15+
final class DeterministicStressTest extends TestCase
16+
{
17+
private function createException(string $message, int $code): MaatifyException
18+
{
19+
return new class($message, $code) extends MaatifyException {
20+
protected function defaultCategory(): ErrorCategoryEnum
21+
{
22+
return ErrorCategoryEnum::SYSTEM;
23+
}
24+
protected function defaultErrorCode(): ErrorCodeInterface
25+
{
26+
return ErrorCodeEnum::MAATIFY_ERROR;
27+
}
28+
protected function defaultHttpStatus(): int
29+
{
30+
return 500;
31+
}
32+
};
33+
}
34+
35+
public function testInstantiationDeterminism(): void
36+
{
37+
$iterations = 100;
38+
$first = null;
39+
40+
for ($i = 0; $i < $iterations; $i++) {
41+
$current = $this->createException('Test', 123);
42+
43+
if ($first === null) {
44+
$first = $current;
45+
} else {
46+
$this->assertSame($first->getMessage(), $current->getMessage());
47+
$this->assertSame($first->getCode(), $current->getCode());
48+
$this->assertSame($first->getCategory(), $current->getCategory());
49+
$this->assertSame($first->getErrorCode(), $current->getErrorCode());
50+
$this->assertSame($first->getHttpStatus(), $current->getHttpStatus());
51+
$this->assertSame($first->isSafe(), $current->isSafe());
52+
$this->assertSame($first->isRetryable(), $current->isRetryable());
53+
}
54+
}
55+
}
56+
57+
public function testEscalationDeterminismLoop(): void
58+
{
59+
// Check that wrapping the same exception repeatedly yields consistent results
60+
$inner = $this->createException('Inner', 0);
61+
62+
$prevCategory = null;
63+
64+
for ($i = 0; $i < 50; $i++) {
65+
$outer = new class($inner) extends MaatifyException {
66+
public function __construct(\Throwable $prev) {
67+
parent::__construct('Outer', 0, $prev);
68+
}
69+
protected function defaultCategory(): ErrorCategoryEnum { return ErrorCategoryEnum::VALIDATION; }
70+
protected function defaultErrorCode(): ErrorCodeInterface { return ErrorCodeEnum::INVALID_ARGUMENT; }
71+
protected function defaultHttpStatus(): int { return 400; }
72+
};
73+
74+
// Validation (50) wraps System (90) -> Should escalate to System
75+
$this->assertSame(ErrorCategoryEnum::SYSTEM, $outer->getCategory());
76+
77+
if ($prevCategory !== null) {
78+
$this->assertSame($prevCategory, $outer->getCategory());
79+
}
80+
$prevCategory = $outer->getCategory();
81+
}
82+
}
83+
}

0 commit comments

Comments
 (0)