derekmd

Experience

330

0 Best Reply Awards

  • Member Since 2 Years Ago
  • 2 Lessons Completed
  • 0 Favorites

7th December, 2016

derekmd left a reply on WithoutEvents() For Only Integration Test Setup • 1 year ago

withoutEvents() override

@EventFellows A few weeks ago I updated my reply above with an override to Illuminate\Foundation\Testing\Concerns\withoutEvents() that will also swap the facade instance in case $this->withoutEvents() is called before $this->withoutEventsFor().

event() just uses app('events') so it's dynamic to the mock or concrete object currently in the app container. However Event::fire() resolves the app('events') instance once then keeps that instance in the Illuminate\Support\Facades\Event singleton for the remainder of the current request unless Event::swap() is invoked.

With the override, you can be sure either event dispatching technique uses the expected Mockery object or concrete Illuminate\Contracts\Events\Dispatcher instance.

Example

Here's a real world integration test I can confirm passes assertions when Event::fire() is used inside Eloquent event listeners triggered within factory() calls in the below closure.

/* @test */
public function subscription_purchase_is_succcessful_with_valid_card
{
    $this->expectsJobs(App\Jobs\Email\OrderReceiptEmail::class);
    $this->expectsEvents(App\Events\PaymentMethodsChanged::class);

    list($order, $orderItem) = $this->withoutEventsFor(function () {
        return [
            factory(Order::class)->create([
                'sub_total' => '349.00',
                'tax' => '45.37',
                'total' => '394.37',
            ]),
            factory(OrderItem::class)->create([
                'order_id' => $order->id,
                'product_id' => ProductId::ANNUAL_SUBSCRIPTION,
                'amount' => '349.00',
                'tax' => '45.37',
                'transaction_type' => OrderItemTransactionType::Purchase,
            ]),
        ];
    });

    // etc.
}

23rd August, 2016

derekmd left a reply on WithoutEvents() For Only Integration Test Setup • 1 year ago

I took another crack at this and found instead calling Event::swap($instance) (instead of $this->app->instance('events', $instance)) will correctly restore Event::fire() connector behaviour after the $this->withoutEventsFor() closure.

class TestCase extends Illuminate\Foundation\Testing\TestCase
{
    /**
     * Temporarily disable event firing for a test setup closure function.
     *
     * @param callable $callback
     *
     * @return mixed Value returned by the callback.
     */
    protected function withoutEventsFor(callable $callback)
    {
        $events = $this->app['events'];

        $this->withoutEvents();

        $value = $callback();

        Event::swap($events);

        return $value;
    }

    /**
     * Create a user instance and authenticate on the session.
     *
     * Optional parameters:
     * - array $attributes Optional values for the User model.
     * - string $role
     */
    protected function beUser()
    {
        $arguments = func_get_args();
        $attributes = isset($arguments[0]) && is_array($arguments[0]) ? $arguments[0] : [];

        $this->user = factory(User::class)->create($attributes);
        $this->be($this->user);

        $role = array_get(array_slice($arguments, -1), 0);

        if (is_string($role) && !empty(trim($role))) {
            factory(RoleUser::class, $role)->create([
                'user_id' => $this->user->id,
            ]);
        }
    }
}

Above is a /tests project class my test suite always extends. You could also implement this as a trait although this method depends on [email protected]() and $this->app declared in Illuminate\Foundation\Testing\TestCase.

So in my scenario, Eloquent User::create() internally-triggered event may potentially dispatch another queued event depending on the condition of that model. e.g., App\Events\UserVerificationChanged is dispatched for newly registered users if they weren't a) created by an admin and b) preset as verified by that admin. The queued event would cause a notification on their account dashboard asking them to verify their account.

When I'm writing an integration test covering them later changing the account's email address, that above use case doesn't matter! But Laravel's factory(User::class)->create() will be dispatching those unrelated events. So now this works:

