Laravel(Sendgrid,event,batch)

モデル(アップデート走る時にイベント発火)

    /**
     * @inheritdoc
     */
    protected static function boot()
    {
        parent::boot();
        static::deleting(function (Customer $customer) {
            $customer->address()->delete();
            $customer->tags()->detach();
            $customer->carts()->delete();
        });
        static::updated(function (Customer $customer) {
            if ($customer->isDirty('mailmaga_flg') && $customer->mailmaga_flg === true) {
                // mailmaga_flgがtrueに変更された場合にイベントを発火
                Event::dispatch(new MailmagazineFlagEnabled($customer));
            }
        });
        static::setScopes();
    }

イベント

<?php

namespace Oms\Events;

use Illuminate\Foundation\Events\Dispatchable;
use Oms\Models\Customer;

class MailmagazineFlagEnabled
{
    use Dispatchable;

    public Customer $customer;

    /**
     * Create a new event instance.
     *
     * @return void
     */
    public function __construct(Customer $customer)
    {
        $this->customer = $customer;
    }
}

eventServiceProvider

<?php

namespace Oms\Providers;

use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Oms\Listeners\MailmagazineFlagEventListener;

class EventServiceProvider extends ServiceProvider
{
    /**
     * The event listener mappings for the application.
     *
     * @var array
     */
    protected $listen = [
        //
    ];

    /**
     * Register any events for your application
     *
     * @var array
     */
    protected $subscribe = [
        'Oms\Listeners\UserEventListener',
        'Oms\Listeners\StoreSearchTargetPost',
        'Oms\Listeners\StoreSearchTargetPage',
        'Oms\Listeners\OutputInvoiceAndQuotation',
        MailmagazineFlagEventListener::class,
    ];
}

リスナー

<?php

declare(strict_types=1);

namespace Oms\Listeners;

use Illuminate\Contracts\Events\Dispatcher;
use Oms\Events\MailmagazineFlagEnabled;
use Oms\ExternalApi\Sendgrid\SuppressionApi;

class MailmagazineFlagEventListener
{
    private SuppressionApi $suppressionApi;

    public function __construct(SuppressionApi $suppressionApi)
    {
        $this->suppressionApi = $suppressionApi;
    }

    public function subscribe(Dispatcher $event): void
    {
        $event->listen(
            MailmagazineFlagEnabled::class,
           'Oms\ListenersMailmagazineFlagEventListener@onEnabled'
        );
    }

    public function onEnabled(MailmagazineFlagEnabled $event): void
    {
        $customer = $event->customer;

        // NOTE: 迷惑メール報告リストに入っていた場合にメールを送れるようにする為に削除
        $this->suppressionApi->deleteSpamReports($customer->email);


        /**
         * 届いたメールのOne-Click Unsubscribe機能をクリックおよび
         * メール末尾からSendgridのオプトアウト機能を使用した場合、
         * そのメールアドレスが、Sendgrid内のglobal unsubまたはgroup unsub(配信停止)に入るので
         * 両方から削除する必要がある
         *
         * ※global unsub, group unsub はおそらくグローバルサプレッションリストとグループサプレッションリストのこと
         */
        $this->suppressionApi->deleteGlobalSuppression($customer->email);
        $this->deleteEmailFromAllGroupSuppression($customer->email);
    }

    private function deleteEmailFromAllGroupSuppression(string $email): void
    {
        $emails = $this->suppressionApi->getGroupSuppressions();

        if (empty($emails)) {
            return;
        }

        if (in_array($email, $emails)) {
            $this->suppressionApi->deleteGroupSuppression($email);
        }
    }
}

API専用クラス

<?php

declare(strict_types=1);

namespace Oms\ExternalApi\Sendgrid;

use GuzzleHttp\Client;
use Oms\ExternalApi\Sendgrid\Traits\Mockable;
use Psr\Http\Message\ResponseInterface;

class SuppressionApi
{
    use Mockable;

    private Client $client;

    // 「配信停止」グループ
    public const UNSUBSCRIBE_GROUP_ID = 19977;

