diff --git a/src/Contracts/FieldTypes/FieldTypeHandlerContract.php b/src/Contracts/FieldTypes/FieldTypeHandlerContract.php new file mode 100644 index 0000000..53fd96a --- /dev/null +++ b/src/Contracts/FieldTypes/FieldTypeHandlerContract.php @@ -0,0 +1,10 @@ +definition = $definition; + $this->handler = $handler; + } + + public function handler(): FieldTypeHandlerContract + { + return $this->handler; + } + + public function definition(): AbstractDefinition + { + return $this->definition; + } + + public function __call(string $name, array $args): self + { + $this->definition()->$name(...$args); + + return $this; + } +} diff --git a/src/FieldTypes/CastableFields/CastableObjectField.php b/src/FieldTypes/CastableFields/CastableObjectField.php new file mode 100644 index 0000000..a1c5a7f --- /dev/null +++ b/src/FieldTypes/CastableFields/CastableObjectField.php @@ -0,0 +1,14 @@ +meta); + } + + public function jsonSerialize(): array + { + return $this->metadata(); + } + + public function type(): string + { + return $this->meta['type']; + } + + /** + * @return static|?string + */ + public function description(?string $description) + { + if (is_null($description)) { + return $this->meta['description'] ?? null; + } + + $this->meta['description'] = $description; + + return $this; + } +} diff --git a/src/FieldTypes/Definitions/ArrTypeDefinition.php b/src/FieldTypes/Definitions/ArrTypeDefinition.php new file mode 100644 index 0000000..22d0786 --- /dev/null +++ b/src/FieldTypes/Definitions/ArrTypeDefinition.php @@ -0,0 +1,67 @@ +definition = $definition; + $this->handler = $handler; + $this->meta['type'] = 'array'; + } + + public function handler(): FieldTypeHandlerContract + { + return $this->handler; + } + + public function definition(): AbstractDefinition + { + return $this->definition; + } + + /** + * @return static|string + */ + public function items(?string $items = null) + { + if (is_null($items)) { + return $this->meta['items'] ?? null; + } + + $this->meta['items'] = $items; + + return $this; + } + + public function maxLength(?int $maxLength = null) + { + if (is_null($maxLength)) { + return $this->meta['maxLength'] ?? null; + } + + $this->meta['maxLength'] = $maxLength; + + return $this; + } + + public function minLength(?int $minLength = null) + { + if (is_null($minLength)) { + return $this->meta['minLength'] ?? null; + } + + $this->meta['minLength'] = $minLength; + + return $this; + } +} diff --git a/src/FieldTypes/Definitions/ObjectTypeDefinition.php b/src/FieldTypes/Definitions/ObjectTypeDefinition.php new file mode 100644 index 0000000..2614090 --- /dev/null +++ b/src/FieldTypes/Definitions/ObjectTypeDefinition.php @@ -0,0 +1,62 @@ +target = $target; + $this->meta['type'] = 'object'; + } + + public function target(): string + { + return $this->target; + } + + public function nullable(?bool $nullable = null) + { + if (is_null($nullable)) { + return $this->meta['nullable'] ?? null; + } + + $this->meta['nullable'] = $nullable; + + return $this; + } + + /** + * @param null|array $props + */ + public function properties(?array $props = null) + { + if (is_null($props)) { + return $this->meta['properties'] ?? []; + } + + $this->meta['properties'] = $props; + + return $this; + } + + /** + * @param null|array $props + */ + public function required(?array $props = null) + { + if (is_null($props)) { + return $this->meta['required'] ?? []; + } + + $this->meta['required'] = $props; + + return $this; + } +} diff --git a/src/FieldTypes/Definitions/UnionTypeDefinition.php b/src/FieldTypes/Definitions/UnionTypeDefinition.php new file mode 100644 index 0000000..09695f7 --- /dev/null +++ b/src/FieldTypes/Definitions/UnionTypeDefinition.php @@ -0,0 +1,43 @@ +> $refs + */ + public function __construct(array $refs) + { + $nonLexiconRefs = array_filter($refs, fn ($ref) => ! in_array(LexiconContract::class, class_implements($ref), true)); + + if (! empty($nonLexiconRefs)) { + throw new InvalidArgumentException(sprintf( + 'Each ref must implement LexiconContract. Invalid: %s', + implode(', ', $nonLexiconRefs) + )); + } + + $this->meta['refs'] = $refs; + $this->meta['type'] = 'union'; + } + + public function refs() + { + return $this->meta['refs'] ?? []; + } + + public function closed(?bool $closed = null) + { + if (is_null($closed)) { + return $this->meta['closed'] ?? false; + } + + $this->meta['closed'] = $closed; + + return $this; + } +} diff --git a/src/FieldTypes/Factories/ArrTypePairFactory.php b/src/FieldTypes/Factories/ArrTypePairFactory.php new file mode 100644 index 0000000..b06247c --- /dev/null +++ b/src/FieldTypes/Factories/ArrTypePairFactory.php @@ -0,0 +1,33 @@ +> $refs + * @return AbstractDefinition + */ + public static function definition(...$args): AbstractDefinition + { + // Accepts list via FieldType::union([...]) + // Uses variadic args for interface compatibility, so $args = [[ref1, ref2, ref3]] + $refs = current($args); + + return new UnionTypeDefinition(self::definitionParameters(...$refs)); + } + + private static function definitionParameters(string ...$refs): array + { + $nonLexiconRefs = array_filter($refs, fn ($ref) => ! in_array(LexiconContract::class, class_implements($ref), true)); + + if (! empty($nonLexiconRefs)) { + throw new \InvalidArgumentException(sprintf( + 'Each entry in $refs must be a class-string implementing LexiconContract. The following entries are invalid: %s', + implode(', ', $nonLexiconRefs) + )); + } + + return $refs; + } +} diff --git a/src/FieldTypes/FieldType.php b/src/FieldTypes/FieldType.php new file mode 100644 index 0000000..029f2de --- /dev/null +++ b/src/FieldTypes/FieldType.php @@ -0,0 +1,57 @@ +handler(), + $pairFactory->definition($inner->handler(), $inner->definition()) + ); + } + + /** + * @param class-string $target + * @return CastableObjectField + */ + public static function object(string $target): CastableObjectField + { + $pairFactory = new ObjectTypePairFactory(); + + return new CastableObjectField( + $pairFactory->handler(), + $pairFactory->definition($target) + ); + } + + /** + * @param list> $refs + * @return CastableUnionField + */ + public static function union(array $refs): CastableUnionField + { + $pairFactory = new UnionTypePairFactory(); + + return new CastableUnionField( + $pairFactory->handler(), + $pairFactory->definition($refs) + ); + } +} diff --git a/src/FieldTypes/Handlers/ArrTypeHandler.php b/src/FieldTypes/Handlers/ArrTypeHandler.php new file mode 100644 index 0000000..3d75a1f --- /dev/null +++ b/src/FieldTypes/Handlers/ArrTypeHandler.php @@ -0,0 +1,32 @@ +definition($arrDefinition); + + return array_map(function($item) use ($arrDefinition) { + return $arrDefinition->handler()->handle( + $item, + $arrDefinition->definition() + ); + }, $value); + } + + private function definition(ArrTypeDefinition $definition): ArrTypeDefinition + { + return $definition; + } +} diff --git a/src/FieldTypes/Handlers/ObjectTypeHandler.php b/src/FieldTypes/Handlers/ObjectTypeHandler.php new file mode 100644 index 0000000..f9fda80 --- /dev/null +++ b/src/FieldTypes/Handlers/ObjectTypeHandler.php @@ -0,0 +1,37 @@ +definition($definition); + + if ($value === null && $definition->nullable() === true) { + return null; + } + + /** @var class-string $target */ + $target = $definition->target(); + + if (! class_exists($target)) { + throw new \InvalidArgumentException("Class {$target} does not exist."); + } + + return new $target($value); + } + + private function definition(ObjectTypeDefinition $definition): ObjectTypeDefinition + { + return $definition; + } +} diff --git a/src/FieldTypes/Handlers/UnionTypeHandler.php b/src/FieldTypes/Handlers/UnionTypeHandler.php new file mode 100644 index 0000000..7d2ba1d --- /dev/null +++ b/src/FieldTypes/Handlers/UnionTypeHandler.php @@ -0,0 +1,41 @@ +definition($unionTypeDefinition); + + /** @var list> $refs */ + $refs = $unionTypeDefinition->refs(); + + $givenNSIDs = array_merge(...array_map( + fn (string $refClassName) => [$refClassName => $refClassName::nsid()], + $refs + )); + + $targetClass = array_search($targetNSID = $value['$type'], $givenNSIDs); + + if ($targetClass !== false) { + return new $targetClass($value); + } + + throw new InvalidArgumentException( + "Unable to resolve {$targetNSID} in given NSIDs: " . implode(', ', $givenNSIDs) + ); + } + + private function definition(UnionTypeDefinition $definition): UnionTypeDefinition + { + return $definition; + } +} diff --git a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php index 8000f6e..c918e95 100644 --- a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php +++ b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php @@ -40,7 +40,7 @@ public function jsonSerialize(): array ]; } - public function nsid(): string + public static function nsid(): string { return 'app.bsky.embed.images'; } diff --git a/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php b/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php index 72e83a6..2a5ddaa 100644 --- a/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php +++ b/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php @@ -27,7 +27,7 @@ public function jsonSerialize(): array return ['$type' => sprintf("%s#%s", $this->nsid(), $this->type())] + $this->schema(); } - public function nsid(): string + public static function nsid(): string { return 'app.bsky.richtext.facet'; } diff --git a/src/Lexicons/Traits/Lexicon.php b/src/Lexicons/Traits/Lexicon.php index 190df89..806a12e 100644 --- a/src/Lexicons/Traits/Lexicon.php +++ b/src/Lexicons/Traits/Lexicon.php @@ -6,7 +6,7 @@ trait Lexicon { use Serializable; - public function nsid(): string + public static function nsid(): string { $segments = explode( '\\', diff --git a/src/Responses/BaseResponse.php b/src/Responses/BaseResponse.php index 791881c..1433141 100644 --- a/src/Responses/BaseResponse.php +++ b/src/Responses/BaseResponse.php @@ -2,8 +2,10 @@ namespace Atproto\Responses; +use Atproto\Contracts\FieldTypes\FieldTypeHandlerContract; use Atproto\Contracts\Resources\ObjectContract; use Atproto\Exceptions\Resource\BadAssetCallException; +use Atproto\FieldTypes\CastableFields\CastableField; use Atproto\Support\Arr; use Atproto\Traits\Castable; @@ -99,6 +101,10 @@ private function parse(string $name) /** @var ?ObjectContract $cast */ $asset = Arr::get($this->casts(), $name); + if ($asset instanceof CastableField) { + return $asset->handler()->handle($value, $asset->definition()); + } + if ($asset) { $value = (new $asset($value))->cast(); } diff --git a/tests/Feature/FieldTypes/FieldTypeTest.php b/tests/Feature/FieldTypes/FieldTypeTest.php new file mode 100644 index 0000000..92dc8b4 --- /dev/null +++ b/tests/Feature/FieldTypes/FieldTypeTest.php @@ -0,0 +1,272 @@ + [ + '$type' => 'com.example.fake#view', + 'foo' => 'bar' + ], + 'items' => [ + ['foo' => 'baz'], + ['foo' => 'qux'] + ], + ]; + + $fields = [ + 'embed' => FieldType::union([ + FakeVariant::class, + ])->description('Union field'), + + 'items' => FieldType::array( + FieldType::object(FakeVariant::class)->nullable(false) + )->minLength(1)->maxLength(10), + ]; + + foreach ($fields as $key => $castable) { + $result = $castable->handler()->handle( + $input[$key], + $castable->definition() + ); + + if ($key === 'embed') { + $this->assertInstanceOf(FakeVariant::class, $result); + $this->assertEquals('bar', $result->data()['foo']); + } + + if ($key === 'items') { + $this->assertCount(2, $result); + $this->assertInstanceOf(FakeVariant::class, $result[0]); + $this->assertEquals('baz', $result[0]->data()['foo']); + } + } + } + + public function test_nullable_object_casts_to_null() + { + $input = ['data' => null]; + + $fields = [ + 'data' => FieldType::object(FakeVariant::class)->nullable(true), + ]; + + $result = $fields['data']->handler()->handle( + $input['data'], + $fields['data']->definition() + ); + + $this->assertNull($result); + } + + public function test_non_nullable_object_throws_on_null() + { + $this->expectException(\TypeError::class); + + $input = ['data' => null]; + + $fields = [ + 'data' => FieldType::object(FakeVariant::class)->nullable(false), + ]; + + $fields['data']->handler()->handle( + $input['data'], + $fields['data']->definition() + ); + } + + public function test_closed_union_rejects_unknown_type() + { + $this->expectException(\InvalidArgumentException::class); + + $input = [ + '$type' => 'com.unknown#bad', + 'foo' => 'bar', + ]; + + $field = FieldType::union([ + FakeVariant::class + ])->closed(true); + + $field->handler()->handle($input, $field->definition()); + } + + public function test_object_with_properties_casts_nested_objects() + { + $input = [ + 'outer' => [ + 'inner' => [ + 'foo' => 'bar' + ] + ] + ]; + + $inner = FieldType::object(FakeVariant::class); + $outer = FieldType::object(FakeVariant::class)->properties([ + 'inner' => $inner + ]); + + $result = $outer->handler()->handle($input['outer'], $outer->definition()); + + $this->assertInstanceOf(FakeVariant::class, $result); + $this->assertEquals(['inner' => ['foo' => 'bar']], $result->data()); + } + + public function test_description_reflected_in_serialized_metadata() + { + $field = FieldType::object(FakeVariant::class) + ->description('This is a test object'); + + $serialized = json_encode($field->definition()); + + $this->assertStringContainsString('"description":"This is a test object"', $serialized); + } + + public function test_open_union_allows_unknown_type_but_fails_without_handler() + { + $this->expectException(\InvalidArgumentException::class); + + $input = [ + '$type' => 'unknown.type#blah', + 'foo' => 'baz', + ]; + + $union = FieldType::union([ + FakeVariant::class + ])->closed(false); + + $union->handler()->handle($input, $union->definition()); + } + + public function test_nullable_default_is_false_and_throws_on_null() + { + $this->expectException(\TypeError::class); + + $input = ['data' => null]; + + $field = FieldType::object(FakeVariant::class); + + $field->handler()->handle($input['data'], $field->definition()); + } + + public function test_array_of_union_casts_properly() + { + $input = [ + ['foo' => 'bar', '$type' => FakeVariant::nsid()], + ['bar' => 'baz', '$type' => AnotherFakeVariant::nsid()], + ]; + + $field = FieldType::array( + FieldType::union([ + FakeVariant::class, + AnotherFakeVariant::class + ]) + ); + + $result = $field->handler()->handle($input, $field->definition()); + + $this->assertCount(2, $result); + $this->assertInstanceOf(FakeVariant::class, $result[0]); + $this->assertInstanceOf(AnotherFakeVariant::class, $result[1]); + } + + public function test_union_of_object_and_array_casts() + { + $input = [ + '$type' => 'com.example.arr#view', + 'data' => [ + ['foo' => 'a'], + ['foo' => 'b'] + ] + ]; + + $field = FieldType::union([ + FakeArrayWrapper::class, + FakeVariant::class, + ]); + + $result = $field->handler()->handle($input, $field->definition()); + + $this->assertInstanceOf(FakeArrayWrapper::class, $result); + $this->assertEquals('a', $result->data()['data'][0]['foo']); + } + + public function test_metadata_serialization_is_correct() + { + $field = FieldType::object(FakeVariant::class) + ->nullable(true) + ->description('Some object field') + ->properties([ + 'foo' => FieldType::object(FakeVariant::class) + ]); + + $definition = $field->definition(); + + $meta = $definition->metadata(); + + $this->assertEquals('object', $meta['type']); + $this->assertTrue($meta['nullable']); + $this->assertEquals('Some object field', $meta['description']); + $this->assertArrayHasKey('properties', $meta); + $this->assertIsArray($meta['properties']); + } +} + +class FakeVariant implements LexiconContract +{ + private array $data; + + public function __construct(array $data) + { + $this->data = $data; + } + + public function data(): array + { + return $this->data; + } + + public static function nsid(): string + { + return 'com.example.fake#view'; + } + + public function jsonSerialize():array { + return []; + } + + public function __toString(): string + { + return json_encode($this->data); + } +} + +class AnotherFakeVariant implements LexiconContract { + private array $data; + public function __construct(array $data) {$this->data = $data;} + public function data(): array { return $this->data; } + public static function nsid(): string { return 'com.example.other#view'; } + public function jsonSerialize():array { + return []; + } + public function __toString(): string { return json_encode($this->data); } +} + +class FakeArrayWrapper implements LexiconContract { + private array $data; + public function __construct(array $data) {$this->data = $data;} + public function data(): array { return $this->data; } + public static function nsid(): string { return 'com.example.arr#view'; } + public function jsonSerialize():array { + return []; + } + public function __toString(): string { return json_encode($this->data); } +} + diff --git a/tests/Feature/Lexicons/App/Bsky/Feed/GetTimelineTest.php b/tests/Feature/Lexicons/App/Bsky/Feed/GetTimelineTest.php index 2ee2a28..b7a66d5 100644 --- a/tests/Feature/Lexicons/App/Bsky/Feed/GetTimelineTest.php +++ b/tests/Feature/Lexicons/App/Bsky/Feed/GetTimelineTest.php @@ -30,7 +30,7 @@ public function testGetTimeline(): void foreach($feed as $entry) { $this->assertInstanceOf(PostObject::class, $post = $entry->post()); - $this->assertSame($client->authenticated()->did(), $post->author()->did()); + $this->assertSame($client->authenticated()->handle(), $post->author()->handle()); } } } diff --git a/tests/Feature/SessionManagementTest.php b/tests/Feature/SessionManagementTest.php index 1fbda17..df71dac 100644 --- a/tests/Feature/SessionManagementTest.php +++ b/tests/Feature/SessionManagementTest.php @@ -3,7 +3,6 @@ namespace Feature; use Atproto\Client; -use Atproto\Contracts\Resources\ResponseContract; use Atproto\Exceptions\BlueskyException; use Atproto\Responses\Com\Atproto\Server\CreateSessionResponse; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/FieldTypes/CastableFields/CastableFieldTest.php b/tests/Unit/FieldTypes/CastableFields/CastableFieldTest.php new file mode 100644 index 0000000..0202fb2 --- /dev/null +++ b/tests/Unit/FieldTypes/CastableFields/CastableFieldTest.php @@ -0,0 +1,62 @@ +assertSame($handler, $field->handler()); + $this->assertSame($definition, $field->definition()); + } + + public function test_it_sets_metadata_via_call(): void + { + $definition = new DummyDefinition(); + $handler = new DummyHandler(); + $field = new CastableField($handler, $definition); + + $field->description('Test description'); + + $this->assertEquals('Test description', $definition->metadata()['description']); + } + + public function test_it_throws_exception_on_invalid_call(): void + { + $this->expectException(\Error::class); + + $definition = new DummyDefinition(); + $handler = new DummyHandler(); + $field = new CastableField($handler, $definition); + + $field->nonexistentMethod(); + } +} + + +class DummyDefinition extends AbstractDefinition +{ + public function description(?string $description) + { + parent::description($description); + return $this; + } +} + +class DummyHandler implements FieldTypeHandlerContract +{ + public function handle($value, AbstractDefinition $definition) + { + return $value; + } +} diff --git a/tests/Unit/FieldTypes/Definitions/AbstractDefinitionTest.php b/tests/Unit/FieldTypes/Definitions/AbstractDefinitionTest.php new file mode 100644 index 0000000..eec6d84 --- /dev/null +++ b/tests/Unit/FieldTypes/Definitions/AbstractDefinitionTest.php @@ -0,0 +1,66 @@ +meta[$key] = $value; + } + }; + + $definition->set('type', 'object'); + $definition->set('description', 'Test object'); + + $this->assertEquals([ + 'type' => 'object', + 'description' => 'Test object', + ], $definition->metadata()); + } + + public function test_json_serialize_returns_filtered_metadata() + { + $definition = new class extends AbstractDefinition { + public function set(string $key, $value): void + { + $this->meta[$key] = $value; + } + }; + + $definition->set('type', 'array'); + $definition->set('description', null); // should be filtered + + $this->assertEquals([ + 'type' => 'array' + ], $definition->jsonSerialize()); + } + + public function test_type_returns_correct_type() + { + $definition = new class extends AbstractDefinition { + public function __construct() { + $this->meta['type'] = 'union'; + } + }; + + $this->assertEquals('union', $definition->type()); + } + + public function test_description_set_and_get_behavior() + { + $definition = new class extends AbstractDefinition {}; + + $this->assertNull($definition->description(null)); + + $definition->description('A sample description'); + + $this->assertEquals('A sample description', $definition->description(null)); + } +} diff --git a/tests/Unit/FieldTypes/Definitions/ArrTypeDefinitionTest.php b/tests/Unit/FieldTypes/Definitions/ArrTypeDefinitionTest.php new file mode 100644 index 0000000..e765686 --- /dev/null +++ b/tests/Unit/FieldTypes/Definitions/ArrTypeDefinitionTest.php @@ -0,0 +1,72 @@ +createMock(FieldTypeHandlerContract::class), + $this->createMock(AbstractDefinition::class) + ); + + $this->assertEquals('array', $def->type()); + } + + public function test_handler_and_definition_accessors() + { + $handler = $this->createMock(FieldTypeHandlerContract::class); + $innerDef = $this->createMock(AbstractDefinition::class); + + $def = new ArrTypeDefinition($handler, $innerDef); + + $this->assertSame($handler, $def->handler()); + $this->assertSame($innerDef, $def->definition()); + } + + public function test_items_set_and_get() + { + $def = $this->makeDefinition(); + $def->items('string'); + + $this->assertEquals('string', $def->items()); + } + + public function test_min_length_set_and_get() + { + $def = $this->makeDefinition(); + $def->minLength(3); + + $this->assertEquals(3, $def->minLength()); + } + + public function test_max_length_set_and_get() + { + $def = $this->makeDefinition(); + $def->maxLength(10); + + $this->assertEquals(10, $def->maxLength()); + } + + public function test_description_metadata() + { + $def = $this->makeDefinition(); + $def->description('A list of items'); + + $this->assertEquals('A list of items', $def->description(null)); + } + + private function makeDefinition(): ArrTypeDefinition + { + return new ArrTypeDefinition( + $this->createMock(FieldTypeHandlerContract::class), + $this->createMock(AbstractDefinition::class) + ); + } +} diff --git a/tests/Unit/FieldTypes/Definitions/ObjectTypeDefinitionTest.php b/tests/Unit/FieldTypes/Definitions/ObjectTypeDefinitionTest.php new file mode 100644 index 0000000..f96884f --- /dev/null +++ b/tests/Unit/FieldTypes/Definitions/ObjectTypeDefinitionTest.php @@ -0,0 +1,60 @@ +assertEquals('object', $def->type()); + } + + public function test_target_returns_correct_class() + { + $def = new ObjectTypeDefinition(\stdClass::class); + $this->assertEquals(\stdClass::class, $def->target()); + } + + public function test_nullable_set_and_get() + { + $def = new ObjectTypeDefinition(\stdClass::class); + + $def->nullable(true); + $this->assertTrue($def->nullable()); + + $def->nullable(false); + $this->assertFalse($def->nullable()); + } + + public function test_properties_are_stored_and_retrieved() + { + $field = $this->createMock(CastableField::class); + + $def = new ObjectTypeDefinition(\stdClass::class); + $def->properties(['foo' => $field]); + + $this->assertArrayHasKey('foo', $def->properties()); + $this->assertSame($field, $def->properties()['foo']); + } + + public function test_required_fields() + { + $def = new ObjectTypeDefinition(\stdClass::class); + $def->required(['foo', 'bar']); + + $this->assertEquals(['foo', 'bar'], $def->required()); + } + + public function test_description_set_and_get() + { + $def = new ObjectTypeDefinition(\stdClass::class); + $def->description('This is a test'); + + $this->assertEquals('This is a test', $def->description(null)); + } +} diff --git a/tests/Unit/FieldTypes/Definitions/UnionTypeDefinitionTest.php b/tests/Unit/FieldTypes/Definitions/UnionTypeDefinitionTest.php new file mode 100644 index 0000000..c41c64a --- /dev/null +++ b/tests/Unit/FieldTypes/Definitions/UnionTypeDefinitionTest.php @@ -0,0 +1,83 @@ +assertEquals('union', $def->type()); + } + + public function test_refs_returns_given_classes() + { + $refs = [DummyLexicon1::class, DummyLexicon2::class]; + $def = new UnionTypeDefinition($refs); + + $this->assertEquals($refs, $def->refs()); + } + + public function test_closed_set_and_get() + { + $def = new UnionTypeDefinition([DummyLexicon1::class]); + + $def->closed(true); + $this->assertTrue($def->closed()); + + $def->closed(false); + $this->assertFalse($def->closed()); + } + + public function test_description_works() + { + $def = new UnionTypeDefinition([DummyLexicon1::class]); + $def->description('This is a union'); + + $this->assertEquals('This is a union', $def->description(null)); + } +} + +class DummyLexicon1 implements LexiconContract +{ + public static function nsid(): string + { + return 'com.example.dummy1'; + } + + public function jsonSerialize(): array + { + return []; + } + + public function __toString(): string + { + return 'foo'; + } +} + +class DummyLexicon2 implements LexiconContract +{ + public static function nsid(): string + { + return 'com.example.dummy2'; + } + + public function jsonSerialize(): array + { + return []; + } + + public function __toString(): string + { + return 'bar'; + } +} diff --git a/tests/Unit/FieldTypes/Factories/ArrTypePairFactoryTest.php b/tests/Unit/FieldTypes/Factories/ArrTypePairFactoryTest.php new file mode 100644 index 0000000..c029fce --- /dev/null +++ b/tests/Unit/FieldTypes/Factories/ArrTypePairFactoryTest.php @@ -0,0 +1,32 @@ +assertInstanceOf(ArrTypeHandler::class, $handler); + } + + public function test_it_returns_arr_type_definition() + { + $innerHandler = $this->createMock(FieldTypeHandlerContract::class); + $innerDefinition = $this->createMock(AbstractDefinition::class); + + $definition = ArrTypePairFactory::definition($innerHandler, $innerDefinition); + + $this->assertInstanceOf(ArrTypeDefinition::class, $definition); + $this->assertSame($innerHandler, $definition->handler()); + $this->assertSame($innerDefinition, $definition->definition()); + } +} diff --git a/tests/Unit/FieldTypes/Factories/ObjectTypePairFactoryTest.php b/tests/Unit/FieldTypes/Factories/ObjectTypePairFactoryTest.php new file mode 100644 index 0000000..72292cf --- /dev/null +++ b/tests/Unit/FieldTypes/Factories/ObjectTypePairFactoryTest.php @@ -0,0 +1,28 @@ +assertInstanceOf(ObjectTypeHandler::class, $handler); + } + + public function test_it_returns_object_type_definition() + { + $targetClass = \stdClass::class; + + $definition = ObjectTypePairFactory::definition($targetClass); + + $this->assertInstanceOf(ObjectTypeDefinition::class, $definition); + $this->assertEquals($targetClass, $definition->target()); + } +} diff --git a/tests/Unit/FieldTypes/Factories/UnionTypePairFactoryTest.php b/tests/Unit/FieldTypes/Factories/UnionTypePairFactoryTest.php new file mode 100644 index 0000000..a34823e --- /dev/null +++ b/tests/Unit/FieldTypes/Factories/UnionTypePairFactoryTest.php @@ -0,0 +1,58 @@ +assertInstanceOf(UnionTypeHandler::class, $handler); + } + + public function test_it_returns_union_type_definition() + { + $definition = UnionTypePairFactory::definition([ + DummyLexiconClass::class, + ]); + + $this->assertInstanceOf(UnionTypeDefinition::class, $definition); + $this->assertContains(DummyLexiconClass::class, $definition->refs()); + } + + public function test_it_throws_exception_for_invalid_refs() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/invalid/i'); + + UnionTypePairFactory::definition([ + \stdClass::class, + ]); + } +} + +class DummyLexiconClass implements LexiconContract +{ + public static function nsid(): string + { + return 'com.example.dummy#def'; + } + + public function jsonSerialize(): array + { + return []; + } + + public function __toString(): string + { + return 'foo'; + } +} diff --git a/tests/Unit/FieldTypes/FieldTypeTest.php b/tests/Unit/FieldTypes/FieldTypeTest.php new file mode 100644 index 0000000..d589bd5 --- /dev/null +++ b/tests/Unit/FieldTypes/FieldTypeTest.php @@ -0,0 +1,54 @@ +assertSame($handler, $field->handler()); + } + + public function test_definition_returns_expected_definition() + { + $definition = new ObjectTypeDefinition(FakeObject::class); + $field = new CastableField(new ObjectTypeHandler(), $definition); + + $this->assertSame($definition, $field->definition()); + } + + public function test_magic_call_sets_metadata_correctly() + { + $definition = new ObjectTypeDefinition(FakeObject::class); + $field = new CastableField(new ObjectTypeHandler(), $definition); + + $field->description('Test description'); + + $this->assertEquals('Test description', $definition->description(null)); + } + + public function test_magic_call_throws_exception_for_invalid_method() + { + $this->expectException(\Error::class); + $this->expectExceptionMessage('ObjectTypeDefinition::'); + + $definition = new ObjectTypeDefinition(FakeObject::class); + $field = new CastableField(new ObjectTypeHandler(), $definition); + + $field->nonExistentMethod(); // should throw native exception + } +} + +class FakeObject +{ + // Dummy class for testing +} diff --git a/tests/Unit/FieldTypes/Handlers/ArrTypeHandlerTest.php b/tests/Unit/FieldTypes/Handlers/ArrTypeHandlerTest.php new file mode 100644 index 0000000..71cc734 --- /dev/null +++ b/tests/Unit/FieldTypes/Handlers/ArrTypeHandlerTest.php @@ -0,0 +1,34 @@ +createMock(AbstractDefinition::class); + + $arrDef = new ArrTypeDefinition($mockInnerHandler, $mockInnerDef); + $handler = new ArrTypeHandler(); + + $result = $handler->handle($input, $arrDef); + + $this->assertEquals($expected, $result); + } +} diff --git a/tests/Unit/FieldTypes/Handlers/ObjectTypeHandlerTest.php b/tests/Unit/FieldTypes/Handlers/ObjectTypeHandlerTest.php new file mode 100644 index 0000000..3e95f49 --- /dev/null +++ b/tests/Unit/FieldTypes/Handlers/ObjectTypeHandlerTest.php @@ -0,0 +1,40 @@ + 'Eldar']; + + $definition = new ObjectTypeDefinition(FakeObject::class); + $handler = new ObjectTypeHandler(); + + $result = $handler->handle($data, $definition); + + $this->assertInstanceOf(FakeObject::class, $result); + $this->assertEquals($data, $result->getData()); + } + + public function test_it_throws_if_class_does_not_exist() + { + $this->expectException(\InvalidArgumentException::class); + + $definition = new ObjectTypeDefinition('NonExistent\\Class\\Here'); + $handler = new ObjectTypeHandler(); + + $handler->handle([], $definition); + } +} + +class FakeObject +{ + private array $data; + public function __construct(array $data) { $this->data = $data; } + public function getData(): array { return $this->data; } +} diff --git a/tests/Unit/FieldTypes/Handlers/UnionTypeHandlerTest.php b/tests/Unit/FieldTypes/Handlers/UnionTypeHandlerTest.php new file mode 100644 index 0000000..fe45c9e --- /dev/null +++ b/tests/Unit/FieldTypes/Handlers/UnionTypeHandlerTest.php @@ -0,0 +1,58 @@ + FakeLexicon::nsid(), 'payload' => 'ok']; + + $definition = new UnionTypeDefinition([FakeLexicon::class]); + $handler = new UnionTypeHandler(); + + $result = $handler->handle($data, $definition); + + $this->assertInstanceOf(FakeLexicon::class, $result); + $this->assertEquals($data, $result->getData()); + } + + public function test_it_throws_when_type_does_not_match_any_ref() + { + $this->expectException(InvalidArgumentException::class); + + $data = ['$type' => 'unknown.nsid#type']; + + $definition = new UnionTypeDefinition([FakeLexicon::class]); + $handler = new UnionTypeHandler(); + + $handler->handle($data, $definition); + } + + public function test_it_throws_when_refs_include_invalid_classes() + { + $this->expectException(InvalidArgumentException::class); + + new UnionTypeDefinition([\stdClass::class]); + } +} + +class FakeLexicon implements LexiconContract +{ + private array $data; + public function __construct(array $data) { $this->data = $data; } + public function getData(): array { return $this->data; } + public static function nsid(): string { return 'com.example.fake#view'; } + public function jsonSerialize(): array {return [];} + + public function __toString(): string + { + return 'foo'; + } +} diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php index aa4f942..f7da56f 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php @@ -6,7 +6,6 @@ use Atproto\Contracts\DataModel\BlobContract; use Atproto\DataModel\Blob\Blob; use Atproto\Exceptions\InvalidArgumentException; -use Atproto\IPFS\CID\CID; use Atproto\Lexicons\App\Bsky\Embed\External; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/RecordWithMediaTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/RecordWithMediaTest.php index 7443737..535e51c 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/RecordWithMediaTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/RecordWithMediaTest.php @@ -5,7 +5,6 @@ use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaContract; use Atproto\Lexicons\App\Bsky\Embed\Record; use Atproto\Lexicons\App\Bsky\Embed\RecordWithMedia; -use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; use PHPUnit\Framework\TestCase; class RecordWithMediaTest extends TestCase diff --git a/tests/Unit/Lexicons/App/Bsky/Feed/SearchPostsTest.php b/tests/Unit/Lexicons/App/Bsky/Feed/SearchPostsTest.php index 4e99669..bfc57d6 100644 --- a/tests/Unit/Lexicons/App/Bsky/Feed/SearchPostsTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Feed/SearchPostsTest.php @@ -6,7 +6,6 @@ use Atproto\Enums\SearchPost\SortEnum; use Atproto\Exceptions\BlueskyException; use Atproto\Exceptions\InvalidArgumentException; -use Atproto\Lexicons\App\Bsky\Feed\Post; use Atproto\Lexicons\App\Bsky\Feed\SearchPosts; use Carbon\Carbon; use DateTimeImmutable; diff --git a/tests/Unit/Responses/Assets/ListObjectTest.php b/tests/Unit/Responses/Assets/ListObjectTest.php index 11d5d89..6a4f889 100644 --- a/tests/Unit/Responses/Assets/ListObjectTest.php +++ b/tests/Unit/Responses/Assets/ListObjectTest.php @@ -2,13 +2,9 @@ namespace Tests\Unit\Responses\Assets; -use Atproto\Responses\Objects\DatetimeObject; use Atproto\Responses\Objects\LabelObject; use Atproto\Responses\Objects\LabelsObject; -use Atproto\Responses\Objects\ListObject; use Atproto\Responses\Objects\PostObject; -use Atproto\Responses\Objects\ProfileObject; -use Atproto\Responses\Objects\ThreadGateObject; use Atproto\Responses\Objects\ViewerObject; use Carbon\Carbon; use Faker\Generator; diff --git a/tests/Unit/Responses/BaseResponseTest.php b/tests/Unit/Responses/BaseResponseTest.php index a8128f5..be4c39d 100644 --- a/tests/Unit/Responses/BaseResponseTest.php +++ b/tests/Unit/Responses/BaseResponseTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Responses; +use Atproto\Contracts\LexiconContract; use Atproto\Contracts\Resources\ObjectContract; use Atproto\Contracts\Resources\ResponseContract; use Atproto\Exceptions\Resource\BadAssetCallException; @@ -59,6 +60,65 @@ public function testResponseCanBeSerialized(): void $this->assertJsonStringEqualsJsonString($expected, (string) $this->resource); $this->assertJsonStringEqualsJsonString($expected, json_encode($this->resource)); } + + public function test_casts_fieldtype_object_correctly() + { + $response = new class([ + 'profile' => ['name' => 'Shah'], + ]) implements ResponseContract { + use BaseResponse; + use Castable; + + public function __construct($value) + { + $this->content = $value; + } + + protected function casts(): array + { + return [ + 'profile' => \Atproto\FieldTypes\FieldType::object(DummyProfile::class), + ]; + } + }; + + $result = $response->get('profile'); + + $this->assertInstanceOf(DummyProfile::class, $result); + $this->assertEquals('Shah', $result->data()['name']); + } + + public function test_casts_fieldtype_union_correctly() + { + $response = new class([ + 'embed' => [ + '$type' => DummyEmbed::nsid(), + 'url' => 'https://example.com', + ] + ]) implements ResponseContract { + use BaseResponse; + use Castable; + + public function __construct($value) + { + $this->content = $value; + } + + protected function casts(): array + { + return [ + 'embed' => \Atproto\FieldTypes\FieldType::union([ + DummyEmbed::class + ]), + ]; + } + }; + + $result = $response->get('embed'); + + $this->assertInstanceOf(DummyEmbed::class, $result); + $this->assertEquals('https://example.com', $result->data()['url']); + } } class TestableResponse implements ResponseContract @@ -78,3 +138,23 @@ class ExampleObject implements ObjectContract { use BaseObject; } + +class DummyProfile implements LexiconContract +{ + private array $data; + public function __construct(array $data) { $this->data = $data; } + public function data(): array { return $this->data; } + public static function nsid(): string { return 'app.test.profile'; } + public function jsonSerialize():array {return [];} + public function __toString(): string { return json_encode($this->data); } +} + +class DummyEmbed implements LexiconContract +{ + private array $data; + public function __construct(array $data) { $this->data = $data; } + public function data(): array { return $this->data; } + public static function nsid(): string { return 'app.test.embed'; } + public function jsonSerialize():array {return [];} + public function __toString(): string { return json_encode($this->data); } +}