Skip to content

Commit 71c0c22

Browse files
committed
add Exists validation rule with comprehensive tests
Signed-off-by: Tonko Mulder <[email protected]>
1 parent 477dfa9 commit 71c0c22

File tree

5 files changed

+311
-0
lines changed

5 files changed

+311
-0
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Validation\Rules;
6+
7+
use Attribute;
8+
use InvalidArgumentException;
9+
use Tempest\Database\Id;
10+
use Tempest\Validation\Rule;
11+
12+
use function Tempest\Database\query;
13+
14+
#[Attribute(Attribute::TARGET_PROPERTY)]
15+
final readonly class Exists implements Rule
16+
{
17+
public function __construct(
18+
private string $model,
19+
) {
20+
if (! class_exists($this->model)) {
21+
throw new InvalidArgumentException("Model {$this->model} does not exist");
22+
}
23+
}
24+
25+
public function isValid(mixed $value): bool
26+
{
27+
if ((! is_numeric($value) || is_float($value)) && ! is_object($value)) {
28+
return false;
29+
}
30+
31+
$id = is_object($value) ? $value : new Id($value);
32+
33+
if ($id->id >= PHP_INT_MAX) {
34+
return false;
35+
}
36+
37+
$model = query($this->model)
38+
->select()
39+
->get(id: $id);
40+
41+
return $model !== null;
42+
}
43+
44+
public function message(): string
45+
{
46+
return sprintf('Record for model %1$s does not exist', $this->model);
47+
}
48+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Validation\Tests\Fixtures;
6+
7+
use Tempest\Database\Id;
8+
9+
/** @internal */
10+
final class ValidateExistsModel
11+
{
12+
public function __construct(
13+
public Id $id,
14+
public string $name,
15+
) {}
16+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Validation\Tests\Rules;
6+
7+
use InvalidArgumentException;
8+
use PHPUnit\Framework\Attributes\Test;
9+
use PHPUnit\Framework\TestCase;
10+
use Tempest\Validation\Rules\Exists;
11+
use Tempest\Validation\Tests\Fixtures\ValidateExistsModel;
12+
13+
/**
14+
* @internal
15+
*/
16+
final class ExistsTest extends TestCase
17+
{
18+
#[Test]
19+
public function throws_exception_for_invalid_model_class(): void
20+
{
21+
$this->expectException(InvalidArgumentException::class);
22+
$this->expectExceptionMessage('Model NonExistentModel does not exist');
23+
24+
new Exists('NonExistentModel');
25+
}
26+
27+
#[Test]
28+
public function returns_false_for_null_or_non_integer_values(): void
29+
{
30+
$rule = new Exists(ValidateExistsModel::class);
31+
32+
$this->assertFalse($rule->isValid('string'));
33+
$this->assertFalse($rule->isValid(1.5));
34+
$this->assertFalse($rule->isValid([]));
35+
$this->assertFalse($rule->isValid(true));
36+
$this->assertFalse($rule->isValid(null));
37+
}
38+
39+
#[Test]
40+
public function returns_correct_error_message(): void
41+
{
42+
$rule = new Exists(ValidateExistsModel::class);
43+
44+
$expectedMessage = sprintf('Record for model %s does not exist', ValidateExistsModel::class);
45+
$this->assertSame($expectedMessage, $rule->message());
46+
}
47+
48+
#[Test]
49+
public function can_be_constructed_with_valid_model_class(): void
50+
{
51+
$rule = new Exists(ValidateExistsModel::class);
52+
53+
$this->assertInstanceOf(Exists::class, $rule);
54+
$this->assertStringContainsString(ValidateExistsModel::class, $rule->message());
55+
}
56+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Fixtures\Rules;
6+
7+
use Tempest\Database\Id;
8+
9+
/** @internal */
10+
final class ValidateExistsModel
11+
{
12+
public function __construct(
13+
public Id $id,
14+
public string $name,
15+
) {}
16+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Integration\Http\Rules;
6+
7+
use PHPUnit\Framework\Attributes\Test;
8+
use Tempest\Database\Exceptions\QueryWasInvalid;
9+
use Tempest\Database\Migrations\CreateMigrationsTable;
10+
use Tempest\Validation\Rules\Exists;
11+
use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable;
12+
use Tests\Tempest\Fixtures\Migrations\CreateBookTable;
13+
use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable;
14+
use Tests\Tempest\Fixtures\Modules\Books\Models\Author;
15+
use Tests\Tempest\Fixtures\Modules\Books\Models\Book;
16+
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
17+
18+
/**
19+
* @internal
20+
*/
21+
final class ExistsRuleTest extends FrameworkIntegrationTestCase
22+
{
23+
#[Test]
24+
public function validates_existing_record_returns_true(): void
25+
{
26+
$this->migrate(
27+
CreateMigrationsTable::class,
28+
CreatePublishersTable::class,
29+
CreateAuthorTable::class,
30+
CreateBookTable::class,
31+
);
32+
33+
$book = Book::create(title: 'Timeline Taxi');
34+
35+
$rule = new Exists(Book::class);
36+
37+
$this->assertTrue($rule->isValid($book->id));
38+
}
39+
40+
#[Test]
41+
public function validates_non_existent_record_returns_false(): void
42+
{
43+
$this->migrate(
44+
CreateMigrationsTable::class,
45+
CreatePublishersTable::class,
46+
CreateAuthorTable::class,
47+
CreateBookTable::class,
48+
);
49+
50+
$rule = new Exists(Book::class);
51+
52+
$this->assertFalse($rule->isValid(99999));
53+
$this->assertFalse($rule->isValid(12345));
54+
}
55+
56+
#[Test]
57+
public function validates_multiple_existing_records(): void
58+
{
59+
$this->migrate(
60+
CreateMigrationsTable::class,
61+
CreatePublishersTable::class,
62+
CreateAuthorTable::class,
63+
CreateBookTable::class,
64+
);
65+
66+
$book1 = Book::create(title: 'The Lord of the Rings');
67+
$book2 = Book::create(title: 'The Silmarillion');
68+
$book3 = Book::create(title: 'Unfinished Tales');
69+
70+
$rule = new Exists(Book::class);
71+
72+
$this->assertTrue($rule->isValid($book1->id->id));
73+
$this->assertTrue($rule->isValid($book2->id->id));
74+
$this->assertTrue($rule->isValid($book3->id->id));
75+
76+
$this->assertFalse($rule->isValid(99999));
77+
}
78+
79+
#[Test]
80+
public function validates_different_model_types(): void
81+
{
82+
$this->migrate(
83+
CreateMigrationsTable::class,
84+
CreatePublishersTable::class,
85+
CreateAuthorTable::class,
86+
CreateBookTable::class,
87+
);
88+
89+
$author = Author::create(name: 'B. Roose');
90+
$book = Book::create(title: 'Timeline Taxi');
91+
92+
$authorRule = new Exists(Author::class);
93+
$bookRule = new Exists(Book::class);
94+
95+
$this->assertTrue($authorRule->isValid($author->id->id));
96+
$this->assertTrue($bookRule->isValid($book->id->id));
97+
98+
$this->assertFalse($authorRule->isValid(99999));
99+
$this->assertFalse($bookRule->isValid(99999));
100+
101+
$author2 = Author::create(name: 'B. Roose');
102+
$book2 = Book::create(title: 'Timeline Taxi 2');
103+
104+
$this->assertTrue($authorRule->isValid($author2->id->id));
105+
$this->assertTrue($bookRule->isValid($book2->id->id));
106+
}
107+
108+
#[Test]
109+
public function validates_edge_cases_with_large_id_numbers(): void
110+
{
111+
$this->migrate(
112+
CreateMigrationsTable::class,
113+
CreatePublishersTable::class,
114+
CreateAuthorTable::class,
115+
CreateBookTable::class,
116+
);
117+
118+
$rule = new Exists(Book::class);
119+
120+
$this->assertFalse($rule->isValid(PHP_INT_MAX));
121+
$this->assertFalse($rule->isValid(999999999));
122+
$this->assertFalse($rule->isValid(2147483647)); // Max 32-bit integer
123+
}
124+
125+
#[Test]
126+
public function validates_after_record_deletion(): void
127+
{
128+
$this->migrate(
129+
CreateMigrationsTable::class,
130+
CreatePublishersTable::class,
131+
CreateAuthorTable::class,
132+
CreateBookTable::class,
133+
);
134+
135+
$book = Book::create(title: 'Timeline Taxi Draft');
136+
$bookId = $book->id->id;
137+
138+
$rule = new Exists(Book::class);
139+
140+
$this->assertTrue($rule->isValid($bookId));
141+
142+
$book->delete();
143+
144+
$this->assertFalse($rule->isValid($bookId));
145+
}
146+
147+
#[Test]
148+
public function validates_with_sequential_id_creation(): void
149+
{
150+
$this->migrate(
151+
CreateMigrationsTable::class,
152+
CreatePublishersTable::class,
153+
CreateAuthorTable::class,
154+
CreateBookTable::class,
155+
);
156+
157+
$rule = new Exists(Book::class);
158+
$createdIds = [];
159+
160+
for ($i = 1; $i <= 5; $i++) {
161+
$book = Book::create(title: "Book {$i}");
162+
$createdIds[] = $book->id->id;
163+
164+
$this->assertTrue($rule->isValid($book->id->id));
165+
}
166+
167+
foreach ($createdIds as $id) {
168+
$this->assertTrue($rule->isValid($id));
169+
}
170+
171+
$maxId = max($createdIds);
172+
$this->assertFalse($rule->isValid($maxId + 1));
173+
$this->assertFalse($rule->isValid($maxId + 100));
174+
}
175+
}

0 commit comments

Comments
 (0)