    public function __construct()
    {
        $config = [
            'base_uri' => config('oms.sendgrid.host') . '/v3/',
        ];

        $this->client = app()->get(Client::class, ['config' => $config]);
    }

    /**
     * https://sendgrid.kke.co.jp/docs/API_Reference/Web_API_v3/Suppression_Management/global_suppressions.html#List-all-globally-unsubscribed-email-addresses-GET
     *
     * @param array{start_time: string, end_time: string, limit: int, offset: int} $queryParams
     *
     * @throws \GuzzleHttp\Exception\GuzzleException
     * @throws \JsonException
     */
    public function getGlobalSuppressions(array $queryParams): array
    {
        $baseOptions = config('oms.sendgrid.base_request_options');
        $mergedOptions = array_merge($baseOptions, ['query' => $queryParams]);
        $response = $this->client->request(
            'GET',
            'suppression/unsubscribes',
            $mergedOptions
        );

        return json_decode(
            $response->getBody()->getContents(),
            true,
            512,
            JSON_THROW_ON_ERROR
        );
    }

    /**
     * @param string $email
     * https://sendgrid.kke.co.jp/docs/API_Reference/Web_API_v3/Suppression_Management/global_suppressions.html#Delete-a-Global-Suppression-DELETE
     */
    public function deleteGlobalSuppression(string $email): ResponseInterface
    {
        $baseOptions = config('oms.sendgrid.base_request_options');
        return $this->client->request(
            'DELETE',
            "asm/suppressions/global/{$email}",
            $baseOptions
        );
    }

    /**
     * @param array{start_time: string, end_time: string, limit: int, offset: int} $queryParams
     * https://sendgrid.kke.co.jp/docs/API_Reference/Web_API_v3/spam_reports.html#List-all-spam-reports-GET
     */
    public function getSpamReports(array $queryParams): array
    {
        $baseOptions = config('oms.sendgrid.base_request_options');
        $mergedOptions = array_merge($baseOptions, ['query' => $queryParams]);
        $response = $this->client->request(
            'GET',
            'suppression/spam_reports',
            $mergedOptions
        );

        return json_decode(
            $response->getBody()->getContents(),
            true,
            512,
            JSON_THROW_ON_ERROR
        );
    }

    /**
     * @param string $email
     * https://sendgrid.kke.co.jp/docs/API_Reference/Web_API_v3/spam_reports.html#Delete-a-specific-spam-report-DELETE
     */
    public function deleteSpamReports(string $email): ResponseInterface
    {
        $baseOptions = config('oms.sendgrid.base_request_options');
        return $this->client->request(
            'DELETE',
            "suppression/spam_reports/{$email}",
            $baseOptions
        );
    }

    /**
     * https://sendgrid.kke.co.jp/docs/API_Reference/Web_API_v3/Suppression_Management/suppressions.html#-Get-suppressed-addresses
     */
    public function getGroupSuppressions(): array
    {
        $baseOptions = config('oms.sendgrid.base_request_options');
        $groupId = self::UNSUBSCRIBE_GROUP_ID;

        $response = $this->client->request(
            'GET',
            "asm/groups/{$groupId}/suppressions",
            $baseOptions
        );

        return json_decode(
            $response->getBody()->getContents(),
            true,
            512,
            JSON_THROW_ON_ERROR
        );
    }

    /**
     * @param string $email
     * https://sendgrid.kke.co.jp/docs/API_Reference/Web_API_v3/Suppression_Management/suppressions.html#--DELETE
     */
    public function deleteGroupSuppression(string $email): ResponseInterface
    {
        $baseOptions = config('oms.sendgrid.base_request_options');
        $groupId = self::UNSUBSCRIBE_GROUP_ID;

        return $this->client->request(
            'DELETE',
            "asm/groups/{$groupId}/suppressions/{$email}",
            $baseOptions
        );
    }
}

トレイと

<?php

namespace Oms\ExternalApi\Sendgrid\Traits;

use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\MessageFormatter;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use Psr\Log\LoggerInterface;

