···5454 */
5555 public function boot(): void
5656 {
5757+ $this->bootValidationRules();
5858+5759 if ($this->app->runningInConsole()) {
5860 $this->bootForConsole();
5961 }
6262+ }
6363+6464+ /**
6565+ * Register custom validation rules.
6666+ */
6767+ protected function bootValidationRules(): void
6868+ {
6969+ $validator = $this->app->make('validator');
7070+7171+ // Register AT Protocol validation rules
7272+ $validator->extend('nsid', function ($attribute, $value) {
7373+ $rule = new Validation\Rules\Nsid();
7474+ $failed = false;
7575+ $rule->validate($attribute, $value, function () use (&$failed) {
7676+ $failed = true;
7777+ });
7878+7979+ return ! $failed;
8080+ }, 'The :attribute is not a valid NSID.');
8181+8282+ $validator->extend('did', function ($attribute, $value) {
8383+ $rule = new Validation\Rules\Did();
8484+ $failed = false;
8585+ $rule->validate($attribute, $value, function () use (&$failed) {
8686+ $failed = true;
8787+ });
8888+8989+ return ! $failed;
9090+ }, 'The :attribute is not a valid DID.');
9191+9292+ $validator->extend('handle', function ($attribute, $value) {
9393+ $rule = new Validation\Rules\Handle();
9494+ $failed = false;
9595+ $rule->validate($attribute, $value, function () use (&$failed) {
9696+ $failed = true;
9797+ });
9898+9999+ return ! $failed;
100100+ }, 'The :attribute is not a valid handle.');
101101+102102+ $validator->extend('at_uri', function ($attribute, $value) {
103103+ $rule = new Validation\Rules\AtUri();
104104+ $failed = false;
105105+ $rule->validate($attribute, $value, function () use (&$failed) {
106106+ $failed = true;
107107+ });
108108+109109+ return ! $failed;
110110+ }, 'The :attribute is not a valid AT URI.');
111111+112112+ $validator->extend('at_datetime', function ($attribute, $value) {
113113+ $rule = new Validation\Rules\AtDatetime();
114114+ $failed = false;
115115+ $rule->validate($attribute, $value, function () use (&$failed) {
116116+ $failed = true;
117117+ });
118118+119119+ return ! $failed;
120120+ }, 'The :attribute is not a valid AT Protocol datetime.');
121121+122122+ $validator->extend('cid', function ($attribute, $value) {
123123+ $rule = new Validation\Rules\Cid();
124124+ $failed = false;
125125+ $rule->validate($attribute, $value, function () use (&$failed) {
126126+ $failed = true;
127127+ });
128128+129129+ return ! $failed;
130130+ }, 'The :attribute is not a valid CID.');
131131+132132+ $validator->extend('max_graphemes', function ($attribute, $value, $parameters) {
133133+ if (empty($parameters)) {
134134+ return false;
135135+ }
136136+ $rule = new Validation\Rules\MaxGraphemes((int) $parameters[0]);
137137+ $failed = false;
138138+ $rule->validate($attribute, $value, function () use (&$failed) {
139139+ $failed = true;
140140+ });
141141+142142+ return ! $failed;
143143+ }, 'The :attribute may not be greater than :max_graphemes graphemes.');
144144+145145+ $validator->extend('min_graphemes', function ($attribute, $value, $parameters) {
146146+ if (empty($parameters)) {
147147+ return false;
148148+ }
149149+ $rule = new Validation\Rules\MinGraphemes((int) $parameters[0]);
150150+ $failed = false;
151151+ $rule->validate($attribute, $value, function () use (&$failed) {
152152+ $failed = true;
153153+ });
154154+155155+ return ! $failed;
156156+ }, 'The :attribute must be at least :min_graphemes graphemes.');
157157+158158+ $validator->extend('language', function ($attribute, $value) {
159159+ $rule = new Validation\Rules\Language();
160160+ $failed = false;
161161+ $rule->validate($attribute, $value, function () use (&$failed) {
162162+ $failed = true;
163163+ });
164164+165165+ return ! $failed;
166166+ }, 'The :attribute is not a valid BCP 47 language code.');
167167+168168+ // Register replacements for parameterized rules
169169+ $validator->replacer('max_graphemes', function ($message, $attribute, $rule, $parameters) {
170170+ return str_replace(':max_graphemes', $parameters[0], $message);
171171+ });
172172+173173+ $validator->replacer('min_graphemes', function ($message, $attribute, $rule, $parameters) {
174174+ return str_replace(':min_graphemes', $parameters[0], $message);
175175+ });
60176 }
6117762178 /**
+54
src/Validation/Rules/AtDatetime.php
···11+<?php
22+33+namespace SocialDept\Schema\Validation\Rules;
44+55+use Closure;
66+use DateTime;
77+use Illuminate\Contracts\Validation\ValidationRule;
88+99+class AtDatetime implements ValidationRule
1010+{
1111+ /**
1212+ * Run the validation rule.
1313+ */
1414+ public function validate(string $attribute, mixed $value, Closure $fail): void
1515+ {
1616+ if (! is_string($value)) {
1717+ $fail("The {$attribute} must be a string.");
1818+1919+ return;
2020+ }
2121+2222+ if (! $this->isValidAtDatetime($value)) {
2323+ $fail("The {$attribute} is not a valid AT Protocol datetime.");
2424+ }
2525+ }
2626+2727+ /**
2828+ * Validate AT Protocol datetime format.
2929+ *
3030+ * Must be ISO 8601 format with timezone (typically UTC)
3131+ * Example: 2024-01-01T00:00:00Z or 2024-01-01T00:00:00.000Z
3232+ */
3333+ protected function isValidAtDatetime(string $value): bool
3434+ {
3535+ // Try to parse as DateTime with ISO 8601 format
3636+ $datetime = DateTime::createFromFormat(DateTime::ATOM, $value);
3737+3838+ if ($datetime !== false) {
3939+ return true;
4040+ }
4141+4242+ // Also try with milliseconds
4343+ $datetime = DateTime::createFromFormat('Y-m-d\TH:i:s.u\Z', $value);
4444+4545+ if ($datetime !== false) {
4646+ return true;
4747+ }
4848+4949+ // Try standard ISO 8601 with Z
5050+ $datetime = DateTime::createFromFormat('Y-m-d\TH:i:s\Z', $value);
5151+5252+ return $datetime !== false;
5353+ }
5454+}
+98
src/Validation/Rules/AtUri.php
···11+<?php
22+33+namespace SocialDept\Schema\Validation\Rules;
44+55+use Closure;
66+use Illuminate\Contracts\Validation\ValidationRule;
77+88+class AtUri implements ValidationRule
99+{
1010+ /**
1111+ * Run the validation rule.
1212+ */
1313+ public function validate(string $attribute, mixed $value, Closure $fail): void
1414+ {
1515+ if (! is_string($value)) {
1616+ $fail("The {$attribute} must be a string.");
1717+1818+ return;
1919+ }
2020+2121+ if (! $this->isValidAtUri($value)) {
2222+ $fail("The {$attribute} is not a valid AT URI.");
2323+ }
2424+ }
2525+2626+ /**
2727+ * Validate AT URI format.
2828+ *
2929+ * Format: at://did:plc:xyz/collection/rkey
3030+ * or: at://handle.domain/collection/rkey
3131+ */
3232+ protected function isValidAtUri(string $value): bool
3333+ {
3434+ // Must start with at://
3535+ if (! str_starts_with($value, 'at://')) {
3636+ return false;
3737+ }
3838+3939+ // Remove at:// prefix
4040+ $remainder = substr($value, 5);
4141+4242+ // Must have at least authority part
4343+ if (empty($remainder)) {
4444+ return false;
4545+ }
4646+4747+ // Split into authority and path
4848+ $parts = explode('/', $remainder, 2);
4949+ $authority = $parts[0];
5050+5151+ // Validate authority (DID or handle)
5252+ $didRule = new Did();
5353+ $handleRule = new Handle();
5454+5555+ $isValidDid = true;
5656+ $isValidHandle = true;
5757+5858+ $didRule->validate('authority', $authority, function () use (&$isValidDid) {
5959+ $isValidDid = false;
6060+ });
6161+6262+ $handleRule->validate('authority', $authority, function () use (&$isValidHandle) {
6363+ $isValidHandle = false;
6464+ });
6565+6666+ if (! $isValidDid && ! $isValidHandle) {
6767+ return false;
6868+ }
6969+7070+ // If there's a path, validate it
7171+ if (isset($parts[1]) && ! empty($parts[1])) {
7272+ // Path should be collection/rkey format
7373+ $pathParts = explode('/', $parts[1]);
7474+7575+ if (count($pathParts) < 1) {
7676+ return false;
7777+ }
7878+7979+ // Each path segment should be valid
8080+ foreach ($pathParts as $segment) {
8181+ if (empty($segment) || ! $this->isValidPathSegment($segment)) {
8282+ return false;
8383+ }
8484+ }
8585+ }
8686+8787+ return true;
8888+ }
8989+9090+ /**
9191+ * Check if path segment is valid.
9292+ */
9393+ protected function isValidPathSegment(string $segment): bool
9494+ {
9595+ // Path segments should be alphanumeric with dots, hyphens, underscores
9696+ return (bool) preg_match('/^[a-zA-Z0-9._-]+$/', $segment);
9797+ }
9898+}
+76
src/Validation/Rules/Cid.php
···11+<?php
22+33+namespace SocialDept\Schema\Validation\Rules;
44+55+use Closure;
66+use Illuminate\Contracts\Validation\ValidationRule;
77+88+class Cid implements ValidationRule
99+{
1010+ /**
1111+ * Run the validation rule.
1212+ */
1313+ public function validate(string $attribute, mixed $value, Closure $fail): void
1414+ {
1515+ if (! is_string($value)) {
1616+ $fail("The {$attribute} must be a string.");
1717+1818+ return;
1919+ }
2020+2121+ if (! $this->isValidCid($value)) {
2222+ $fail("The {$attribute} is not a valid CID.");
2323+ }
2424+ }
2525+2626+ /**
2727+ * Validate CID format.
2828+ *
2929+ * CID (Content Identifier) is typically base58 or base32 encoded
3030+ * CIDv0: Qm... (base58, 46 characters)
3131+ * CIDv1: b... (base32) or z... (base58)
3232+ */
3333+ protected function isValidCid(string $value): bool
3434+ {
3535+ $length = strlen($value);
3636+3737+ // CIDv0: Starts with Qm and is 46 characters
3838+ if (str_starts_with($value, 'Qm') && $length === 46) {
3939+ return $this->isBase58($value);
4040+ }
4141+4242+ // CIDv1: Starts with 'b' (base32) or 'z' (base58)
4343+ if (str_starts_with($value, 'b') && $length > 10) {
4444+ return $this->isBase32($value);
4545+ }
4646+4747+ if (str_starts_with($value, 'z') && $length > 10) {
4848+ return $this->isBase58($value);
4949+ }
5050+5151+ // Also accept bafy... (base32 CIDv1)
5252+ if (str_starts_with($value, 'bafy') && $length > 10) {
5353+ return $this->isBase32($value);
5454+ }
5555+5656+ return false;
5757+ }
5858+5959+ /**
6060+ * Check if string is valid base58.
6161+ */
6262+ protected function isBase58(string $value): bool
6363+ {
6464+ // Base58 alphabet (no 0, O, I, l)
6565+ return (bool) preg_match('/^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/', $value);
6666+ }
6767+6868+ /**
6969+ * Check if string is valid base32.
7070+ */
7171+ protected function isBase32(string $value): bool
7272+ {
7373+ // Base32 lowercase alphabet
7474+ return (bool) preg_match('/^[a-z2-7]+$/', $value);
7575+ }
7676+}
+44
src/Validation/Rules/Did.php
···11+<?php
22+33+namespace SocialDept\Schema\Validation\Rules;
44+55+use Closure;
66+use Illuminate\Contracts\Validation\ValidationRule;
77+88+class Did implements ValidationRule
99+{
1010+ /**
1111+ * Run the validation rule.
1212+ */
1313+ public function validate(string $attribute, mixed $value, Closure $fail): void
1414+ {
1515+ if (! is_string($value)) {
1616+ $fail("The {$attribute} must be a string.");
1717+1818+ return;
1919+ }
2020+2121+ // Check if Beacon package is available
2222+ if (class_exists('SocialDept\Beacon\Support\Identity')) {
2323+ if (! \SocialDept\Beacon\Support\Identity::isDid($value)) {
2424+ $fail("The {$attribute} is not a valid DID.");
2525+ }
2626+2727+ return;
2828+ }
2929+3030+ // Fallback validation if Beacon is not available
3131+ if (! $this->isValidDid($value)) {
3232+ $fail("The {$attribute} is not a valid DID.");
3333+ }
3434+ }
3535+3636+ /**
3737+ * Fallback DID validation.
3838+ */
3939+ protected function isValidDid(string $value): bool
4040+ {
4141+ // DID format: did:method:method-specific-id
4242+ return (bool) preg_match('/^did:[a-z]+:[a-zA-Z0-9._:%-]+$/', $value);
4343+ }
4444+}
+50
src/Validation/Rules/Handle.php
···11+<?php
22+33+namespace SocialDept\Schema\Validation\Rules;
44+55+use Closure;
66+use Illuminate\Contracts\Validation\ValidationRule;
77+88+class Handle implements ValidationRule
99+{
1010+ /**
1111+ * Run the validation rule.
1212+ */
1313+ public function validate(string $attribute, mixed $value, Closure $fail): void
1414+ {
1515+ if (! is_string($value)) {
1616+ $fail("The {$attribute} must be a string.");
1717+1818+ return;
1919+ }
2020+2121+ // Check if Beacon package is available
2222+ if (class_exists('SocialDept\Beacon\Support\Identity')) {
2323+ if (! \SocialDept\Beacon\Support\Identity::isHandle($value)) {
2424+ $fail("The {$attribute} is not a valid handle.");
2525+ }
2626+2727+ return;
2828+ }
2929+3030+ // Fallback validation if Beacon is not available
3131+ if (! $this->isValidHandle($value)) {
3232+ $fail("The {$attribute} is not a valid handle.");
3333+ }
3434+ }
3535+3636+ /**
3737+ * Fallback handle validation.
3838+ */
3939+ protected function isValidHandle(string $value): bool
4040+ {
4141+ // Handle format: domain.tld (DNS name)
4242+ // Must be at least 3 chars, no spaces, valid DNS characters
4343+ if (strlen($value) < 3 || strlen($value) > 253) {
4444+ return false;
4545+ }
4646+4747+ // Check for valid DNS hostname format
4848+ return (bool) preg_match('/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/', $value);
4949+ }
5050+}
+61
src/Validation/Rules/Language.php
···11+<?php
22+33+namespace SocialDept\Schema\Validation\Rules;
44+55+use Closure;
66+use Illuminate\Contracts\Validation\ValidationRule;
77+88+class Language implements ValidationRule
99+{
1010+ /**
1111+ * Run the validation rule.
1212+ */
1313+ public function validate(string $attribute, mixed $value, Closure $fail): void
1414+ {
1515+ if (! is_string($value)) {
1616+ $fail("The {$attribute} must be a string.");
1717+1818+ return;
1919+ }
2020+2121+ if (! $this->isValidBcp47($value)) {
2222+ $fail("The {$attribute} is not a valid BCP 47 language code.");
2323+ }
2424+ }
2525+2626+ /**
2727+ * Validate BCP 47 language code.
2828+ *
2929+ * Format: language[-script][-region][-variant]
3030+ * Examples: en, en-US, zh-Hans, en-GB-oed
3131+ */
3232+ protected function isValidBcp47(string $value): bool
3333+ {
3434+ // BCP 47 regex pattern
3535+ // Primary language: 2-3 letter code or 4-8 letter code
3636+ // Script: 4 letters (optional)
3737+ // Region: 2 letters or 3 digits (optional)
3838+ // Variant: 5-8 alphanumeric or digit followed by 3 alphanumeric (optional, repeatable)
3939+ $pattern = '/^
4040+ ([a-z]{2,3}|[a-z]{4}|[a-z]{5,8}) # Primary language
4141+ (-[A-Z][a-z]{3})? # Script (optional)
4242+ (-([A-Z]{2}|[0-9]{3}))? # Region (optional)
4343+ (-([a-z0-9]{5,8}|[0-9][a-z0-9]{3}))* # Variant (optional, repeatable)
4444+ (-[a-z]-[a-z0-9]{2,8})* # Extension (optional)
4545+ (-x-[a-z0-9]{1,8})? # Private use (optional)
4646+ $/xi';
4747+4848+ if (! preg_match($pattern, $value)) {
4949+ return false;
5050+ }
5151+5252+ // Additional validation: Check if primary language is valid
5353+ $parts = explode('-', $value);
5454+ $primaryLanguage = strtolower($parts[0]);
5555+5656+ // Language code should be 2-3 characters (ISO 639-1 or 639-2)
5757+ $length = strlen($primaryLanguage);
5858+5959+ return $length >= 2 && $length <= 8;
6060+ }
6161+}
+40
src/Validation/Rules/MaxGraphemes.php
···11+<?php
22+33+namespace SocialDept\Schema\Validation\Rules;
44+55+use Closure;
66+use Illuminate\Contracts\Validation\ValidationRule;
77+88+class MaxGraphemes implements ValidationRule
99+{
1010+ /**
1111+ * Maximum grapheme count.
1212+ */
1313+ protected int $max;
1414+1515+ /**
1616+ * Create a new rule instance.
1717+ */
1818+ public function __construct(int $max)
1919+ {
2020+ $this->max = $max;
2121+ }
2222+2323+ /**
2424+ * Run the validation rule.
2525+ */
2626+ public function validate(string $attribute, mixed $value, Closure $fail): void
2727+ {
2828+ if (! is_string($value)) {
2929+ $fail("The {$attribute} must be a string.");
3030+3131+ return;
3232+ }
3333+3434+ $count = grapheme_strlen($value);
3535+3636+ if ($count > $this->max) {
3737+ $fail("The {$attribute} may not be greater than {$this->max} graphemes.");
3838+ }
3939+ }
4040+}
+40
src/Validation/Rules/MinGraphemes.php
···11+<?php
22+33+namespace SocialDept\Schema\Validation\Rules;
44+55+use Closure;
66+use Illuminate\Contracts\Validation\ValidationRule;
77+88+class MinGraphemes implements ValidationRule
99+{
1010+ /**
1111+ * Minimum grapheme count.
1212+ */
1313+ protected int $min;
1414+1515+ /**
1616+ * Create a new rule instance.
1717+ */
1818+ public function __construct(int $min)
1919+ {
2020+ $this->min = $min;
2121+ }
2222+2323+ /**
2424+ * Run the validation rule.
2525+ */
2626+ public function validate(string $attribute, mixed $value, Closure $fail): void
2727+ {
2828+ if (! is_string($value)) {
2929+ $fail("The {$attribute} must be a string.");
3030+3131+ return;
3232+ }
3333+3434+ $count = grapheme_strlen($value);
3535+3636+ if ($count < $this->min) {
3737+ $fail("The {$attribute} must be at least {$this->min} graphemes.");
3838+ }
3939+ }
4040+}
+28
src/Validation/Rules/Nsid.php
···11+<?php
22+33+namespace SocialDept\Schema\Validation\Rules;
44+55+use Closure;
66+use Illuminate\Contracts\Validation\ValidationRule;
77+use SocialDept\Schema\Parser\Nsid as NsidParser;
88+99+class Nsid implements ValidationRule
1010+{
1111+ /**
1212+ * Run the validation rule.
1313+ */
1414+ public function validate(string $attribute, mixed $value, Closure $fail): void
1515+ {
1616+ if (! is_string($value)) {
1717+ $fail("The {$attribute} must be a string.");
1818+1919+ return;
2020+ }
2121+2222+ try {
2323+ NsidParser::parse($value);
2424+ } catch (\Exception) {
2525+ $fail("The {$attribute} is not a valid NSID.");
2626+ }
2727+ }
2828+}
+94
tests/Unit/Validation/Rules/AtDatetimeTest.php
···11+<?php
22+33+namespace SocialDept\Schema\Tests\Unit\Validation\Rules;
44+55+use Orchestra\Testbench\TestCase;
66+use SocialDept\Schema\Validation\Rules\AtDatetime;
77+88+class AtDatetimeTest extends TestCase
99+{
1010+ protected AtDatetime $rule;
1111+1212+ protected function setUp(): void
1313+ {
1414+ parent::setUp();
1515+1616+ $this->rule = new AtDatetime();
1717+ }
1818+1919+ public function test_it_validates_valid_datetime_with_z(): void
2020+ {
2121+ $valid = '2024-01-01T00:00:00Z';
2222+2323+ $failed = false;
2424+ $this->rule->validate('datetime', $valid, function () use (&$failed) {
2525+ $failed = true;
2626+ });
2727+2828+ $this->assertFalse($failed);
2929+ }
3030+3131+ public function test_it_validates_datetime_with_milliseconds(): void
3232+ {
3333+ $valid = '2024-01-01T12:34:56.789Z';
3434+3535+ $failed = false;
3636+ $this->rule->validate('datetime', $valid, function () use (&$failed) {
3737+ $failed = true;
3838+ });
3939+4040+ $this->assertFalse($failed);
4141+ }
4242+4343+ public function test_it_validates_various_datetime_formats(): void
4444+ {
4545+ $validDatetimes = [
4646+ '2024-01-01T00:00:00Z',
4747+ '2024-12-31T23:59:59Z',
4848+ '2024-06-15T12:30:45Z',
4949+ ];
5050+5151+ foreach ($validDatetimes as $datetime) {
5252+ $failed = false;
5353+ $this->rule->validate('datetime', $datetime, function () use (&$failed) {
5454+ $failed = true;
5555+ });
5656+5757+ $this->assertFalse($failed, "Expected {$datetime} to be valid");
5858+ }
5959+ }
6060+6161+ public function test_it_rejects_invalid_datetime(): void
6262+ {
6363+ $invalid = 'not-a-datetime';
6464+6565+ $failed = false;
6666+ $this->rule->validate('datetime', $invalid, function () use (&$failed) {
6767+ $failed = true;
6868+ });
6969+7070+ $this->assertTrue($failed);
7171+ }
7272+7373+ public function test_it_rejects_datetime_without_timezone(): void
7474+ {
7575+ $invalid = '2024-01-01T00:00:00';
7676+7777+ $failed = false;
7878+ $this->rule->validate('datetime', $invalid, function () use (&$failed) {
7979+ $failed = true;
8080+ });
8181+8282+ $this->assertTrue($failed);
8383+ }
8484+8585+ public function test_it_rejects_non_string(): void
8686+ {
8787+ $failed = false;
8888+ $this->rule->validate('datetime', 123, function () use (&$failed) {
8989+ $failed = true;
9090+ });
9191+9292+ $this->assertTrue($failed);
9393+ }
9494+}
+100
tests/Unit/Validation/Rules/AtUriTest.php
···11+<?php
22+33+namespace SocialDept\Schema\Tests\Unit\Validation\Rules;
44+55+use Orchestra\Testbench\TestCase;
66+use SocialDept\Schema\Validation\Rules\AtUri;
77+88+class AtUriTest extends TestCase
99+{
1010+ protected AtUri $rule;
1111+1212+ protected function setUp(): void
1313+ {
1414+ parent::setUp();
1515+1616+ $this->rule = new AtUri();
1717+ }
1818+1919+ public function test_it_validates_valid_at_uri_with_did(): void
2020+ {
2121+ $valid = 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jwlwj2ctlk26';
2222+2323+ $failed = false;
2424+ $this->rule->validate('uri', $valid, function () use (&$failed) {
2525+ $failed = true;
2626+ });
2727+2828+ $this->assertFalse($failed);
2929+ }
3030+3131+ public function test_it_validates_valid_at_uri_with_handle(): void
3232+ {
3333+ $valid = 'at://user.bsky.social/app.bsky.feed.post/3jwlwj2ctlk26';
3434+3535+ $failed = false;
3636+ $this->rule->validate('uri', $valid, function () use (&$failed) {
3737+ $failed = true;
3838+ });
3939+4040+ $this->assertFalse($failed);
4141+ }
4242+4343+ public function test_it_validates_at_uri_without_path(): void
4444+ {
4545+ $valid = 'at://did:plc:z72i7hdynmk6r22z27h6tvur';
4646+4747+ $failed = false;
4848+ $this->rule->validate('uri', $valid, function () use (&$failed) {
4949+ $failed = true;
5050+ });
5151+5252+ $this->assertFalse($failed);
5353+ }
5454+5555+ public function test_it_rejects_uri_without_at_protocol(): void
5656+ {
5757+ $invalid = 'https://example.com/path';
5858+5959+ $failed = false;
6060+ $this->rule->validate('uri', $invalid, function () use (&$failed) {
6161+ $failed = true;
6262+ });
6363+6464+ $this->assertTrue($failed);
6565+ }
6666+6767+ public function test_it_rejects_invalid_authority(): void
6868+ {
6969+ $invalid = 'at://not a valid authority/path';
7070+7171+ $failed = false;
7272+ $this->rule->validate('uri', $invalid, function () use (&$failed) {
7373+ $failed = true;
7474+ });
7575+7676+ $this->assertTrue($failed);
7777+ }
7878+7979+ public function test_it_rejects_empty_uri(): void
8080+ {
8181+ $invalid = 'at://';
8282+8383+ $failed = false;
8484+ $this->rule->validate('uri', $invalid, function () use (&$failed) {
8585+ $failed = true;
8686+ });
8787+8888+ $this->assertTrue($failed);
8989+ }
9090+9191+ public function test_it_rejects_non_string(): void
9292+ {
9393+ $failed = false;
9494+ $this->rule->validate('uri', 123, function () use (&$failed) {
9595+ $failed = true;
9696+ });
9797+9898+ $this->assertTrue($failed);
9999+ }
100100+}
+88
tests/Unit/Validation/Rules/CidTest.php
···11+<?php
22+33+namespace SocialDept\Schema\Tests\Unit\Validation\Rules;
44+55+use Orchestra\Testbench\TestCase;
66+use SocialDept\Schema\Validation\Rules\Cid;
77+88+class CidTest extends TestCase
99+{
1010+ protected Cid $rule;
1111+1212+ protected function setUp(): void
1313+ {
1414+ parent::setUp();
1515+1616+ $this->rule = new Cid();
1717+ }
1818+1919+ public function test_it_validates_cidv0(): void
2020+ {
2121+ $valid = 'QmXg9Pp2ytZ14xgmQjYEiHjVjMFXzCVVEcRTWJBmLgR39V';
2222+2323+ $failed = false;
2424+ $this->rule->validate('cid', $valid, function () use (&$failed) {
2525+ $failed = true;
2626+ });
2727+2828+ $this->assertFalse($failed);
2929+ }
3030+3131+ public function test_it_validates_cidv1_with_base32(): void
3232+ {
3333+ $valid = 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi';
3434+3535+ $failed = false;
3636+ $this->rule->validate('cid', $valid, function () use (&$failed) {
3737+ $failed = true;
3838+ });
3939+4040+ $this->assertFalse($failed);
4141+ }
4242+4343+ public function test_it_validates_cidv1_with_base58(): void
4444+ {
4545+ $valid = 'zdj7WhuEjrB52m1BisYCtmjH1hSKa7yZ3jEZ9JcXaFRD51wVz';
4646+4747+ $failed = false;
4848+ $this->rule->validate('cid', $valid, function () use (&$failed) {
4949+ $failed = true;
5050+ });
5151+5252+ $this->assertFalse($failed);
5353+ }
5454+5555+ public function test_it_rejects_invalid_cid(): void
5656+ {
5757+ $invalid = 'not-a-cid';
5858+5959+ $failed = false;
6060+ $this->rule->validate('cid', $invalid, function () use (&$failed) {
6161+ $failed = true;
6262+ });
6363+6464+ $this->assertTrue($failed);
6565+ }
6666+6767+ public function test_it_rejects_cidv0_with_wrong_length(): void
6868+ {
6969+ $invalid = 'QmShortCid';
7070+7171+ $failed = false;
7272+ $this->rule->validate('cid', $invalid, function () use (&$failed) {
7373+ $failed = true;
7474+ });
7575+7676+ $this->assertTrue($failed);
7777+ }
7878+7979+ public function test_it_rejects_non_string(): void
8080+ {
8181+ $failed = false;
8282+ $this->rule->validate('cid', 123, function () use (&$failed) {
8383+ $failed = true;
8484+ });
8585+8686+ $this->assertTrue($failed);
8787+ }
8888+}
+82
tests/Unit/Validation/Rules/DidTest.php
···11+<?php
22+33+namespace SocialDept\Schema\Tests\Unit\Validation\Rules;
44+55+use Orchestra\Testbench\TestCase;
66+use SocialDept\Schema\Validation\Rules\Did;
77+88+class DidTest extends TestCase
99+{
1010+ protected Did $rule;
1111+1212+ protected function setUp(): void
1313+ {
1414+ parent::setUp();
1515+1616+ $this->rule = new Did();
1717+ }
1818+1919+ public function test_it_validates_valid_did(): void
2020+ {
2121+ $valid = 'did:plc:abcdef123456';
2222+2323+ $failed = false;
2424+ $this->rule->validate('did', $valid, function () use (&$failed) {
2525+ $failed = true;
2626+ });
2727+2828+ $this->assertFalse($failed);
2929+ }
3030+3131+ public function test_it_validates_various_did_methods(): void
3232+ {
3333+ $validDids = [
3434+ 'did:plc:z72i7hdynmk6r22z27h6tvur',
3535+ 'did:web:example.com',
3636+ 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK',
3737+ ];
3838+3939+ foreach ($validDids as $did) {
4040+ $failed = false;
4141+ $this->rule->validate('did', $did, function () use (&$failed) {
4242+ $failed = true;
4343+ });
4444+4545+ $this->assertFalse($failed, "Expected {$did} to be valid");
4646+ }
4747+ }
4848+4949+ public function test_it_rejects_invalid_did(): void
5050+ {
5151+ $invalid = 'not-a-did';
5252+5353+ $failed = false;
5454+ $this->rule->validate('did', $invalid, function () use (&$failed) {
5555+ $failed = true;
5656+ });
5757+5858+ $this->assertTrue($failed);
5959+ }
6060+6161+ public function test_it_rejects_did_without_method(): void
6262+ {
6363+ $invalid = 'did:';
6464+6565+ $failed = false;
6666+ $this->rule->validate('did', $invalid, function () use (&$failed) {
6767+ $failed = true;
6868+ });
6969+7070+ $this->assertTrue($failed);
7171+ }
7272+7373+ public function test_it_rejects_non_string(): void
7474+ {
7575+ $failed = false;
7676+ $this->rule->validate('did', 123, function () use (&$failed) {
7777+ $failed = true;
7878+ });
7979+8080+ $this->assertTrue($failed);
8181+ }
8282+}
+83
tests/Unit/Validation/Rules/HandleTest.php
···11+<?php
22+33+namespace SocialDept\Schema\Tests\Unit\Validation\Rules;
44+55+use Orchestra\Testbench\TestCase;
66+use SocialDept\Schema\Validation\Rules\Handle;
77+88+class HandleTest extends TestCase
99+{
1010+ protected Handle $rule;
1111+1212+ protected function setUp(): void
1313+ {
1414+ parent::setUp();
1515+1616+ $this->rule = new Handle();
1717+ }
1818+1919+ public function test_it_validates_valid_handle(): void
2020+ {
2121+ $valid = 'user.bsky.social';
2222+2323+ $failed = false;
2424+ $this->rule->validate('handle', $valid, function () use (&$failed) {
2525+ $failed = true;
2626+ });
2727+2828+ $this->assertFalse($failed);
2929+ }
3030+3131+ public function test_it_validates_various_handles(): void
3232+ {
3333+ $validHandles = [
3434+ 'example.com',
3535+ 'user.bsky.social',
3636+ 'my-handle.example.io',
3737+ 'test123.domain.org',
3838+ ];
3939+4040+ foreach ($validHandles as $handle) {
4141+ $failed = false;
4242+ $this->rule->validate('handle', $handle, function () use (&$failed) {
4343+ $failed = true;
4444+ });
4545+4646+ $this->assertFalse($failed, "Expected {$handle} to be valid");
4747+ }
4848+ }
4949+5050+ public function test_it_rejects_invalid_handle(): void
5151+ {
5252+ $invalid = 'invalid handle';
5353+5454+ $failed = false;
5555+ $this->rule->validate('handle', $invalid, function () use (&$failed) {
5656+ $failed = true;
5757+ });
5858+5959+ $this->assertTrue($failed);
6060+ }
6161+6262+ public function test_it_rejects_too_short_handle(): void
6363+ {
6464+ $invalid = 'ab';
6565+6666+ $failed = false;
6767+ $this->rule->validate('handle', $invalid, function () use (&$failed) {
6868+ $failed = true;
6969+ });
7070+7171+ $this->assertTrue($failed);
7272+ }
7373+7474+ public function test_it_rejects_non_string(): void
7575+ {
7676+ $failed = false;
7777+ $this->rule->validate('handle', 123, function () use (&$failed) {
7878+ $failed = true;
7979+ });
8080+8181+ $this->assertTrue($failed);
8282+ }
8383+}
+110
tests/Unit/Validation/Rules/LanguageTest.php
···11+<?php
22+33+namespace SocialDept\Schema\Tests\Unit\Validation\Rules;
44+55+use Orchestra\Testbench\TestCase;
66+use SocialDept\Schema\Validation\Rules\Language;
77+88+class LanguageTest extends TestCase
99+{
1010+ protected Language $rule;
1111+1212+ protected function setUp(): void
1313+ {
1414+ parent::setUp();
1515+1616+ $this->rule = new Language();
1717+ }
1818+1919+ public function test_it_validates_simple_language_code(): void
2020+ {
2121+ $valid = 'en';
2222+2323+ $failed = false;
2424+ $this->rule->validate('language', $valid, function () use (&$failed) {
2525+ $failed = true;
2626+ });
2727+2828+ $this->assertFalse($failed);
2929+ }
3030+3131+ public function test_it_validates_language_with_region(): void
3232+ {
3333+ $valid = 'en-US';
3434+3535+ $failed = false;
3636+ $this->rule->validate('language', $valid, function () use (&$failed) {
3737+ $failed = true;
3838+ });
3939+4040+ $this->assertFalse($failed);
4141+ }
4242+4343+ public function test_it_validates_language_with_script(): void
4444+ {
4545+ $valid = 'zh-Hans';
4646+4747+ $failed = false;
4848+ $this->rule->validate('language', $valid, function () use (&$failed) {
4949+ $failed = true;
5050+ });
5151+5252+ $this->assertFalse($failed);
5353+ }
5454+5555+ public function test_it_validates_complex_language_tags(): void
5656+ {
5757+ $validLanguages = [
5858+ 'en',
5959+ 'en-US',
6060+ 'zh-Hans',
6161+ 'zh-Hans-CN',
6262+ 'en-GB',
6363+ 'es-419',
6464+ 'fr-CA',
6565+ ];
6666+6767+ foreach ($validLanguages as $language) {
6868+ $failed = false;
6969+ $this->rule->validate('language', $language, function () use (&$failed) {
7070+ $failed = true;
7171+ });
7272+7373+ $this->assertFalse($failed, "Expected {$language} to be valid");
7474+ }
7575+ }
7676+7777+ public function test_it_rejects_invalid_language_code(): void
7878+ {
7979+ $invalid = 'not-a-language-123';
8080+8181+ $failed = false;
8282+ $this->rule->validate('language', $invalid, function () use (&$failed) {
8383+ $failed = true;
8484+ });
8585+8686+ $this->assertTrue($failed);
8787+ }
8888+8989+ public function test_it_rejects_too_short_code(): void
9090+ {
9191+ $invalid = 'e';
9292+9393+ $failed = false;
9494+ $this->rule->validate('language', $invalid, function () use (&$failed) {
9595+ $failed = true;
9696+ });
9797+9898+ $this->assertTrue($failed);
9999+ }
100100+101101+ public function test_it_rejects_non_string(): void
102102+ {
103103+ $failed = false;
104104+ $this->rule->validate('language', 123, function () use (&$failed) {
105105+ $failed = true;
106106+ });
107107+108108+ $this->assertTrue($failed);
109109+ }
110110+}
+78
tests/Unit/Validation/Rules/MaxGraphemesTest.php
···11+<?php
22+33+namespace SocialDept\Schema\Tests\Unit\Validation\Rules;
44+55+use Orchestra\Testbench\TestCase;
66+use SocialDept\Schema\Validation\Rules\MaxGraphemes;
77+88+class MaxGraphemesTest extends TestCase
99+{
1010+ public function test_it_validates_string_within_limit(): void
1111+ {
1212+ $rule = new MaxGraphemes(10);
1313+1414+ $valid = 'Hello';
1515+1616+ $failed = false;
1717+ $rule->validate('text', $valid, function () use (&$failed) {
1818+ $failed = true;
1919+ });
2020+2121+ $this->assertFalse($failed);
2222+ }
2323+2424+ public function test_it_rejects_string_exceeding_limit(): void
2525+ {
2626+ $rule = new MaxGraphemes(5);
2727+2828+ $invalid = 'This is too long';
2929+3030+ $failed = false;
3131+ $rule->validate('text', $invalid, function () use (&$failed) {
3232+ $failed = true;
3333+ });
3434+3535+ $this->assertTrue($failed);
3636+ }
3737+3838+ public function test_it_counts_graphemes_correctly(): void
3939+ {
4040+ $rule = new MaxGraphemes(5);
4141+4242+ // 6 emoji graphemes
4343+ $invalid = '😀😁😂😃😄😅';
4444+4545+ $failed = false;
4646+ $rule->validate('text', $invalid, function () use (&$failed) {
4747+ $failed = true;
4848+ });
4949+5050+ $this->assertTrue($failed);
5151+ }
5252+5353+ public function test_it_allows_exact_limit(): void
5454+ {
5555+ $rule = new MaxGraphemes(5);
5656+5757+ $valid = '😀😁😂😃😄';
5858+5959+ $failed = false;
6060+ $rule->validate('text', $valid, function () use (&$failed) {
6161+ $failed = true;
6262+ });
6363+6464+ $this->assertFalse($failed);
6565+ }
6666+6767+ public function test_it_rejects_non_string(): void
6868+ {
6969+ $rule = new MaxGraphemes(10);
7070+7171+ $failed = false;
7272+ $rule->validate('text', 123, function () use (&$failed) {
7373+ $failed = true;
7474+ });
7575+7676+ $this->assertTrue($failed);
7777+ }
7878+}
+78
tests/Unit/Validation/Rules/MinGraphemesTest.php
···11+<?php
22+33+namespace SocialDept\Schema\Tests\Unit\Validation\Rules;
44+55+use Orchestra\Testbench\TestCase;
66+use SocialDept\Schema\Validation\Rules\MinGraphemes;
77+88+class MinGraphemesTest extends TestCase
99+{
1010+ public function test_it_validates_string_meeting_minimum(): void
1111+ {
1212+ $rule = new MinGraphemes(5);
1313+1414+ $valid = 'Hello World';
1515+1616+ $failed = false;
1717+ $rule->validate('text', $valid, function () use (&$failed) {
1818+ $failed = true;
1919+ });
2020+2121+ $this->assertFalse($failed);
2222+ }
2323+2424+ public function test_it_rejects_string_below_minimum(): void
2525+ {
2626+ $rule = new MinGraphemes(10);
2727+2828+ $invalid = 'Short';
2929+3030+ $failed = false;
3131+ $rule->validate('text', $invalid, function () use (&$failed) {
3232+ $failed = true;
3333+ });
3434+3535+ $this->assertTrue($failed);
3636+ }
3737+3838+ public function test_it_counts_graphemes_correctly(): void
3939+ {
4040+ $rule = new MinGraphemes(5);
4141+4242+ // 3 emoji graphemes
4343+ $invalid = '😀😁😂';
4444+4545+ $failed = false;
4646+ $rule->validate('text', $invalid, function () use (&$failed) {
4747+ $failed = true;
4848+ });
4949+5050+ $this->assertTrue($failed);
5151+ }
5252+5353+ public function test_it_allows_exact_minimum(): void
5454+ {
5555+ $rule = new MinGraphemes(5);
5656+5757+ $valid = '😀😁😂😃😄';
5858+5959+ $failed = false;
6060+ $rule->validate('text', $valid, function () use (&$failed) {
6161+ $failed = true;
6262+ });
6363+6464+ $this->assertFalse($failed);
6565+ }
6666+6767+ public function test_it_rejects_non_string(): void
6868+ {
6969+ $rule = new MinGraphemes(5);
7070+7171+ $failed = false;
7272+ $rule->validate('text', 123, function () use (&$failed) {
7373+ $failed = true;
7474+ });
7575+7676+ $this->assertTrue($failed);
7777+ }
7878+}
+70
tests/Unit/Validation/Rules/NsidTest.php
···11+<?php
22+33+namespace SocialDept\Schema\Tests\Unit\Validation\Rules;
44+55+use Orchestra\Testbench\TestCase;
66+use SocialDept\Schema\Validation\Rules\Nsid;
77+88+class NsidTest extends TestCase
99+{
1010+ protected Nsid $rule;
1111+1212+ protected function setUp(): void
1313+ {
1414+ parent::setUp();
1515+1616+ $this->rule = new Nsid();
1717+ }
1818+1919+ public function test_it_validates_valid_nsid(): void
2020+ {
2121+ $valid = 'app.bsky.feed.post';
2222+2323+ $failed = false;
2424+ $this->rule->validate('nsid', $valid, function () use (&$failed) {
2525+ $failed = true;
2626+ });
2727+2828+ $this->assertFalse($failed);
2929+ }
3030+3131+ public function test_it_rejects_invalid_nsid(): void
3232+ {
3333+ $invalid = 'invalid-nsid';
3434+3535+ $failed = false;
3636+ $this->rule->validate('nsid', $invalid, function () use (&$failed) {
3737+ $failed = true;
3838+ });
3939+4040+ $this->assertTrue($failed);
4141+ }
4242+4343+ public function test_it_rejects_non_string(): void
4444+ {
4545+ $failed = false;
4646+ $this->rule->validate('nsid', 123, function () use (&$failed) {
4747+ $failed = true;
4848+ });
4949+5050+ $this->assertTrue($failed);
5151+ }
5252+5353+ public function test_it_validates_various_nsids(): void
5454+ {
5555+ $validNsids = [
5656+ 'com.example.test',
5757+ 'app.bsky.feed.post',
5858+ 'io.github.user.action',
5959+ ];
6060+6161+ foreach ($validNsids as $nsid) {
6262+ $failed = false;
6363+ $this->rule->validate('nsid', $nsid, function () use (&$failed) {
6464+ $failed = true;
6565+ });
6666+6767+ $this->assertFalse($failed, "Expected {$nsid} to be valid");
6868+ }
6969+ }
7070+}