class UserApiControllerTest extends TestCase
{
    public function testAccountEmailChangeTriggersTwoEmailNotifications()
    {
        $this->expectsJobs([
            App\Jobs\Email\Auth\AccountEmailChangeEmail::class,
            App\Jobs\Email\Auth\VerificationEmail::class,
        ]);

        $this->withoutEventsFor([$this, 'beUser']);

        $faker = Faker\Factory::create();

        $inputs = [
            'email' => $faker->email,
        ];

        $url = route('api.v1.user.update');

        $this->put($url, $inputs)
            ->seeJson(['status' => 'success'])
            ->assertResponseOk();

        $this->seeInDatabase('users', [
            'id' => $this->user->id,
            'email' => $inputs['email'],
            'is_email_verified' => false,
        ]);
    }
}

If I remove either of those $this->expectsJobs() entries, the integration test now fails. Before it would still be suppressed due to the $this->withoutEventsFor()-invoked $this->withoutEvents() instance still being bound in the app container.

30th June, 2016

derekmd started a new conversation WithoutEvents() For Only Integration Test Setup • 2 years ago

I have a scenario where Event::fire() is called in both created and deleted events of a model. During the API's PUT update endpoint integration test I want to know:

  • that event isn't fired when the update action is successful. (i.e., "InvalidArgumentException: No connector for []" wouldn't being thrown during the integration test scenario.)

However factory()->create() when setting up the integration test does fire the created model's event. That means I'd have to call $this->withoutEvents() which means the integration test won't assert that event was mistakenly fired for the update action itself (not the test setup.)

So I'd like to temporarily suppress events in setup then re-enable for the test itself. A few things I tried:

  • Use $this->withoutEvents() in a PHPUnit @dataProvider method. However Laravel's service container isn't available in those methods so I can't call factory() to make the model instance.

  • Override the application's 'events' facade and restore it after a closure executes:

    /**
     * Temporarily disable event firing for a test setup closure function.
     *
     * @param callable $callback
     *
     * @return mixed Value returned by the callback.
     */
    protected function withoutEventsFor(callable $callback)
    {
        $events = $this->app['events'];
    
        $this->withoutEvents();
    
        $value = $callback();
    
        $this->app->instance('events', $events);
    
        return $value;
    }
    
    
    public function testUpdateIsPrimary()
    {
        $this->beUser();
    
        list($primary, $secondary) = $this->withoutEventsFor(function () {
            return [
                factory(UserPayoutMethod::class, 'paypal')->create([
                    'user_id' => $this->user->id,
                    'is_primary' => true,
                ]),
                factory(UserPayoutMethod::class, 'emt')->create([
                    'user_id' => $this->user->id,
                    'is_primary' => false,
                ]),
            ];
        });
    
        // then perform API test
    }
    

For the latter approach, I found Event::fire() referencing the app's 'events' singleton instance were still being suppressed as if the mock created by $this->withoutEvents() was still in place.

Anyone have an idea how I could go about ignoring event dispatches for factory()->create()?

11th February, 2016

derekmd left a reply on Combining Multiple HasMany Relationships Into One Model Relation Query Builder • 2 years ago

I didn't even think of an accessor method! For the sake of later chaining query conditions, messages() should likely be changed to a nested disjunctive:

public function messages()
{
    return Message::where(function ($query) {
        return $query->where('sender_user_id', $this->id)->orWhere('recipient_user_id', $this->id);
    });
}

However you're also correct about this relationship being unusable for with() or load(). Both of these methods require the relationship builder implementing Illuminate\Database\Eloquent\Relations\Relation. Obviously it's not too useful to eager load all of a user's messages but you can re-use the relationship for finer purposes. e.g., retrieve this user and the latest message from their 5 most recent conversation threads.

But anytime I'm extending vendor code (creating a dependency with that release) and passing ignored junk parameters to the parent class ($foreignKeys[0] to HasMany::__construct()), it gives an indication it's likely an over-engineered solution.

10th February, 2016

derekmd started a new conversation Combining Multiple HasMany Relationships Into One Model Relation Query Builder • 2 years ago

This thread is more an extension of https://laracasts.com/forum/?p=431-eloquent-relation-setup-two-foreign-keys-to-same-table as I have a messages table containing two foreign key sender_user_id and recipient_user_id that both reference the users table. My problem is what if I wish to create an Eloquent Relation on the model so I can take advantage of lazy/eager-loading and re-contextualizing the results? I will be adding a messages() method to the User model below.

class User extends \Illuminate\Database\Eloquent\Model
{
    public function messagesSent()
    {
        return $this->hasMany(Message::class, 'sender_user_id');
    }

    public function messagesReceived()
    {
        return $this->hasMany(Message::class, 'recipient_user_id');
    }
}

The solution I came up with was to extend \Illuminate\Database\Eloquent\Relations\HasMany to support multiple values in its $foreignKey parameter.

<?php

namespace App\Database\Eloquent\Relations;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

/**
 * Class HasManyWithKeys.
 *
 * Extend Illuminate\Database\Eloquent\Relations\HasMany so you can reference
 * unify HasMany relationships on different foreign keys in the same table.
 *
 * @package App\Database\Eloquent\Relations
 */
class HasManyWithKeys extends HasMany
{
    /**
     * @var array
     */
    protected $foreignKeys;

    /**
     * Create a new has one or many relationship instance.
     *
     * @param \Illuminate\Database\Eloquent\Builder $query
     * @param \Illuminate\Database\Eloquent\Model $parent
     * @param array $foreignKeys
     * @param string $localKey
     *
     * @return HasManyWithKeys
     */
    public function __construct(Builder $query, Model $parent, $foreignKeys, $localKey)
    {
        $this->localKey = $localKey;
        $this->foreignKeys = $foreignKeys;

        parent::__construct($query, $parent, $foreignKeys[0], $localKey);
    }

    /**
     * Set the base constraints on the relation query.
     */
    public function addConstraints()
    {
        if (static::$constraints) {
            $foreignKeys = $this->foreignKeys;

            $this->query->where(function ($query) use ($foreignKeys) {
                foreach ($foreignKeys as $foreignKey) {
                    $query->orWhere(function ($query) use ($foreignKey) {
                        $query->where($foreignKey, '=', $this->getParentKey())
                            ->whereNotNull($this->foreignKey);
                    });
                }
            });
        }
    }

    /**
     * Set the constraints for an eager load of the relation.
     *
     * @param array $models
     */
    public function addEagerConstraints(array $models)
    {
        $foreignKeys = $this->foreignKeys;

        $this->query->where(function ($query) use ($foreignKeys, $models) {
            foreach ($foreignKeys as $foreignKey) {
                $query->orWhere(function ($query) use ($foreignKey, $models) {
                    $$query->whereIn($foreignKey, $this->getKeys($models, $this->localKey));
                });
            }
        });
    }
}

Then in the above model definition I could add:

class User extends \Illuminate\Database\Eloquent\Model
{
    // ...

    public function messages()
    {
        return $this->hasManyWithKeys(Message::class, ['user_id_to', 'user_id_from']);
    }

    /**
     * Obviously move this to a parent class or Trait.
     *
     * Define a one-to-many relationship on a table with multiple foreign keys
     * referencing the same model.
     *
     * @param string $related
     * @param array|string $foreignKeys
     * @param string $localKey
     *
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
     */
    public function hasManyWithKeys($related, $foreignKeys = null, $localKey = null)
    {
        // Default to Illuminate\Database\Eloquent\Relations\HasMany for less
        // than two foreign keys.
        if (empty($foreignKeys) || is_string($foreignKeys)) {
            return $this->hasMany($related, $foreignKeys, $localKey);
        }

        if (count($foreignKeys) === 1) {
            return $this->hasMany($related, $foreignKeys[0], $localKey);
        }

        $instance = new $related;

        $localKey = $localKey ?: $this->getKeyName();

        $foreignKeys = array_map(function ($foreignKey) use ($instance) {
            return $instance->getTable() . '.' . $foreignKey;
        }, $foreignKeys);

        return new HasManyWithKeys($instance->newQuery(), $this, $foreignKeys, $localKey);
    }
}

With this you can then use the query builder returned by $user->messages() to retrieve message threads or the unread count for that user.

So did I reinvent the wheel here? Reading through https://laravel.com/docs/5.2/eloquent-relationships and the source of namespace \Illuminate\Database\Eloquent\Relations\*, I was unable to find the capabilities I was looking for. (And yes, that $foreignKeys[0] constructor parameter is cringe-worthy.)

Edit Your Profile
Update

Want to change your profile photo? We pull from gravatar.com.