trait Mockable
{
    public static function mock()
    {
        $config = [
            'base_uri' => config('oms.sendgrid.host') . '/v3',
        ];

        $mock = new MockHandler([
            new Response(200, [], 'ok'),
            new Response(200, [], 'ok'),
            new Response(200, [], 'ok'),
        ]);

        $stack = HandlerStack::create($mock);
        $stack->push(
            Middleware::log(
                app(LoggerInterface::class),
                new MessageFormatter('{req_headers} {req_body}')
            )
        );

        $config['handler'] = $stack;

        $mockClient = new Client($config);
        app()->instance(Client::class, $mockClient);

        return new self();
    }
}

テスト(API)

<?php

namespace Tests\Unit\ExternalApi\Sendgrid\BaseClientTest;

use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Response;
use Oms\ExternalApi\Sendgrid\SuppressionApi;
use Tests\TestCase;

class SuppressionApiTest extends TestCase
{
    /**
     * @test
     */
    public function getGlobalSupppretions()
    {
        $queryParams = [
            'start_time' => 1443651141,
            'end_time' => 1443651154,
            'limit' => 10,
            'offset' => 0
        ];
        $baseOptions = config('oms.sendgrid.base_request_options');
        $mergedOptions = array_merge($baseOptions, ['query' => $queryParams]);

        $mockResponse = $this->mock(Response::class);
        $mockResponse->shouldReceive('getBody->getContents')
            ->andReturn('[]');

        $mockClient = $this->mock(Client::class);
        $mockClient->shouldReceive('request')
        ->once()
        ->withArgs([
            'GET',
            'suppression/unsubscribes',
            $mergedOptions,
        ])
            ->andReturn($mockResponse);

        $this->app->instance(Client::class, $mockClient);

        $api = new SuppressionApi();
        $api->getGlobalSuppressions($queryParams);
    }

    /**
     * @test
     */
    public function deleteGlobalSuppression()
    {
        $email = 'sample@sample.com';
        $baseOptions = config('oms.sendgrid.base_request_options');

        $mockClient = $this->mock(Client::class);
        $mockClient->shouldReceive('request')
        ->once()
        ->withArgs([
            'DELETE',
            "asm/suppressions/global/{$email}",
            $baseOptions,
        ])
        ->andReturn(new Response());

        $this->app->instance(Client::class, $mockClient);

        $api = new SuppressionApi();
        $response = $api->deleteGlobalSuppression($email);

        $this->assertTrue($response instanceof Response);
    }

    /**
     * @test
     */
    public function getSpamReports()
    {
        $queryParams = [
            'start_time' => 1443651141,
            'end_time' => 1443651154,
            'limit' => 10,
            'offset' => 0
        ];
        $baseOptions = config('oms.sendgrid.base_request_options');
        $mergedOptions = array_merge($baseOptions, ['query' => $queryParams]);

        $mockResponse = $this->mock(Response::class);
        $mockResponse->shouldReceive('getBody->getContents')
            ->andReturn('[]');

        $mockClient = $this->mock(Client::class);
        $mockClient->shouldReceive('request')
        ->once()
        ->withArgs([
            'GET',
            'suppression/spam_reports',
            $mergedOptions,
        ])
            ->andReturn($mockResponse);

        $this->app->instance(Client::class, $mockClient);

        $api = new SuppressionApi();
        $api->getSpamReports($queryParams);
    }

    /**
     * @test
     */
    public function deleteSpamReports()
    {
        $email = 'sample@sample.com';
        $baseOptions = config('oms.sendgrid.base_request_options');

        $mockClient = $this->mock(Client::class);
        $mockClient->shouldReceive('request')
        ->once()
        ->withArgs([
            'DELETE',
            "suppression/spam_reports/{$email}",
            $baseOptions,
        ])
        ->andReturn(new Response());

        $this->app->instance(Client::class, $mockClient);

        $api = new SuppressionApi();
        $response = $api->deleteSpamReports($email);

        $this->assertTrue($response instanceof Response);
    }

