mirror of
https://github.com/archtechx/airwire.git
synced 2025-12-12 02:34:04 +00:00
initial
This commit is contained in:
commit
d26fa93f1e
35 changed files with 2388 additions and 0 deletions
90
tests/Airwire/ComponentTest.php
Normal file
90
tests/Airwire/ComponentTest.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
use Airwire\Airwire;
|
||||
use Airwire\Attributes\Wired;
|
||||
use Airwire\Component;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
use function Pest\Laravel\withoutExceptionHandling;
|
||||
|
||||
beforeEach(fn () => Airwire::component('test-component', TestComponent::class));
|
||||
|
||||
test('properties are shared only if they have the Wired attribute', function () {
|
||||
expect(TestComponent::test()
|
||||
->state(['foo' => 'abc', 'bar' => 'xyz'])
|
||||
->send()
|
||||
->data
|
||||
)->toBe(['bar' => 'xyz', 'results' => [], 'second' => []]); // foo is not Wired
|
||||
});
|
||||
|
||||
test('methods are shared only if they have the Wired attribute', function () {
|
||||
expect(TestComponent::test()->call('foo')->send()->call('foo'))->toBeNull();
|
||||
expect(TestComponent::test()->call('bar')->send()->call('bar'))->not()->toBeNull();
|
||||
});
|
||||
|
||||
test('exceptions thrown during method execution are returned in the metadata', function () {
|
||||
expect(TestComponent::test()->call('brokenMethod')->send()->exceptions())->toHaveKey('brokenMethod')->toHaveCount(1);
|
||||
expect(TestComponent::test()->call('brokenMethod')->send()->exceptions('brokenMethod'))->toMatchArray(['message' => 'foobar']);
|
||||
});
|
||||
|
||||
test('readonly properties are not accepted by the component', function () {
|
||||
expect(TestComponent::test()->state(['results' => 'foo'])->send()->data)->not()->toHaveKey('readonly');
|
||||
});
|
||||
|
||||
test('mount can return readonly data', function () {
|
||||
$response = TestComponent::test()->call('mount')->send();
|
||||
|
||||
expect($response->call('mount'))
|
||||
->toHaveKey('results', 'foo')
|
||||
->not()->toHaveKey('readonly');
|
||||
});
|
||||
|
||||
test('properties can have custom default values', function () {
|
||||
expect(TestComponent::test()->hydrate()->getState()['results'])->toBeInstanceOf(Collection::class);
|
||||
expect(TestComponent::test()->hydrate()->getState()['results']->all())->toBe([]);
|
||||
});
|
||||
|
||||
test('the frontend can send an array that should be assigned to a collection', function () {
|
||||
expect(TestComponent::test()->state(['second' => ['foo' => 'bar']])->hydrate()->second->all())->toBe(['foo' => 'bar']);
|
||||
});
|
||||
|
||||
class TestComponent extends Component
|
||||
{
|
||||
public $foo;
|
||||
|
||||
#[Wired]
|
||||
public $bar;
|
||||
|
||||
#[Wired(readonly: true, default: [])]
|
||||
public Collection $results;
|
||||
|
||||
#[Wired(default: [])]
|
||||
public Collection $second;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
return [
|
||||
'readonly' => [
|
||||
'results' => 'foo',
|
||||
],
|
||||
'bar' => 'abc',
|
||||
];
|
||||
}
|
||||
|
||||
public function foo(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
#[Wired]
|
||||
public function bar(): int
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
#[Wired]
|
||||
public function brokenMethod()
|
||||
{
|
||||
throw new Exception('foobar');
|
||||
}
|
||||
}
|
||||
6
tests/Airwire/TypeScriptTest.php
Normal file
6
tests/Airwire/TypeScriptTest.php
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
|
||||
// todo generated definitions
|
||||
// todo : any as default
|
||||
// todo do custom DTOs get TS defs as well?
|
||||
// todo model relations
|
||||
203
tests/Airwire/TypehintsTest.php
Normal file
203
tests/Airwire/TypehintsTest.php
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
<?php
|
||||
|
||||
use Airwire\Airwire;
|
||||
use Airwire\Attributes\Encode;
|
||||
use Airwire\Component;
|
||||
use Airwire\Attributes\Wired;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
beforeEach(function () {
|
||||
Schema::create('products', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('description');
|
||||
$table->unsignedInteger('price');
|
||||
$table->string('secret')->nullable();
|
||||
$table->json('variants')->default('[]');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Airwire::component('typehint-component', TypehintComponent::class);
|
||||
|
||||
Airwire::typeTransformer(
|
||||
type: MyDTO::class,
|
||||
decode: fn (array $data) => new MyDTO($data['foo'], $data['abc']),
|
||||
encode: fn (MyDTO $dto) => ['foo' => $dto->foo, 'abc' => $dto->abc],
|
||||
);
|
||||
});
|
||||
|
||||
// beforeEach(fn () => DB::table('products')->truncate());
|
||||
|
||||
afterEach(fn () => Schema::dropIfExists('products'));
|
||||
|
||||
test('untyped properties are set directly from the json data', function () {
|
||||
foreach ([1, 'foo', ['a' => 'b']] as $value) {
|
||||
expect(Airwire::test(TypehintComponent::class)
|
||||
->state(['notype' => $value])
|
||||
->send()->data('notype')
|
||||
)->toBe($value);
|
||||
}
|
||||
});
|
||||
|
||||
test('strings and numbers are cast to the required type', function () {
|
||||
expect(Airwire::test(TypehintComponent::class)
|
||||
->state(['price' => '1'])
|
||||
->send()->data('price')
|
||||
)->toBe(1);
|
||||
|
||||
expect(Airwire::test(TypehintComponent::class)
|
||||
->state(['name' => 123])
|
||||
->send()->data('name')
|
||||
)->toBe('123');
|
||||
});
|
||||
|
||||
test('received model attributes are converted to unsaved model instances', function () {
|
||||
$model = Airwire::test(TypehintComponent::class)
|
||||
->state(['model' => ['name' => 'foo', 'price' => '100', 'variants' => [
|
||||
['price' => 200, 'color' => 'black']
|
||||
]]])
|
||||
->hydrate()->model;
|
||||
|
||||
expect($model)->toBeInstanceOf(Product::class);
|
||||
expect($model->name)->toBe('foo');
|
||||
expect($model->price)->toBe(100); // Types are converted per the casts
|
||||
expect($model->variants)->toBe([['price' => 200, 'color' => 'black']]); // Array casts are supported
|
||||
});
|
||||
|
||||
test('received model attributes must be fillable', function () {
|
||||
$model = Airwire::test(TypehintComponent::class)
|
||||
->state(['model' => ['name' => 'foo', 'price' => '100', 'secret' => 'bar']])
|
||||
->hydrate()->model;
|
||||
|
||||
expect($model)->toBeInstanceOf(Product::class);
|
||||
expect($model->name)->toBe('foo');
|
||||
expect($model->bar)->toBe(null); // Not fillable
|
||||
});
|
||||
|
||||
test('model properties can be hidden', function () {
|
||||
Product::create(['id' => 1, 'name' => 'foo', 'price' => 10, 'description' => 'bar']);
|
||||
|
||||
expect(Airwire::test(TypehintComponent::class)
|
||||
->call('first')
|
||||
->send()->call('first')
|
||||
)->toBeArray()->toHaveKey('created_at')->not()->toHaveKey('updated_at');
|
||||
});
|
||||
|
||||
test('received model ids are converted to model instances', function () {
|
||||
Product::create(['id' => 1, 'name' => 'foo', 'price' => 10, 'description' => 'bar']);
|
||||
|
||||
$model = Airwire::test(TypehintComponent::class)
|
||||
->state(['model' => 1])
|
||||
->hydrate()->model;
|
||||
|
||||
expect($model)->toBeInstanceOf(Product::class);
|
||||
expect($model->id)->toBe(1);
|
||||
expect($model->exists())->toBe(true);
|
||||
});
|
||||
|
||||
test('sent models are converted to arrays', function () {
|
||||
Product::create(['id' => 1, 'name' => 'foo', 'price' => 10, 'description' => 'bar']);
|
||||
|
||||
expect(Airwire::test(TypehintComponent::class)
|
||||
->call('first')
|
||||
->send()->call('first')
|
||||
)->toBeArray()->toHaveKey('id', 1);
|
||||
});
|
||||
|
||||
test('custom DTOs can be used', function () {
|
||||
// Sending
|
||||
expect(Airwire::test(TypehintComponent::class)
|
||||
->state(['dto' => [
|
||||
'foo' => 'bar',
|
||||
'abc' => 123,
|
||||
]])
|
||||
->hydrate()
|
||||
->dto
|
||||
)->toBeInstanceOf(MyDTO::class)->toHaveKey('foo', 'bar')->toHaveKey('abc', 123);
|
||||
|
||||
// Receiving
|
||||
expect(Airwire::test(TypehintComponent::class)
|
||||
->state(['dto' => [
|
||||
'foo' => 'bar',
|
||||
'abc' => 123,
|
||||
]])
|
||||
->send()
|
||||
->data('dto')
|
||||
)->toBe(['foo' => 'bar', 'abc' => 123]);
|
||||
});
|
||||
|
||||
test('model can be passed to a method', function () {
|
||||
expect(TypehintComponent::test()
|
||||
->call('save', ['name' => 'foo', 'price' => 10, 'description' => 'bar'])
|
||||
->send()
|
||||
->call('save')
|
||||
)->toBe('1');
|
||||
|
||||
expect(Product::count())->toBe(1);
|
||||
});
|
||||
|
||||
test('models can be encoded back to the id', function () {
|
||||
Product::create(['id' => 1, 'name' => 'foo', 'price' => 10, 'description' => 'bar']);
|
||||
|
||||
expect(TypehintComponent::test()
|
||||
->state(['model2' => 1])
|
||||
->send()->data('model2')
|
||||
)->toBe(1);
|
||||
});
|
||||
|
||||
class TypehintComponent extends Component
|
||||
{
|
||||
#[Wired]
|
||||
public $notype;
|
||||
|
||||
#[Wired]
|
||||
public string $name;
|
||||
|
||||
#[Wired]
|
||||
public int $price;
|
||||
|
||||
#[Wired]
|
||||
public Product $model;
|
||||
|
||||
#[Wired] #[Encode(method: 'getKey')] // todo add the same feature for Decode
|
||||
public Product $model2;
|
||||
|
||||
#[Wired]
|
||||
public MyDTO $dto;
|
||||
|
||||
#[Wired]
|
||||
public function first(): Product
|
||||
{
|
||||
return Product::first();
|
||||
}
|
||||
|
||||
#[Wired]
|
||||
public function save(Product $model): string
|
||||
{
|
||||
$model->save();
|
||||
|
||||
return $model->id;
|
||||
}
|
||||
}
|
||||
|
||||
class Product extends Model
|
||||
{
|
||||
public $fillable = ['id', 'name', 'price', 'description', 'variants'];
|
||||
public $hidden = ['updated_at'];
|
||||
|
||||
public $casts = [
|
||||
'price' => 'int',
|
||||
'variants' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
class MyDTO
|
||||
{
|
||||
public function __construct(
|
||||
public string $foo,
|
||||
public int $abc,
|
||||
) {}
|
||||
}
|
||||
220
tests/Airwire/ValidationTest.php
Normal file
220
tests/Airwire/ValidationTest.php
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
<?php
|
||||
|
||||
use Airwire\Airwire;
|
||||
use Airwire\Attributes\Wired;
|
||||
use Airwire\Component;
|
||||
|
||||
beforeAll(function () {
|
||||
Airwire::component('autovalidated-component', AutovalidatedComponent::class);
|
||||
Airwire::component('manually-validated-component', ManuallyValidatedComponent::class);
|
||||
Airwire::component('multi-input-component', MultiInputComponent::class);
|
||||
Airwire::component('implicitly-validated-component', ImplicitlyValidatedComponent::class);
|
||||
});
|
||||
|
||||
test('validation is executed on the new state (old state + changes)', function () {
|
||||
// ❌ Old state was invalid, too short name
|
||||
expect(Airwire::test('autovalidated-component')
|
||||
->state(['name' => 'sam'])
|
||||
->send()->errors()
|
||||
)->not()->toBeEmpty();
|
||||
|
||||
// ✅ Valid state after changes are applied
|
||||
expect(Airwire::test('autovalidated-component')
|
||||
->state(['name' => 'sam'])
|
||||
->changes(['name' => 'sam 123456789'])
|
||||
->send()->errors()
|
||||
)->toBeEmpty();
|
||||
|
||||
// ❌ Invalid state after changes arre applied
|
||||
expect(Airwire::test('autovalidated-component')
|
||||
->state(['name' => 'sam 123456789'])
|
||||
->changes(['email' => 'sam123456789@toolong.com'])
|
||||
->send()->errors()
|
||||
)->not()->toBeEmpty();
|
||||
});
|
||||
|
||||
test('properties CANNOT be changed when validation fails and strict validation is ON', function () {
|
||||
expect(Airwire::test('autovalidated-component')
|
||||
->state(['name' => 'original'])
|
||||
->changes(['name' => 'failing'])
|
||||
->send()->data('name')
|
||||
)->toBe('original');
|
||||
});
|
||||
|
||||
// Strict validation prevents ANY EXECUTION AT ALL when the received state is invalid
|
||||
test('properties CANNOT be changed when validation fails for any other properties and strict validation is ON', function () {
|
||||
expect(Airwire::test('autovalidated-component')
|
||||
->state(['name' => 'original', 'email' => 'original@email'])
|
||||
->changes(['name' => 'failing', 'email' => 'new@email'])
|
||||
->send()->data
|
||||
)->toBe([
|
||||
'name' => 'original',
|
||||
'email' => 'original@email',
|
||||
]);
|
||||
});
|
||||
|
||||
test('methods CANNOT be called when validation fails and strict validation is ON', function () {
|
||||
expect(Airwire::test('autovalidated-component')
|
||||
->state(['name' => 'sam'])
|
||||
->call('foo')
|
||||
->send()->metadata->calls
|
||||
)->not()->toHaveKey('foo');
|
||||
});
|
||||
|
||||
test('properties CAN be changed when validation fails and strict validation is OFF', function () {
|
||||
expect(Airwire::test('manually-validated-component')
|
||||
->state(['name' => 'sam']) // Failing validation
|
||||
->changes(['name' => 'failing'])
|
||||
->send()->data('name')
|
||||
)->toBe('failing');
|
||||
});
|
||||
|
||||
test('only changes are reversed, old state will be returned even if it is invalid', function () {
|
||||
$response = Airwire::test('manually-validated-component')
|
||||
->state(['name' => 'sam']) // ❌ Failing validation
|
||||
->call('foo')
|
||||
->send();
|
||||
|
||||
expect($response->data('name'))->toBe('sam');
|
||||
});
|
||||
|
||||
test('when methods call validate() and it fails, execution is stopped', function () {
|
||||
$response = Airwire::test('manually-validated-component')
|
||||
->state(['name' => 'sam']) // ❌ Failing validation
|
||||
->call('foo') // No validation
|
||||
->send();
|
||||
|
||||
expect($response->data('name'))->toBe('sam');
|
||||
expect($response->call('foo'))->toBe('bar');
|
||||
expect($response->call('abc'))->toBe(null);
|
||||
|
||||
$response = Airwire::test('manually-validated-component')
|
||||
->state(['name' => 'sam 123456789', 'email' => 'foo']) // ✅ Passing validation
|
||||
->call('foo')
|
||||
->call('abc') // Manual validation
|
||||
->send();
|
||||
|
||||
expect($response->call('foo'))->toBe('bar');
|
||||
expect($response->call('abc'))->toBe('xyz');
|
||||
});
|
||||
|
||||
test('validate can be used to prevent updating', function () {
|
||||
expect(MultiInputComponent::test()
|
||||
->state(['name' => 'original', 'email' => 'valid@mail'])
|
||||
->changes(['name' => 'invalid'])
|
||||
->send()->data('name')
|
||||
)->toBe('original');
|
||||
});
|
||||
|
||||
test('validated can be used to validate the specified properties and get their values', function () {
|
||||
expect(MultiInputComponent::test()
|
||||
->state(['name' => 'invalid', 'email' => 'valid@mail'])
|
||||
->call('method')
|
||||
->send()->call('method')
|
||||
)->toBe(null);
|
||||
|
||||
expect(MultiInputComponent::test()
|
||||
->state(['name' => 'very valid', 'email' => 'valid@mail'])
|
||||
->call('method')
|
||||
->send()->call('method')
|
||||
)->toBe(['name' => 'very valid', 'email' => 'valid@mail']);
|
||||
});
|
||||
|
||||
test('uncaught validation exceptions in hydrate terminate execution', function () {
|
||||
expect(
|
||||
ImplicitlyValidatedComponent::test()
|
||||
->state(['foo' => 'abc'])
|
||||
->send()->errors()
|
||||
)->toHaveKey('foo');
|
||||
});
|
||||
|
||||
class AutovalidatedComponent extends Component
|
||||
{
|
||||
#[Wired]
|
||||
public string $name;
|
||||
|
||||
#[Wired]
|
||||
public string $email;
|
||||
|
||||
public $rules = [
|
||||
'name' => ['required', 'min:10'],
|
||||
'email' => ['nullable', 'max:10'],
|
||||
];
|
||||
|
||||
#[Wired]
|
||||
public function foo()
|
||||
{
|
||||
return 'bar';
|
||||
}
|
||||
}
|
||||
|
||||
class ManuallyValidatedComponent extends Component
|
||||
{
|
||||
public bool $strictValidation = false;
|
||||
|
||||
#[Wired]
|
||||
public string $name;
|
||||
|
||||
#[Wired]
|
||||
public string $email;
|
||||
|
||||
public $rules = [
|
||||
'name' => ['required', 'min:10'],
|
||||
'email' => ['nullable', 'max:10'],
|
||||
];
|
||||
|
||||
#[Wired]
|
||||
public function foo()
|
||||
{
|
||||
return 'bar';
|
||||
}
|
||||
|
||||
#[Wired]
|
||||
public function abc()
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
return 'xyz';
|
||||
}
|
||||
}
|
||||
|
||||
class MultiInputComponent extends Component
|
||||
{
|
||||
public bool $strictValidation = false;
|
||||
|
||||
#[Wired]
|
||||
public string $name;
|
||||
|
||||
#[Wired]
|
||||
public string $email;
|
||||
|
||||
public $rules = [
|
||||
'name' => ['required', 'min:10'],
|
||||
'email' => ['nullable', 'max:20'],
|
||||
];
|
||||
|
||||
public function updating(string $property, mixed $new, mixed $old): bool
|
||||
{
|
||||
return $this->validate($property);
|
||||
}
|
||||
|
||||
#[Wired]
|
||||
public function method()
|
||||
{
|
||||
return $this->validated();
|
||||
}
|
||||
}
|
||||
|
||||
class ImplicitlyValidatedComponent extends Component
|
||||
{
|
||||
public bool $strictValidation = false;
|
||||
|
||||
#[Wired()]
|
||||
public string $foo;
|
||||
|
||||
public array $rules = [
|
||||
'foo' => ['required', 'min:10'],
|
||||
];
|
||||
|
||||
// validated in dehydrate()
|
||||
}
|
||||
45
tests/Pest.php
Normal file
45
tests/Pest.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Test Case
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The closure you provide to your test functions is always bound to a specific PHPUnit test
|
||||
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
|
||||
| need to change it using the "uses()" function to bind a different classes or traits.
|
||||
|
|
||||
*/
|
||||
|
||||
uses(Airwire\Tests\TestCase::class)->in('Airwire');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Expectations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When you're writing tests, you often need to check that values meet certain conditions. The
|
||||
| "expect()" function gives you access to a set of "expectations" methods that you can use
|
||||
| to assert different things. Of course, you may extend the Expectation API at any time.
|
||||
|
|
||||
*/
|
||||
|
||||
expect()->extend('toBeOne', function () {
|
||||
return $this->toBe(1);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Functions
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
|
||||
| project that you don't want to repeat in every file. Here you can also expose helpers as
|
||||
| global functions to help you to reduce the number of lines of code in your test files.
|
||||
|
|
||||
*/
|
||||
|
||||
function something()
|
||||
{
|
||||
// ..
|
||||
}
|
||||
16
tests/TestCase.php
Normal file
16
tests/TestCase.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace Airwire\Tests;
|
||||
|
||||
use Airwire\AirwireServiceProvider;
|
||||
use Orchestra\Testbench\TestCase as TestbenchTestCase;
|
||||
|
||||
class TestCase extends TestbenchTestCase
|
||||
{
|
||||
protected function getPackageProviders($app)
|
||||
{
|
||||
return [
|
||||
AirwireServiceProvider::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue