···11+<?php
22+33+namespace SocialDept\Schema\Data\Types;
44+55+use SocialDept\Schema\Data\TypeDefinition;
66+use SocialDept\Schema\Exceptions\RecordValidationException;
77+88+class ArrayType extends TypeDefinition
99+{
1010+ /**
1111+ * Type of array items.
1212+ */
1313+ public readonly ?TypeDefinition $items;
1414+1515+ /**
1616+ * Minimum array length.
1717+ */
1818+ public readonly ?int $minLength;
1919+2020+ /**
2121+ * Maximum array length.
2222+ */
2323+ public readonly ?int $maxLength;
2424+2525+ /**
2626+ * Create a new ArrayType.
2727+ */
2828+ public function __construct(
2929+ ?TypeDefinition $items = null,
3030+ ?int $minLength = null,
3131+ ?int $maxLength = null,
3232+ ?string $description = null
3333+ ) {
3434+ parent::__construct('array', $description);
3535+3636+ $this->items = $items;
3737+ $this->minLength = $minLength;
3838+ $this->maxLength = $maxLength;
3939+ }
4040+4141+ /**
4242+ * Create from array data.
4343+ */
4444+ public static function fromArray(array $data): self
4545+ {
4646+ // Items will be parsed by TypeParser, this is just a placeholder
4747+ return new self(
4848+ items: null,
4949+ minLength: $data['minLength'] ?? null,
5050+ maxLength: $data['maxLength'] ?? null,
5151+ description: $data['description'] ?? null
5252+ );
5353+ }
5454+5555+ /**
5656+ * Convert to array.
5757+ */
5858+ public function toArray(): array
5959+ {
6060+ $array = ['type' => $this->type];
6161+6262+ if ($this->description !== null) {
6363+ $array['description'] = $this->description;
6464+ }
6565+6666+ if ($this->items !== null) {
6767+ $array['items'] = $this->items->toArray();
6868+ }
6969+7070+ if ($this->minLength !== null) {
7171+ $array['minLength'] = $this->minLength;
7272+ }
7373+7474+ if ($this->maxLength !== null) {
7575+ $array['maxLength'] = $this->maxLength;
7676+ }
7777+7878+ return $array;
7979+ }
8080+8181+ /**
8282+ * Validate a value against this type definition.
8383+ */
8484+ public function validate(mixed $value, string $path = ''): void
8585+ {
8686+ if (! is_array($value)) {
8787+ throw RecordValidationException::invalidType($path, 'array', gettype($value));
8888+ }
8989+9090+ // Check if it's a sequential array
9191+ if (! array_is_list($value)) {
9292+ throw RecordValidationException::invalidValue($path, 'must be a sequential array');
9393+ }
9494+9595+ $length = count($value);
9696+9797+ // Validate length
9898+ if ($this->minLength !== null && $length < $this->minLength) {
9999+ throw RecordValidationException::invalidValue($path, "must have at least {$this->minLength} items");
100100+ }
101101+102102+ if ($this->maxLength !== null && $length > $this->maxLength) {
103103+ throw RecordValidationException::invalidValue($path, "must have at most {$this->maxLength} items");
104104+ }
105105+106106+ // Validate items
107107+ if ($this->items !== null) {
108108+ foreach ($value as $index => $item) {
109109+ $itemPath = "{$path}[{$index}]";
110110+ $this->items->validate($item, $itemPath);
111111+ }
112112+ }
113113+ }
114114+115115+ /**
116116+ * Set items type after construction.
117117+ */
118118+ public function withItems(TypeDefinition $items): self
119119+ {
120120+ return new self(
121121+ items: $items,
122122+ minLength: $this->minLength,
123123+ maxLength: $this->maxLength,
124124+ description: $this->description
125125+ );
126126+ }
127127+}
+152
src/Data/Types/BlobType.php
···11+<?php
22+33+namespace SocialDept\Schema\Data\Types;
44+55+use SocialDept\Schema\Data\TypeDefinition;
66+use SocialDept\Schema\Exceptions\RecordValidationException;
77+88+class BlobType extends TypeDefinition
99+{
1010+ /**
1111+ * Accepted MIME types.
1212+ *
1313+ * @var array<string>|null
1414+ */
1515+ public readonly ?array $accept;
1616+1717+ /**
1818+ * Maximum blob size in bytes.
1919+ */
2020+ public readonly ?int $maxSize;
2121+2222+ /**
2323+ * Create a new BlobType.
2424+ *
2525+ * @param array<string>|null $accept
2626+ */
2727+ public function __construct(
2828+ ?array $accept = null,
2929+ ?int $maxSize = null,
3030+ ?string $description = null
3131+ ) {
3232+ parent::__construct('blob', $description);
3333+3434+ $this->accept = $accept;
3535+ $this->maxSize = $maxSize;
3636+ }
3737+3838+ /**
3939+ * Create from array data.
4040+ */
4141+ public static function fromArray(array $data): self
4242+ {
4343+ return new self(
4444+ accept: $data['accept'] ?? null,
4545+ maxSize: $data['maxSize'] ?? null,
4646+ description: $data['description'] ?? null
4747+ );
4848+ }
4949+5050+ /**
5151+ * Convert to array.
5252+ */
5353+ public function toArray(): array
5454+ {
5555+ $array = ['type' => $this->type];
5656+5757+ if ($this->description !== null) {
5858+ $array['description'] = $this->description;
5959+ }
6060+6161+ if ($this->accept !== null) {
6262+ $array['accept'] = $this->accept;
6363+ }
6464+6565+ if ($this->maxSize !== null) {
6666+ $array['maxSize'] = $this->maxSize;
6767+ }
6868+6969+ return $array;
7070+ }
7171+7272+ /**
7373+ * Validate a value against this type definition.
7474+ */
7575+ public function validate(mixed $value, string $path = ''): void
7676+ {
7777+ if (! is_array($value)) {
7878+ throw RecordValidationException::invalidType($path, 'blob (object)', gettype($value));
7979+ }
8080+8181+ // Blob must have $type property
8282+ if (! isset($value['$type']) || $value['$type'] !== 'blob') {
8383+ throw RecordValidationException::invalidValue($path, 'must have $type property set to "blob"');
8484+ }
8585+8686+ // Blob must have ref (CID reference)
8787+ if (! isset($value['ref'])) {
8888+ throw RecordValidationException::invalidValue($path, 'must have ref property');
8989+ }
9090+9191+ // Blob must have mimeType
9292+ if (! isset($value['mimeType'])) {
9393+ throw RecordValidationException::invalidValue($path, 'must have mimeType property');
9494+ }
9595+9696+ // Blob must have size
9797+ if (! isset($value['size'])) {
9898+ throw RecordValidationException::invalidValue($path, 'must have size property');
9999+ }
100100+101101+ // Validate MIME type if accept is specified
102102+ if ($this->accept !== null) {
103103+ $mimeType = $value['mimeType'];
104104+105105+ if (! $this->isMimeTypeAccepted($mimeType)) {
106106+ $accepted = implode(', ', $this->accept);
107107+108108+ throw RecordValidationException::invalidValue($path, "MIME type must be one of: {$accepted}");
109109+ }
110110+ }
111111+112112+ // Validate size if maxSize is specified
113113+ if ($this->maxSize !== null) {
114114+ $size = $value['size'];
115115+116116+ if (! is_int($size)) {
117117+ throw RecordValidationException::invalidValue($path, 'size must be an integer');
118118+ }
119119+120120+ if ($size > $this->maxSize) {
121121+ throw RecordValidationException::invalidValue($path, "size must not exceed {$this->maxSize} bytes");
122122+ }
123123+ }
124124+ }
125125+126126+ /**
127127+ * Check if a MIME type is accepted.
128128+ */
129129+ protected function isMimeTypeAccepted(string $mimeType): bool
130130+ {
131131+ if ($this->accept === null) {
132132+ return true;
133133+ }
134134+135135+ foreach ($this->accept as $accepted) {
136136+ // Exact match
137137+ if ($accepted === $mimeType) {
138138+ return true;
139139+ }
140140+141141+ // Wildcard match (e.g., image/*)
142142+ if (str_ends_with($accepted, '/*')) {
143143+ $prefix = substr($accepted, 0, -1);
144144+ if (str_starts_with($mimeType, $prefix)) {
145145+ return true;
146146+ }
147147+ }
148148+ }
149149+150150+ return false;
151151+ }
152152+}
+139
src/Data/Types/ObjectType.php
···11+<?php
22+33+namespace SocialDept\Schema\Data\Types;
44+55+use SocialDept\Schema\Data\TypeDefinition;
66+use SocialDept\Schema\Exceptions\RecordValidationException;
77+88+class ObjectType extends TypeDefinition
99+{
1010+ /**
1111+ * Object properties.
1212+ *
1313+ * @var array<string, TypeDefinition>
1414+ */
1515+ public readonly array $properties;
1616+1717+ /**
1818+ * Required property names.
1919+ *
2020+ * @var array<string>
2121+ */
2222+ public readonly array $required;
2323+2424+ /**
2525+ * Whether nullable properties are allowed.
2626+ */
2727+ public readonly bool $nullable;
2828+2929+ /**
3030+ * Create a new ObjectType.
3131+ *
3232+ * @param array<string, TypeDefinition> $properties
3333+ * @param array<string> $required
3434+ */
3535+ public function __construct(
3636+ array $properties = [],
3737+ array $required = [],
3838+ bool $nullable = false,
3939+ ?string $description = null
4040+ ) {
4141+ parent::__construct('object', $description);
4242+4343+ $this->properties = $properties;
4444+ $this->required = $required;
4545+ $this->nullable = $nullable;
4646+ }
4747+4848+ /**
4949+ * Create from array data.
5050+ */
5151+ public static function fromArray(array $data): self
5252+ {
5353+ // Properties will be parsed by TypeParser, this is just a placeholder
5454+ return new self(
5555+ properties: [],
5656+ required: $data['required'] ?? [],
5757+ nullable: $data['nullable'] ?? false,
5858+ description: $data['description'] ?? null
5959+ );
6060+ }
6161+6262+ /**
6363+ * Convert to array.
6464+ */
6565+ public function toArray(): array
6666+ {
6767+ $array = ['type' => $this->type];
6868+6969+ if ($this->description !== null) {
7070+ $array['description'] = $this->description;
7171+ }
7272+7373+ if (! empty($this->properties)) {
7474+ $array['properties'] = array_map(
7575+ fn (TypeDefinition $type) => $type->toArray(),
7676+ $this->properties
7777+ );
7878+ }
7979+8080+ if (! empty($this->required)) {
8181+ $array['required'] = $this->required;
8282+ }
8383+8484+ if ($this->nullable) {
8585+ $array['nullable'] = $this->nullable;
8686+ }
8787+8888+ return $array;
8989+ }
9090+9191+ /**
9292+ * Validate a value against this type definition.
9393+ */
9494+ public function validate(mixed $value, string $path = ''): void
9595+ {
9696+ if (! is_array($value)) {
9797+ throw RecordValidationException::invalidType($path, 'object', gettype($value));
9898+ }
9999+100100+ // Validate required properties
101101+ foreach ($this->required as $requiredKey) {
102102+ if (! array_key_exists($requiredKey, $value)) {
103103+ throw RecordValidationException::invalidValue($path, "missing required property '{$requiredKey}'");
104104+ }
105105+ }
106106+107107+ // Validate each property
108108+ foreach ($this->properties as $key => $propertyType) {
109109+ if (! array_key_exists($key, $value)) {
110110+ continue;
111111+ }
112112+113113+ $propertyPath = $path ? "{$path}.{$key}" : $key;
114114+ $propertyValue = $value[$key];
115115+116116+ // Handle nullable
117117+ if ($propertyValue === null && $this->nullable) {
118118+ continue;
119119+ }
120120+121121+ $propertyType->validate($propertyValue, $propertyPath);
122122+ }
123123+ }
124124+125125+ /**
126126+ * Set properties after construction.
127127+ *
128128+ * @param array<string, TypeDefinition> $properties
129129+ */
130130+ public function withProperties(array $properties): self
131131+ {
132132+ return new self(
133133+ properties: $properties,
134134+ required: $this->required,
135135+ nullable: $this->nullable,
136136+ description: $this->description
137137+ );
138138+ }
139139+}
+95
src/Data/Types/RefType.php
···11+<?php
22+33+namespace SocialDept\Schema\Data\Types;
44+55+use SocialDept\Schema\Data\TypeDefinition;
66+77+class RefType extends TypeDefinition
88+{
99+ /**
1010+ * Reference to another type (NSID or local #def).
1111+ */
1212+ public readonly string $ref;
1313+1414+ /**
1515+ * Create a new RefType.
1616+ */
1717+ public function __construct(
1818+ string $ref,
1919+ ?string $description = null
2020+ ) {
2121+ parent::__construct('ref', $description);
2222+2323+ $this->ref = $ref;
2424+ }
2525+2626+ /**
2727+ * Create from array data.
2828+ */
2929+ public static function fromArray(array $data): self
3030+ {
3131+ if (! isset($data['ref'])) {
3232+ throw new \InvalidArgumentException('RefType requires a ref property');
3333+ }
3434+3535+ return new self(
3636+ ref: $data['ref'],
3737+ description: $data['description'] ?? null
3838+ );
3939+ }
4040+4141+ /**
4242+ * Convert to array.
4343+ */
4444+ public function toArray(): array
4545+ {
4646+ $array = [
4747+ 'type' => $this->type,
4848+ 'ref' => $this->ref,
4949+ ];
5050+5151+ if ($this->description !== null) {
5252+ $array['description'] = $this->description;
5353+ }
5454+5555+ return $array;
5656+ }
5757+5858+ /**
5959+ * Validate a value against this type definition.
6060+ */
6161+ public function validate(mixed $value, string $path = ''): void
6262+ {
6363+ // Ref validation requires resolving the reference to its actual type
6464+ // This would be handled by a higher-level validator with schema repository access
6565+ // For now, we just accept any value
6666+ }
6767+6868+ /**
6969+ * Check if this is a local reference (starts with #).
7070+ */
7171+ public function isLocal(): bool
7272+ {
7373+ return str_starts_with($this->ref, '#');
7474+ }
7575+7676+ /**
7777+ * Check if this is an external reference (contains a dot).
7878+ */
7979+ public function isExternal(): bool
8080+ {
8181+ return str_contains($this->ref, '.') && ! $this->isLocal();
8282+ }
8383+8484+ /**
8585+ * Get the definition name from a local reference.
8686+ */
8787+ public function getLocalDefinition(): ?string
8888+ {
8989+ if (! $this->isLocal()) {
9090+ return null;
9191+ }
9292+9393+ return substr($this->ref, 1);
9494+ }
9595+}
+102
src/Data/Types/UnionType.php
···11+<?php
22+33+namespace SocialDept\Schema\Data\Types;
44+55+use SocialDept\Schema\Data\TypeDefinition;
66+use SocialDept\Schema\Exceptions\RecordValidationException;
77+88+class UnionType extends TypeDefinition
99+{
1010+ /**
1111+ * Possible types (refs).
1212+ *
1313+ * @var array<string>
1414+ */
1515+ public readonly array $refs;
1616+1717+ /**
1818+ * Whether this is a closed union (only listed refs allowed).
1919+ */
2020+ public readonly bool $closed;
2121+2222+ /**
2323+ * Create a new UnionType.
2424+ *
2525+ * @param array<string> $refs
2626+ */
2727+ public function __construct(
2828+ array $refs = [],
2929+ bool $closed = false,
3030+ ?string $description = null
3131+ ) {
3232+ parent::__construct('union', $description);
3333+3434+ $this->refs = $refs;
3535+ $this->closed = $closed;
3636+ }
3737+3838+ /**
3939+ * Create from array data.
4040+ */
4141+ public static function fromArray(array $data): self
4242+ {
4343+ return new self(
4444+ refs: $data['refs'] ?? [],
4545+ closed: $data['closed'] ?? false,
4646+ description: $data['description'] ?? null
4747+ );
4848+ }
4949+5050+ /**
5151+ * Convert to array.
5252+ */
5353+ public function toArray(): array
5454+ {
5555+ $array = ['type' => $this->type];
5656+5757+ if ($this->description !== null) {
5858+ $array['description'] = $this->description;
5959+ }
6060+6161+ if (! empty($this->refs)) {
6262+ $array['refs'] = $this->refs;
6363+ }
6464+6565+ if ($this->closed) {
6666+ $array['closed'] = $this->closed;
6767+ }
6868+6969+ return $array;
7070+ }
7171+7272+ /**
7373+ * Validate a value against this type definition.
7474+ */
7575+ public function validate(mixed $value, string $path = ''): void
7676+ {
7777+ if (! is_array($value)) {
7878+ throw RecordValidationException::invalidType($path, 'union (object with $type)', gettype($value));
7979+ }
8080+8181+ // Union types must have a $type discriminator
8282+ if (! isset($value['$type'])) {
8383+ throw RecordValidationException::invalidValue($path, 'must contain $type property');
8484+ }
8585+8686+ $typeRef = $value['$type'];
8787+8888+ if (! is_string($typeRef)) {
8989+ throw RecordValidationException::invalidValue($path, '$type must be a string');
9090+ }
9191+9292+ // If closed, validate the type is in refs
9393+ if ($this->closed && ! in_array($typeRef, $this->refs, true)) {
9494+ $allowed = implode(', ', $this->refs);
9595+9696+ throw RecordValidationException::invalidValue($path, "type must be one of: {$allowed}");
9797+ }
9898+9999+ // Note: Actual validation of the referenced type would happen
100100+ // in a higher-level validator that has access to the schema repository
101101+ }
102102+}
+137
src/Parser/ComplexTypeParser.php
···11+<?php
22+33+namespace SocialDept\Schema\Parser;
44+55+use SocialDept\Schema\Data\TypeDefinition;
66+use SocialDept\Schema\Data\Types\ArrayType;
77+use SocialDept\Schema\Data\Types\BlobType;
88+use SocialDept\Schema\Data\Types\ObjectType;
99+use SocialDept\Schema\Data\Types\RefType;
1010+use SocialDept\Schema\Data\Types\UnionType;
1111+use SocialDept\Schema\Exceptions\TypeResolutionException;
1212+1313+class ComplexTypeParser
1414+{
1515+ /**
1616+ * Primitive parser for nested types.
1717+ */
1818+ protected PrimitiveParser $primitiveParser;
1919+2020+ /**
2121+ * Create a new ComplexTypeParser.
2222+ */
2323+ public function __construct(?PrimitiveParser $primitiveParser = null)
2424+ {
2525+ $this->primitiveParser = $primitiveParser ?? new PrimitiveParser();
2626+ }
2727+2828+ /**
2929+ * Parse a complex type definition from array data.
3030+ *
3131+ * @throws TypeResolutionException
3232+ */
3333+ public function parse(array $data): TypeDefinition
3434+ {
3535+ $type = $data['type'] ?? null;
3636+3737+ if ($type === null) {
3838+ throw TypeResolutionException::unknownType('(missing type field)');
3939+ }
4040+4141+ return match ($type) {
4242+ 'object' => $this->parseObject($data),
4343+ 'array' => $this->parseArray($data),
4444+ 'union' => UnionType::fromArray($data),
4545+ 'ref' => RefType::fromArray($data),
4646+ 'blob' => BlobType::fromArray($data),
4747+ default => throw TypeResolutionException::unknownType($type),
4848+ };
4949+ }
5050+5151+ /**
5252+ * Parse an object type with nested properties.
5353+ */
5454+ protected function parseObject(array $data): ObjectType
5555+ {
5656+ $object = ObjectType::fromArray($data);
5757+5858+ // Parse properties if present
5959+ if (isset($data['properties']) && is_array($data['properties'])) {
6060+ $properties = [];
6161+6262+ foreach ($data['properties'] as $key => $propertyData) {
6363+ $properties[$key] = $this->parseNestedType($propertyData);
6464+ }
6565+6666+ $object = $object->withProperties($properties);
6767+ }
6868+6969+ return $object;
7070+ }
7171+7272+ /**
7373+ * Parse an array type with nested items.
7474+ */
7575+ protected function parseArray(array $data): ArrayType
7676+ {
7777+ $array = ArrayType::fromArray($data);
7878+7979+ // Parse items if present
8080+ if (isset($data['items']) && is_array($data['items'])) {
8181+ $items = $this->parseNestedType($data['items']);
8282+ $array = $array->withItems($items);
8383+ }
8484+8585+ return $array;
8686+ }
8787+8888+ /**
8989+ * Parse a nested type definition (can be primitive or complex).
9090+ */
9191+ protected function parseNestedType(array $data): TypeDefinition
9292+ {
9393+ $type = $data['type'] ?? null;
9494+9595+ if ($type === null) {
9696+ throw TypeResolutionException::unknownType('(missing type field)');
9797+ }
9898+9999+ // Try primitive types first
100100+ if ($this->primitiveParser->isPrimitive($type)) {
101101+ return $this->primitiveParser->parse($data);
102102+ }
103103+104104+ // Try complex types
105105+ return $this->parse($data);
106106+ }
107107+108108+ /**
109109+ * Check if a type is a complex type.
110110+ */
111111+ public function isComplex(string $type): bool
112112+ {
113113+ return in_array($type, [
114114+ 'object',
115115+ 'array',
116116+ 'union',
117117+ 'ref',
118118+ 'blob',
119119+ ]);
120120+ }
121121+122122+ /**
123123+ * Get all supported complex types.
124124+ *
125125+ * @return array<string>
126126+ */
127127+ public function getSupportedTypes(): array
128128+ {
129129+ return [
130130+ 'object',
131131+ 'array',
132132+ 'union',
133133+ 'ref',
134134+ 'blob',
135135+ ];
136136+ }
137137+}
+113
tests/Unit/Data/Types/ArrayTypeTest.php
···11+<?php
22+33+namespace SocialDept\Schema\Tests\Unit\Data\Types;
44+55+use Orchestra\Testbench\TestCase;
66+use SocialDept\Schema\Data\Types\ArrayType;
77+use SocialDept\Schema\Data\Types\StringType;
88+use SocialDept\Schema\Exceptions\RecordValidationException;
99+1010+class ArrayTypeTest extends TestCase
1111+{
1212+ public function test_it_creates_from_array(): void
1313+ {
1414+ $type = ArrayType::fromArray([
1515+ 'type' => 'array',
1616+ 'description' => 'An array',
1717+ 'minLength' => 1,
1818+ 'maxLength' => 10,
1919+ ]);
2020+2121+ $this->assertSame('array', $type->type);
2222+ $this->assertSame('An array', $type->description);
2323+ $this->assertSame(1, $type->minLength);
2424+ $this->assertSame(10, $type->maxLength);
2525+ }
2626+2727+ public function test_it_converts_to_array(): void
2828+ {
2929+ $type = new ArrayType(
3030+ items: new StringType(),
3131+ minLength: 1,
3232+ maxLength: 10,
3333+ description: 'An array'
3434+ );
3535+3636+ $array = $type->toArray();
3737+3838+ $this->assertSame('array', $array['type']);
3939+ $this->assertSame('An array', $array['description']);
4040+ $this->assertArrayHasKey('items', $array);
4141+ $this->assertSame(1, $array['minLength']);
4242+ $this->assertSame(10, $array['maxLength']);
4343+ }
4444+4545+ public function test_it_validates_array_type(): void
4646+ {
4747+ $type = new ArrayType();
4848+4949+ $this->expectException(RecordValidationException::class);
5050+ $this->expectExceptionMessage("Expected type 'array' at 'field' but got 'string'");
5151+5252+ $type->validate('not an array', 'field');
5353+ }
5454+5555+ public function test_it_validates_sequential_array(): void
5656+ {
5757+ $type = new ArrayType();
5858+5959+ $type->validate(['a', 'b', 'c'], 'field');
6060+6161+ $this->expectException(RecordValidationException::class);
6262+ $this->expectExceptionMessage('Invalid value at \'field\': must be a sequential array');
6363+6464+ $type->validate(['key' => 'value'], 'field');
6565+ }
6666+6767+ public function test_it_validates_min_length(): void
6868+ {
6969+ $type = new ArrayType(minLength: 2);
7070+7171+ $type->validate(['a', 'b'], 'field');
7272+7373+ $this->expectException(RecordValidationException::class);
7474+ $this->expectExceptionMessage('Invalid value at \'field\': must have at least 2 items');
7575+7676+ $type->validate(['a'], 'field');
7777+ }
7878+7979+ public function test_it_validates_max_length(): void
8080+ {
8181+ $type = new ArrayType(maxLength: 2);
8282+8383+ $type->validate(['a', 'b'], 'field');
8484+8585+ $this->expectException(RecordValidationException::class);
8686+ $this->expectExceptionMessage('Invalid value at \'field\': must have at most 2 items');
8787+8888+ $type->validate(['a', 'b', 'c'], 'field');
8989+ }
9090+9191+ public function test_it_validates_item_types(): void
9292+ {
9393+ $type = new ArrayType(items: new StringType());
9494+9595+ $type->validate(['a', 'b', 'c'], 'field');
9696+9797+ $this->expectException(RecordValidationException::class);
9898+ $this->expectExceptionMessage("Expected type 'string' at 'field[1]' but got 'integer'");
9999+100100+ $type->validate(['a', 123, 'c'], 'field');
101101+ }
102102+103103+ public function test_with_items_returns_new_instance(): void
104104+ {
105105+ $type = new ArrayType(minLength: 1);
106106+107107+ $newType = $type->withItems(new StringType());
108108+109109+ $this->assertNotSame($type, $newType);
110110+ $this->assertNull($type->items);
111111+ $this->assertInstanceOf(StringType::class, $newType->items);
112112+ }
113113+}
+192
tests/Unit/Data/Types/BlobTypeTest.php
···11+<?php
22+33+namespace SocialDept\Schema\Tests\Unit\Data\Types;
44+55+use Orchestra\Testbench\TestCase;
66+use SocialDept\Schema\Data\Types\BlobType;
77+use SocialDept\Schema\Exceptions\RecordValidationException;
88+99+class BlobTypeTest extends TestCase
1010+{
1111+ public function test_it_creates_from_array(): void
1212+ {
1313+ $type = BlobType::fromArray([
1414+ 'type' => 'blob',
1515+ 'description' => 'A blob',
1616+ 'accept' => ['image/png', 'image/jpeg'],
1717+ 'maxSize' => 1000000,
1818+ ]);
1919+2020+ $this->assertSame('blob', $type->type);
2121+ $this->assertSame('A blob', $type->description);
2222+ $this->assertSame(['image/png', 'image/jpeg'], $type->accept);
2323+ $this->assertSame(1000000, $type->maxSize);
2424+ }
2525+2626+ public function test_it_converts_to_array(): void
2727+ {
2828+ $type = new BlobType(
2929+ accept: ['image/png'],
3030+ maxSize: 1000000,
3131+ description: 'A blob'
3232+ );
3333+3434+ $array = $type->toArray();
3535+3636+ $this->assertSame('blob', $array['type']);
3737+ $this->assertSame('A blob', $array['description']);
3838+ $this->assertSame(['image/png'], $array['accept']);
3939+ $this->assertSame(1000000, $array['maxSize']);
4040+ }
4141+4242+ public function test_it_validates_blob_type(): void
4343+ {
4444+ $type = new BlobType();
4545+4646+ $this->expectException(RecordValidationException::class);
4747+ $this->expectExceptionMessage("Expected type 'blob (object)' at 'field' but got 'string'");
4848+4949+ $type->validate('not an object', 'field');
5050+ }
5151+5252+ public function test_it_validates_type_property(): void
5353+ {
5454+ $type = new BlobType();
5555+5656+ $this->expectException(RecordValidationException::class);
5757+ $this->expectExceptionMessage('Invalid value at \'field\': must have $type property set to "blob"');
5858+5959+ $type->validate(['ref' => 'cid123'], 'field');
6060+ }
6161+6262+ public function test_it_validates_ref_property(): void
6363+ {
6464+ $type = new BlobType();
6565+6666+ $this->expectException(RecordValidationException::class);
6767+ $this->expectExceptionMessage('Invalid value at \'field\': must have ref property');
6868+6969+ $type->validate(['$type' => 'blob'], 'field');
7070+ }
7171+7272+ public function test_it_validates_mime_type_property(): void
7373+ {
7474+ $type = new BlobType();
7575+7676+ $this->expectException(RecordValidationException::class);
7777+ $this->expectExceptionMessage('Invalid value at \'field\': must have mimeType property');
7878+7979+ $type->validate(['$type' => 'blob', 'ref' => 'cid123'], 'field');
8080+ }
8181+8282+ public function test_it_validates_size_property(): void
8383+ {
8484+ $type = new BlobType();
8585+8686+ $this->expectException(RecordValidationException::class);
8787+ $this->expectExceptionMessage('Invalid value at \'field\': must have size property');
8888+8989+ $type->validate(['$type' => 'blob', 'ref' => 'cid123', 'mimeType' => 'image/png'], 'field');
9090+ }
9191+9292+ public function test_it_validates_valid_blob(): void
9393+ {
9494+ $type = new BlobType();
9595+9696+ $type->validate([
9797+ '$type' => 'blob',
9898+ 'ref' => 'cid123',
9999+ 'mimeType' => 'image/png',
100100+ 'size' => 12345,
101101+ ], 'field');
102102+103103+ $this->assertTrue(true);
104104+ }
105105+106106+ public function test_it_validates_accepted_mime_types(): void
107107+ {
108108+ $type = new BlobType(accept: ['image/png', 'image/jpeg']);
109109+110110+ $type->validate([
111111+ '$type' => 'blob',
112112+ 'ref' => 'cid123',
113113+ 'mimeType' => 'image/png',
114114+ 'size' => 12345,
115115+ ], 'field');
116116+117117+ $this->expectException(RecordValidationException::class);
118118+ $this->expectExceptionMessage('Invalid value at \'field\': MIME type must be one of: image/png, image/jpeg');
119119+120120+ $type->validate([
121121+ '$type' => 'blob',
122122+ 'ref' => 'cid123',
123123+ 'mimeType' => 'image/gif',
124124+ 'size' => 12345,
125125+ ], 'field');
126126+ }
127127+128128+ public function test_it_validates_wildcard_mime_types(): void
129129+ {
130130+ $type = new BlobType(accept: ['image/*']);
131131+132132+ $type->validate([
133133+ '$type' => 'blob',
134134+ 'ref' => 'cid123',
135135+ 'mimeType' => 'image/png',
136136+ 'size' => 12345,
137137+ ], 'field');
138138+139139+ $type->validate([
140140+ '$type' => 'blob',
141141+ 'ref' => 'cid123',
142142+ 'mimeType' => 'image/jpeg',
143143+ 'size' => 12345,
144144+ ], 'field');
145145+146146+ $this->expectException(RecordValidationException::class);
147147+148148+ $type->validate([
149149+ '$type' => 'blob',
150150+ 'ref' => 'cid123',
151151+ 'mimeType' => 'video/mp4',
152152+ 'size' => 12345,
153153+ ], 'field');
154154+ }
155155+156156+ public function test_it_validates_max_size(): void
157157+ {
158158+ $type = new BlobType(maxSize: 10000);
159159+160160+ $type->validate([
161161+ '$type' => 'blob',
162162+ 'ref' => 'cid123',
163163+ 'mimeType' => 'image/png',
164164+ 'size' => 10000,
165165+ ], 'field');
166166+167167+ $this->expectException(RecordValidationException::class);
168168+ $this->expectExceptionMessage('Invalid value at \'field\': size must not exceed 10000 bytes');
169169+170170+ $type->validate([
171171+ '$type' => 'blob',
172172+ 'ref' => 'cid123',
173173+ 'mimeType' => 'image/png',
174174+ 'size' => 10001,
175175+ ], 'field');
176176+ }
177177+178178+ public function test_it_validates_size_is_integer(): void
179179+ {
180180+ $type = new BlobType(maxSize: 10000);
181181+182182+ $this->expectException(RecordValidationException::class);
183183+ $this->expectExceptionMessage('Invalid value at \'field\': size must be an integer');
184184+185185+ $type->validate([
186186+ '$type' => 'blob',
187187+ 'ref' => 'cid123',
188188+ 'mimeType' => 'image/png',
189189+ 'size' => '12345',
190190+ ], 'field');
191191+ }
192192+}
+118
tests/Unit/Data/Types/ObjectTypeTest.php
···11+<?php
22+33+namespace SocialDept\Schema\Tests\Unit\Data\Types;
44+55+use Orchestra\Testbench\TestCase;
66+use SocialDept\Schema\Data\Types\ObjectType;
77+use SocialDept\Schema\Data\Types\StringType;
88+use SocialDept\Schema\Exceptions\RecordValidationException;
99+1010+class ObjectTypeTest extends TestCase
1111+{
1212+ public function test_it_creates_from_array(): void
1313+ {
1414+ $type = ObjectType::fromArray([
1515+ 'type' => 'object',
1616+ 'description' => 'An object',
1717+ 'required' => ['name'],
1818+ 'nullable' => true,
1919+ ]);
2020+2121+ $this->assertSame('object', $type->type);
2222+ $this->assertSame('An object', $type->description);
2323+ $this->assertSame(['name'], $type->required);
2424+ $this->assertTrue($type->nullable);
2525+ }
2626+2727+ public function test_it_converts_to_array(): void
2828+ {
2929+ $type = new ObjectType(
3030+ properties: ['name' => new StringType()],
3131+ required: ['name'],
3232+ nullable: true,
3333+ description: 'An object'
3434+ );
3535+3636+ $array = $type->toArray();
3737+3838+ $this->assertSame('object', $array['type']);
3939+ $this->assertSame('An object', $array['description']);
4040+ $this->assertArrayHasKey('properties', $array);
4141+ $this->assertSame(['name'], $array['required']);
4242+ $this->assertTrue($array['nullable']);
4343+ }
4444+4545+ public function test_it_validates_object_type(): void
4646+ {
4747+ $type = new ObjectType();
4848+4949+ $this->expectException(RecordValidationException::class);
5050+ $this->expectExceptionMessage("Expected type 'object' at 'field' but got 'string'");
5151+5252+ $type->validate('not an object', 'field');
5353+ }
5454+5555+ public function test_it_validates_required_properties(): void
5656+ {
5757+ $type = new ObjectType(
5858+ properties: ['name' => new StringType()],
5959+ required: ['name']
6060+ );
6161+6262+ $type->validate(['name' => 'John'], 'field');
6363+6464+ $this->expectException(RecordValidationException::class);
6565+ $this->expectExceptionMessage("Invalid value at 'field': missing required property 'name'");
6666+6767+ $type->validate([], 'field');
6868+ }
6969+7070+ public function test_it_validates_property_types(): void
7171+ {
7272+ $type = new ObjectType(
7373+ properties: ['name' => new StringType()]
7474+ );
7575+7676+ $type->validate(['name' => 'John'], 'field');
7777+7878+ $this->expectException(RecordValidationException::class);
7979+ $this->expectExceptionMessage("Expected type 'string' at 'field.name' but got 'integer'");
8080+8181+ $type->validate(['name' => 123], 'field');
8282+ }
8383+8484+ public function test_it_allows_nullable_properties(): void
8585+ {
8686+ $type = new ObjectType(
8787+ properties: ['name' => new StringType()],
8888+ nullable: true
8989+ );
9090+9191+ $type->validate(['name' => null], 'field');
9292+9393+ $this->assertTrue(true);
9494+ }
9595+9696+ public function test_it_allows_additional_properties(): void
9797+ {
9898+ $type = new ObjectType(
9999+ properties: ['name' => new StringType()]
100100+ );
101101+102102+ // Additional properties should be allowed
103103+ $type->validate(['name' => 'John', 'age' => 30], 'field');
104104+105105+ $this->assertTrue(true);
106106+ }
107107+108108+ public function test_with_properties_returns_new_instance(): void
109109+ {
110110+ $type = new ObjectType(required: ['name']);
111111+112112+ $newType = $type->withProperties(['name' => new StringType()]);
113113+114114+ $this->assertNotSame($type, $newType);
115115+ $this->assertEmpty($type->properties);
116116+ $this->assertCount(1, $newType->properties);
117117+ }
118118+}
+86
tests/Unit/Data/Types/RefTypeTest.php
···11+<?php
22+33+namespace SocialDept\Schema\Tests\Unit\Data\Types;
44+55+use Orchestra\Testbench\TestCase;
66+use SocialDept\Schema\Data\Types\RefType;
77+88+class RefTypeTest extends TestCase
99+{
1010+ public function test_it_creates_from_array(): void
1111+ {
1212+ $type = RefType::fromArray([
1313+ 'type' => 'ref',
1414+ 'ref' => '#main',
1515+ 'description' => 'A reference',
1616+ ]);
1717+1818+ $this->assertSame('ref', $type->type);
1919+ $this->assertSame('#main', $type->ref);
2020+ $this->assertSame('A reference', $type->description);
2121+ }
2222+2323+ public function test_it_throws_on_missing_ref(): void
2424+ {
2525+ $this->expectException(\InvalidArgumentException::class);
2626+ $this->expectExceptionMessage('RefType requires a ref property');
2727+2828+ RefType::fromArray(['type' => 'ref']);
2929+ }
3030+3131+ public function test_it_converts_to_array(): void
3232+ {
3333+ $type = new RefType(
3434+ ref: '#main',
3535+ description: 'A reference'
3636+ );
3737+3838+ $array = $type->toArray();
3939+4040+ $this->assertSame('ref', $array['type']);
4141+ $this->assertSame('#main', $array['ref']);
4242+ $this->assertSame('A reference', $array['description']);
4343+ }
4444+4545+ public function test_it_identifies_local_reference(): void
4646+ {
4747+ $type = new RefType('#main');
4848+4949+ $this->assertTrue($type->isLocal());
5050+ $this->assertFalse($type->isExternal());
5151+ }
5252+5353+ public function test_it_identifies_external_reference(): void
5454+ {
5555+ $type = new RefType('com.atproto.repo.strongRef');
5656+5757+ $this->assertFalse($type->isLocal());
5858+ $this->assertTrue($type->isExternal());
5959+ }
6060+6161+ public function test_it_gets_local_definition_name(): void
6262+ {
6363+ $type = new RefType('#main');
6464+6565+ $this->assertSame('main', $type->getLocalDefinition());
6666+ }
6767+6868+ public function test_it_returns_null_for_external_definition(): void
6969+ {
7070+ $type = new RefType('com.atproto.repo.strongRef');
7171+7272+ $this->assertNull($type->getLocalDefinition());
7373+ }
7474+7575+ public function test_it_validates_any_value(): void
7676+ {
7777+ $type = new RefType('#main');
7878+7979+ // Ref validation is deferred to higher-level validator
8080+ $type->validate(['any' => 'value'], 'field');
8181+ $type->validate('string', 'field');
8282+ $type->validate(123, 'field');
8383+8484+ $this->assertTrue(true);
8585+ }
8686+}
+98
tests/Unit/Data/Types/UnionTypeTest.php
···11+<?php
22+33+namespace SocialDept\Schema\Tests\Unit\Data\Types;
44+55+use Orchestra\Testbench\TestCase;
66+use SocialDept\Schema\Data\Types\UnionType;
77+use SocialDept\Schema\Exceptions\RecordValidationException;
88+99+class UnionTypeTest extends TestCase
1010+{
1111+ public function test_it_creates_from_array(): void
1212+ {
1313+ $type = UnionType::fromArray([
1414+ 'type' => 'union',
1515+ 'description' => 'A union',
1616+ 'refs' => ['#typeA', '#typeB'],
1717+ 'closed' => true,
1818+ ]);
1919+2020+ $this->assertSame('union', $type->type);
2121+ $this->assertSame('A union', $type->description);
2222+ $this->assertSame(['#typeA', '#typeB'], $type->refs);
2323+ $this->assertTrue($type->closed);
2424+ }
2525+2626+ public function test_it_converts_to_array(): void
2727+ {
2828+ $type = new UnionType(
2929+ refs: ['#typeA', '#typeB'],
3030+ closed: true,
3131+ description: 'A union'
3232+ );
3333+3434+ $array = $type->toArray();
3535+3636+ $this->assertSame('union', $array['type']);
3737+ $this->assertSame('A union', $array['description']);
3838+ $this->assertSame(['#typeA', '#typeB'], $array['refs']);
3939+ $this->assertTrue($array['closed']);
4040+ }
4141+4242+ public function test_it_validates_union_type(): void
4343+ {
4444+ $type = new UnionType();
4545+4646+ $this->expectException(RecordValidationException::class);
4747+ $this->expectExceptionMessage("Expected type 'union (object with \$type)' at 'field' but got 'string'");
4848+4949+ $type->validate('not an object', 'field');
5050+ }
5151+5252+ public function test_it_validates_type_discriminator(): void
5353+ {
5454+ $type = new UnionType();
5555+5656+ $this->expectException(RecordValidationException::class);
5757+ $this->expectExceptionMessage('Invalid value at \'field\': must contain $type property');
5858+5959+ $type->validate(['data' => 'value'], 'field');
6060+ }
6161+6262+ public function test_it_validates_type_discriminator_is_string(): void
6363+ {
6464+ $type = new UnionType();
6565+6666+ $this->expectException(RecordValidationException::class);
6767+ $this->expectExceptionMessage('Invalid value at \'field\': $type must be a string');
6868+6969+ $type->validate(['$type' => 123], 'field');
7070+ }
7171+7272+ public function test_it_validates_closed_union(): void
7373+ {
7474+ $type = new UnionType(
7575+ refs: ['#typeA', '#typeB'],
7676+ closed: true
7777+ );
7878+7979+ $type->validate(['$type' => '#typeA'], 'field');
8080+8181+ $this->expectException(RecordValidationException::class);
8282+ $this->expectExceptionMessage('Invalid value at \'field\': type must be one of: #typeA, #typeB');
8383+8484+ $type->validate(['$type' => '#typeC'], 'field');
8585+ }
8686+8787+ public function test_it_allows_any_type_in_open_union(): void
8888+ {
8989+ $type = new UnionType(
9090+ refs: ['#typeA', '#typeB'],
9191+ closed: false
9292+ );
9393+9494+ $type->validate(['$type' => '#typeC'], 'field');
9595+9696+ $this->assertTrue(true);
9797+ }
9898+}