    /**
     * @test
     */
    public function getGroupSuppressions()
    {
        $baseOptions = config('oms.sendgrid.base_request_options');
        $groupId = SuppressionApi::UNSUBSCRIBE_GROUP_ID;

        $mockResponse = $this->mock(Response::class);
        $mockResponse->shouldReceive('getBody->getContents')
            ->andReturn('[]');

        $mockClient = $this->mock(Client::class);
        $mockClient->shouldReceive('request')
            ->once()
            ->withArgs([
                'GET',
                "asm/groups/{$groupId}/suppressions",
                $baseOptions,
            ])
            ->andReturn($mockResponse);

        $this->app->instance(Client::class, $mockClient);

        $api = new SuppressionApi();
        $api->getGroupSuppressions();
    }

    /**
     * @test
     */
    public function deleteGroupSuppression()
    {
        $groupId = SuppressionApi::UNSUBSCRIBE_GROUP_ID;
        $email = 'sample@sample.com';

        $baseOptions = config('oms.sendgrid.base_request_options');

        $mockClient = $this->mock(Client::class);
        $mockClient->shouldReceive('request')
        ->once()
        ->withArgs([
            'DELETE',
            "asm/groups/{$groupId}/suppressions/{$email}",
            $baseOptions
        ])
        ->andReturn(new Response());

        $this->app->instance(Client::class, $mockClient);

        $api = new SuppressionApi();
        $response = $api->deleteGroupSuppression($email);

        $this->assertTrue($response instanceof Response);
    }
}

テスト(リスナー apiをモックする)

<?php

namespace Tests\Unit\Listeners;

use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Response;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Oms\Events\MailmagazineFlagEnabled;
use Oms\ExternalApi\Sendgrid\SuppressionApi;
use Oms\Listeners\MailmagazineFlagEventListener;
use Oms\Models\Customer;
use Tests\TestCase;

class MailmagazineFlagEventListenerTest extends TestCase
{
    use RefreshDatabase;

    private string $email;

    private Customer $customer;

    public function setUp(): void
    {
        parent::setUp();
        $this->email = 'sample@sample.com';
        $this->customer = factory(Customer::class)->create(['email' => $this->email]);
    }

    /**
     * @test
     */
    public function 各拒否リストのAPIが呼び出されていること()
    {
        $suppressionApiMock = $this->mock(SuppressionApi::class);
        $suppressionApiMock->shouldReceive('deleteSpamReports')
        ->once()
        ->withArgs([$this->email])
        ->andReturn(new Response());

        $suppressionApiMock->shouldReceive('deleteGlobalSuppression')
        ->once()
        ->withArgs([$this->email])
        ->andReturn(new Response());

        $suppressionApiMock->shouldReceive('getGroupSuppressions')
        ->once()
        ->andReturn([$this->email]);

        $suppressionApiMock->shouldReceive('deleteGroupSuppression')
        ->once()
        ->withArgs([$this->email])
        ->andReturn(new Response());

        $event = new MailmagazineFlagEnabled($this->customer);

        $listener = new MailmagazineFlagEventListener($suppressionApiMock);
        $listener->onEnabled($event);
    }

    /**
     * @test
     */
    public function グループサプレッションにメールアドレスが登録されていない場合、グループサプレッションの拒否リストのAPIが呼び出されないこと()
    {
        $event = new MailmagazineFlagEnabled($this->customer);

        $suppressionApiMock = $this->mock(SuppressionApi::class);
        $suppressionApiMock->shouldReceive('deleteSpamReports')
        ->once()
        ->withArgs([$this->email])
        ->andReturn(new Response());

        $suppressionApiMock->shouldReceive('deleteGlobalSuppression')
        ->once()
        ->withArgs([$this->email])
        ->andReturn(new Response());

        $suppressionApiMock->shouldReceive('getGroupSuppressions')
        ->once()
        ->andReturn([]);

        $suppressionApiMock->shouldNotReceive('deleteGroupSuppression')
        ->withArgs([$this->email])
        ->andReturn(new Response());

        $listener = new MailmagazineFlagEventListener($suppressionApiMock);
        $listener->onEnabled($event);
    }
}

バッチ

<?php

