File: /home/xedaptot/be.naniguide.com/tests/Unit/SubscriberServiceTest.php
<?php
use App\Events\MailListSubscription;
use App\Model\Contact;
use App\Model\Customer;
use App\Model\Form;
use App\Model\MailList;
use App\Model\MailListTemplate;
use App\Model\Subscriber;
use App\Model\Template;
use App\Model\Timeline;
use App\Services\SubscriberService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
Config::set('sharding.enabled', false);
});
afterEach(function () {
$formIds = DB::table('forms')
->where('uid', 'like', 'subsvc_%')
->pluck('id');
$mailListIds = DB::table('mail_lists')
->where('uid', 'like', 'subsvc_%')
->pluck('id');
$subscriberIds = DB::table('subscribers')
->whereIn('mail_list_id', $mailListIds)
->pluck('id');
$templateIds = DB::table('templates')
->where('uid', 'like', 'subsvc_%')
->pluck('id')
->merge(DB::table('mail_list_templates')->whereIn('mail_list_id', $mailListIds)->pluck('template_id'))
->merge(DB::table('forms')->whereIn('id', $formIds)->pluck('template_id'))
->filter()
->unique()
->values();
DB::table('timelines')->whereIn('subscriber_id', $subscriberIds)->delete();
DB::table('forms')->whereIn('id', $formIds)->delete();
DB::table('mail_list_templates')->whereIn('mail_list_id', $mailListIds)->delete();
DB::table('subscribers')->whereIn('id', $subscriberIds)->delete();
DB::table('fields')->whereIn('mail_list_id', $mailListIds)->delete();
DB::table('mail_lists')->whereIn('id', $mailListIds)->delete();
DB::table('templates')->whereIn('id', $templateIds)->delete();
$customerIds = DB::table('customers')
->where('uid', 'like', 'subsvc_%')
->pluck('id');
DB::table('customers')->whereIn('id', $customerIds)->delete();
DB::table('contacts')->where('uid', 'like', 'subsvc_%')->delete();
});
function subscriberServiceFixture(array $listOverrides = []): array
{
$contact = Contact::forceCreate([
'uid' => uniqid('subsvc_contact_'),
'first_name' => 'Subscriber',
'last_name' => 'Service',
'email' => uniqid('subsvc_') . '@example.test',
]);
$customer = Customer::forceCreate([
'uid' => uniqid('subsvc_customer_'),
'contact_id' => $contact->id,
'status' => 'active',
'timezone' => 'UTC',
]);
$list = MailList::forceCreate(array_merge([
'uid' => uniqid('subsvc_list_'),
'name' => 'Subscriber Service List',
'customer_id' => $customer->id,
'from_email' => '[email protected]',
'from_name' => 'Sender',
'subscribe_confirmation' => false,
], $listOverrides));
$thankYouTemplate = Template::forceCreate([
'uid' => uniqid('subsvc_tpl_'),
'name' => 'Subscriber Service Thank You',
'content' => '<html><body><p>Thanks for confirming.</p></body></html>',
'json' => \App\Model\Template::DEFAULT_BUILDER_JSON,
]);
MailListTemplate::forceCreate([
'uid' => uniqid('subsvc_mltpl_'),
'mail_list_id' => $list->id,
'customer_id' => $customer->id,
'template_id' => $thankYouTemplate->id,
'alias' => 'sign_up_thankyou_page',
]);
$popupTemplate = Template::forceCreate([
'uid' => uniqid('subsvc_tpl_'),
'name' => 'Subscriber Service Popup',
'content' => '<div builder-wrapper><input type="email" name="EMAIL" /><button type="submit">Join</button></div><message-section style="display:none"><p>Done</p></message-section>',
'json' => \App\Model\Template::DEFAULT_BUILDER_JSON,
]);
$form = Form::forceCreate([
'uid' => uniqid('subsvc_form_'),
'name' => 'Subscriber Service Popup Form',
'status' => Form::STATUS_PUBLISHED,
'customer_id' => $customer->id,
'mail_list_id' => $list->id,
'template_id' => $popupTemplate->id,
'metadata' => json_encode([
'display' => 'immediately',
'overlay_opacity' => 20,
]),
]);
return [
'contact' => $contact,
'customer' => $customer,
'list' => $list->fresh(),
'form' => $form->fresh(),
'emailField' => $list->getFieldByTag('EMAIL'),
'firstNameField' => $list->getFieldByTag('FIRST_NAME'),
];
}
function timelineCount(int $subscriberId, string $type): int
{
return DB::table('timelines')
->where('subscriber_id', $subscriberId)
->where('type', $type)
->count();
}
it('adds a subscriber by customer and records the customer timeline', function () {
$fixture = subscriberServiceFixture();
$request = Request::create('/lists/test/subscribers/store', 'POST', [
'EMAIL' => uniqid('subsvc_added_') . '@example.test',
'FIRST_NAME' => 'Customer Added',
]);
[$validator, $subscriber] = app(SubscriberService::class)->addByCustomer($fixture['list'], $request, $fixture['customer']);
expect($validator)->not->toBeNull();
expect($subscriber)->not->toBeNull();
expect($subscriber->status)->toBe(Subscriber::STATUS_SUBSCRIBED);
expect($subscriber->fresh()->getValueByField($fixture['firstNameField']))->toBe('Customer Added');
expect(timelineCount($subscriber->id, Timeline::TYPE_ADDED_BY_CUSTOMER))->toBe(1);
$timeline = DB::table('timelines')
->where('subscriber_id', $subscriber->id)
->where('type', Timeline::TYPE_ADDED_BY_CUSTOMER)
->first();
expect((int) $timeline->customer_id)->toBe($fixture['customer']->id);
});
it('returns validation errors instead of adding duplicate subscribers by customer', function () {
$fixture = subscriberServiceFixture();
$email = uniqid('subsvc_added_') . '@example.test';
Subscriber::forceCreate([
'uid' => uniqid('subsvc_sub_'),
'email' => $email,
'status' => Subscriber::STATUS_SUBSCRIBED,
'mail_list_id' => $fixture['list']->id,
]);
$request = Request::create('/lists/test/subscribers/store', 'POST', [
'EMAIL' => $email,
]);
[$validator, $subscriber] = app(SubscriberService::class)->addByCustomer($fixture['list'], $request, $fixture['customer']);
expect($validator->fails())->toBeTrue();
expect($subscriber)->toBeNull();
// Scope timeline check to THIS customer + this email's subscriber.
// Global count is polluted by other tests + by Stage22 demo seeder (which creates ADDED_BY_CUSTOMER rows).
$existingSubscriber = Subscriber::where('email', $email)
->where('mail_list_id', $fixture['list']->id)
->first();
expect(DB::table('timelines')
->where('type', Timeline::TYPE_ADDED_BY_CUSTOMER)
->where('customer_id', $fixture['customer']->id)
->where('subscriber_id', $existingSubscriber->id)
->count())->toBe(0);
});
it('records sign up form opt in without wiping omitted subscriber fields', function () {
$fixture = subscriberServiceFixture();
$subscriber = Subscriber::forceCreate([
'uid' => uniqid('subsvc_sub_'),
'email' => uniqid('subsvc_web_') . '@example.test',
'status' => Subscriber::STATUS_UNSUBSCRIBED,
'mail_list_id' => $fixture['list']->id,
]);
$subscriber->setFieldValue($fixture['firstNameField'], 'Existing Name');
$subscriber->save();
$request = Request::create('/frontend/lists/test/sign-up', 'POST', [
'EMAIL' => $subscriber->email,
]);
[$validator, $updatedSubscriber] = app(SubscriberService::class)->optInFromWeb($fixture['list'], $request);
expect($validator->fails())->toBeFalse();
expect($updatedSubscriber->id)->toBe($subscriber->id);
expect($updatedSubscriber->fresh()->status)->toBe(Subscriber::STATUS_SUBSCRIBED);
expect($updatedSubscriber->fresh()->getValueByField($fixture['firstNameField']))->toBe('Existing Name');
expect(timelineCount($updatedSubscriber->id, Timeline::TYPE_SIGN_UP_FORM_OPT_IN))->toBe(1);
});
it('returns validation errors for invalid web opt in requests', function () {
$fixture = subscriberServiceFixture();
$timelineCountBefore = DB::table('timelines')
->where('type', Timeline::TYPE_SIGN_UP_FORM_OPT_IN)
->count();
$request = Request::create('/frontend/lists/test/sign-up', 'POST', []);
[$validator, $subscriber] = app(SubscriberService::class)->optInFromWeb($fixture['list'], $request);
expect($validator->fails())->toBeTrue();
expect($subscriber)->toBeNull();
expect(DB::table('timelines')->where('type', Timeline::TYPE_SIGN_UP_FORM_OPT_IN)->count())->toBe($timelineCountBefore);
});
it('records embedded form opt in timelines', function () {
$fixture = subscriberServiceFixture();
$request = Request::create('/lists/test/embedded-form-subscribe', 'POST', [
'EMAIL' => uniqid('subsvc_embed_') . '@example.test',
]);
[$validator, $subscriber] = app(SubscriberService::class)->optInFromEmbeddedForm($fixture['list'], $request);
expect($validator->fails())->toBeFalse();
expect($subscriber)->not->toBeNull();
expect($subscriber->status)->toBe(Subscriber::STATUS_SUBSCRIBED);
expect(timelineCount($subscriber->id, Timeline::TYPE_EMBEDDED_FORM_OPT_IN))->toBe(1);
});
it('returns validation errors for invalid embedded form requests', function () {
$fixture = subscriberServiceFixture();
$request = Request::create('/lists/test/embedded-form-subscribe', 'POST', []);
[$validator, $subscriber] = app(SubscriberService::class)->optInFromEmbeddedForm($fixture['list'], $request);
expect($validator->fails())->toBeTrue();
expect($subscriber)->toBeNull();
expect(DB::table('timelines')->where('type', Timeline::TYPE_EMBEDDED_FORM_OPT_IN)->count())->toBe(0);
});
it('records popup form opt in timelines with the originating form id', function () {
$fixture = subscriberServiceFixture();
$request = Request::create('/frontend/forms/test/save', 'POST', [
'EMAIL' => uniqid('subsvc_popup_') . '@example.test',
]);
[$validator, $subscriber] = app(SubscriberService::class)->optInFromPopupForm($fixture['form'], $request);
expect($validator->fails())->toBeFalse();
expect($subscriber)->not->toBeNull();
$timeline = DB::table('timelines')
->where('subscriber_id', $subscriber->id)
->where('type', Timeline::TYPE_POPUP_FORM_OPT_IN)
->first();
expect($timeline)->not->toBeNull();
expect((int) $timeline->form_id)->toBe($fixture['form']->id);
});
it('returns validation errors for invalid popup form requests', function () {
$fixture = subscriberServiceFixture();
$timelineCountBefore = DB::table('timelines')
->where('type', Timeline::TYPE_POPUP_FORM_OPT_IN)
->count();
$request = Request::create('/frontend/forms/test/save', 'POST', []);
[$validator, $subscriber] = app(SubscriberService::class)->optInFromPopupForm($fixture['form'], $request);
expect($validator->fails())->toBeTrue();
expect($subscriber)->toBeNull();
expect(DB::table('timelines')->where('type', Timeline::TYPE_POPUP_FORM_OPT_IN)->count())->toBe($timelineCountBefore);
});
it('generates signed confirmation urls in absolute and relative forms', function () {
$fixture = subscriberServiceFixture();
$subscriber = Subscriber::forceCreate([
'uid' => uniqid('subsvc_sub_'),
'email' => uniqid('subsvc_confirm_') . '@example.test',
'status' => Subscriber::STATUS_UNCONFIRMED,
'mail_list_id' => $fixture['list']->id,
]);
$service = app(SubscriberService::class);
$absoluteUrl = $service->generateSignedConfirmationUrl($subscriber);
$relativeUrl = $service->generateSignedConfirmationUrl($subscriber, false);
expect($absoluteUrl)->toContain('signature=');
expect($absoluteUrl)->toContain('/frontend/lists/' . $fixture['list']->uid . '/subscribe-confirm/' . $subscriber->id . '/');
expect($relativeUrl)->toStartWith('/frontend/lists/' . $fixture['list']->uid . '/subscribe-confirm/' . $subscriber->id . '/');
expect($relativeUrl)->toContain('signature=');
});
it('confirms unconfirmed subscribers once and dispatches the subscription event once', function () {
Event::fake([MailListSubscription::class]);
$fixture = subscriberServiceFixture();
$subscriber = Subscriber::forceCreate([
'uid' => uniqid('subsvc_sub_'),
'email' => uniqid('subsvc_confirm_') . '@example.test',
'status' => Subscriber::STATUS_UNCONFIRMED,
'mail_list_id' => $fixture['list']->id,
]);
$service = app(SubscriberService::class);
expect($service->confirmOptIn($subscriber))->toBeTrue();
expect($subscriber->fresh()->status)->toBe(Subscriber::STATUS_SUBSCRIBED);
Event::assertDispatchedTimes(MailListSubscription::class, 1);
expect($service->confirmOptIn($subscriber->fresh()))->toBeFalse();
Event::assertDispatchedTimes(MailListSubscription::class, 1);
});
it('throws when trying to confirm subscribers outside the unconfirmed or subscribed states', function () {
$fixture = subscriberServiceFixture();
$subscriber = Subscriber::forceCreate([
'uid' => uniqid('subsvc_sub_'),
'email' => uniqid('subsvc_invalid_') . '@example.test',
'status' => Subscriber::STATUS_UNSUBSCRIBED,
'mail_list_id' => $fixture['list']->id,
]);
expect(fn () => app(SubscriberService::class)->confirmOptIn($subscriber))
->toThrow(RuntimeException::class, 'Subscriber cannot be confirmed from the current status');
});