namespace Oms\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Oms\Console\Commands\Migrations\Traits\Loggable;
use Oms\ExternalApi\Sendgrid\SuppressionApi;
use Oms\Models\Customer;

class SyncSendgridSuppressionToMailmagazineFlag extends Command
{
    use Loggable;

    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'oms:sync_sendgrid_suppression_to_mailmagazine_flag';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Sendgridのグローバルサプレッションリストの内容をcustomerのmailmaga_flgに反映する';

    private SuppressionApi $suppressionApi;

    public function __construct(SuppressionApi $suppressionApi)
    {
        parent::__construct();

        $this->suppressionApi = $suppressionApi;
    }

    /**
     * Execute the console command.
     *
     *
     * @return void
     */
    public function handle(): void
    {
        $this->info('oms:sync_sendgrid_suppression_to_mailmagazine_flag started');

        DB::transaction(function () {
            $this->updateMailmagazineFlagFromGlobalSuppression();
            $this->updateMailmagazineFlagFromSpamReports();
            $this->updateMailMagazineFlagFromGroupSuppression();
        });

        $this->info('oms:sync_sendgrid_suppression_to_mailmagazine_flag finished');
    }

    /**
     * Sendgridのグローバルサプレッションリストに登録されたメールアドレスを取得し、
     * 現会員のmailmaga_flgをfalseに更新する
     *
     * 退会者は対象外とする
     */
    public function updateMailmagazineFlagFromGlobalSuppression(): void
    {
        $queryParams = [
            'start_time' => '',
            'end_time' => '',
            // NOTE: 上限
            'limit' => 500,
            'offset' => 0,
        ];

        while ($response = $this->suppressionApi->getGlobalSuppressions($queryParams)) {
            $emails = array_column($response, 'email');

            $this->updateCustomerMailMagazineFlagWith($emails);

            // Api呼ぶ度にoffset更新
            $queryParams['offset'] += $queryParams['limit'];
        }
    }

    public function updateMailmagazineFlagFromSpamReports(): void
    {
        $queryParams = [
            'start_time' => '',
            'end_time' => '',
            // NOTE: 上限
            'limit' => 500,
            'offset' => 0,
        ];

        while ($response = $this->suppressionApi->getSpamReports($queryParams)) {
            $emails = array_column($response, 'email');

            $this->updateCustomerMailMagazineFlagWith($emails);

            // Api呼ぶ度にoffset更新
            $queryParams['offset'] += $queryParams['limit'];
        }
    }

    public function updateMailMagazineFlagFromGroupSuppression(): void
    {
        $emails = $this->suppressionApi->getGroupSuppressions();

        $this->updateCustomerMailMagazineFlagWith($emails);
    }

    /**
     * @param string[] $emails
     */
    private function updateCustomerMailMagazineFlagWith(array $emails): void
    {
        // NOTE: メモリに載せずに一括で処理する
        // eloquentのイベントが発火しないため注意する
        Customer::query()
            ->where(['is_left' => false])
            ->where(['mailmaga_flg' => true])
            ->whereIn('email', $emails)
            ->update(['mailmaga_flg' => false]);
    }
}

テスト

<?php

namespace Tests\Unit\Console\Commands;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Oms\Console\Commands\SyncSendgridSuppressionToMailmagazineFlag;
use Oms\ExternalApi\Sendgrid\SuppressionApi;
use Oms\Models\Customer;
use Tests\TestCase;

class SyncSendgridSuppressionToMailmagazineFlagTest extends TestCase
{
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();

        // NOTE: それぞれのテストケースでfactoryで生成するとなぜか
        // updateMailmagazinFlagFromGlobalSuppression のテストに影響が出る。
        // 恐らく根本的な原因はSeederやテストで書かれるtruncate()とRefreshDatabaseの相性
        // https://github.com/laravel/framework/issues/18429
        //
        // ただテストは並列に実行されているわけではないと思うのでこのテストを実行した後、
        // customerが増えたままであることは確認できていないので本当に謎
        factory(Customer::class)->create(["email" => "user1@example.com", 'mailmaga_flg' => true]);
        factory(Customer::class)->create(["email" => "user2@example.com", 'mailmaga_flg' => true]);
        factory(Customer::class)->create(["email" => "user3@example.com", 'mailmaga_flg' => true]);
        factory(Customer::class)->create(["email" => "user4@example.com", 'mailmaga_flg' => true]);
    }

    /**
     * @test
     */
    public function グローバルサプレッションに登録されているメールアドレスを持つcustomerのmailmaga_flgがfalseに更新されること(): void
    {
        $firstResponse = [
            [
                // なんでもいいので適当
                "created" => 1443651141,
                "email" => "user1@example.com"
            ]
        ];

        $secondResponse = [
            [
                "created" => 1443651141,
                "email" => "user2@example.com",
            ],
            [
                "created" => 1443651141,
                "email" => "user3@example.com",
            ],
        ];

        // NOTE: とりあえず2回叩かれることをテストしたいので3回叩く
        // 3回目はループを止める為に空配列を返す
        $suppressionApiMock = Mockery::mock(SuppressionApi::class);
        $suppressionApiMock->shouldReceive('getGlobalSuppressions')
            ->times(3)
            ->andReturn($firstResponse, $secondResponse, []);

        $commands = new SyncSendgridSuppressionToMailmagazineFlag($suppressionApiMock);
        $commands->updateMailmagazineFlagFromGlobalSuppression();

        $customers = Customer::query()
            ->where(['mailmaga_flg' => false])
            ->get();

        $this->assertCount(3, $customers);
        // 関係ない人は更新されてないこと
        $this->assertDatabaseHas('customers', [
            'email' => 'user4@example.com',
            'mailmaga_flg' => true,
        ]);
    }

    /**
     * @test
     */
    public function スパムレポートに登録されているメールアドレスを持つcustomerのmailmaga_flgがfalseに更新されること(): void
    {
        $firstResponse = [
            [
                // なんでもいいので適当
                "created" => 1443651141,
                "email" => "user1@example.com",
            ],
        ];

        $secondResponse = [
            [
                "created" => 1443651141,
                "email" => "user2@example.com",
            ],
            [
                "created" => 1443651141,
                "email" => "user3@example.com",
            ],
        ];

        // NOTE: とりあえず2回叩かれることをテストしたいので3回叩く
        // 3回目はループを止める為に空配列を返す
        $suppressionApiMock = Mockery::mock(SuppressionApi::class);
        $suppressionApiMock->shouldReceive('getSpamReports')
            ->times(3)
            ->andReturn($firstResponse, $secondResponse, []);

        $commands = new SyncSendgridSuppressionToMailmagazineFlag($suppressionApiMock);
        $commands->updateMailmagazineFlagFromSpamReports();

        $customers = Customer::query()
            ->where(['mailmaga_flg' => false])
            ->get();

        $this->assertCount(3, $customers);
        // 関係ない人は更新されてないこと
        $this->assertDatabaseHas('customers', [
            'email' => 'user4@example.com',
            'mailmaga_flg' => true,
        ]);
    }

    /**
     * @test
     */
    public function サプレッショングループに登録されているメールアドレスを持つcustomerのmailmaga_flgがfalseに更新されること(): void
    {
        $response = [
            "user1@example.com",
            "user2@example.com",
            "user3@example.com",
        ];

        // NOTE: とりあえず2回叩かれることをテストしたいので3回叩く
        // 3回目はループを止める為に空配列を返す
        $suppressionApiMock = Mockery::mock(SuppressionApi::class);
        $suppressionApiMock->shouldReceive('getGroupSuppressions')
            ->once()
            ->andReturn($response);

        $commands = new SyncSendgridSuppressionToMailmagazineFlag($suppressionApiMock);
        $commands->updateMailMagazineFlagFromGroupSuppression();

        $customers = Customer::query()
            ->where(['mailmaga_flg' => false])
            ->get();

        $this->assertCount(3, $customers);
        // 関係ない人は更新されてないこと
        $this->assertDatabaseHas('customers', [
            'email' => 'user4@example.com',
            'mailmaga_flg' => true,
        ]);
    }
}

コメント

タイトルとURLをコピーしました