File: /home/xedaptot/be.naniguide.com/tests/Unit/SubscriberRowNormalizerTest.php
<?php
/**
* SubscriberRowNormalizerTest — pure-PHP tests for the JSON → canonical row
* translation. Hits a real DB because the normalizer reads the list's fields.
*
* Surface covered:
* - Case-insensitive tag matching (EMAIL / Email / email)
* - Email lowercase + whitespace strip
* - Status validation + fallback
* - Tags accept array OR comma-string
* - Unknown field becomes a soft error (row not rejected outright)
* - Missing EMAIL → row rejected
* - Invalid email format → row rejected
*/
use App\Library\Bulk\SubscriberRowNormalizer;
use App\Model\Customer;
use App\Model\MailList;
use App\Model\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
uses(TestCase::class);
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->customer = Customer::forceCreate([
'uid' => uniqid('norm_c_'), 'timezone' => 'UTC', 'status' => 'active',
]);
$this->user = User::forceCreate([
'customer_id' => $this->customer->id, 'uid' => uniqid('norm_u_'),
'email' => uniqid('u').'@norm.local', 'password' => bcrypt('x'),
]);
$this->list = MailList::forceCreate([
'uid' => uniqid('norm_l_'), 'customer_id' => $this->customer->id,
'name' => 'NormTest', 'from_email' => '[email protected]', 'from_name' => 'N',
'send_welcome_email' => false, 'subscribe_confirmation' => false,
'unsubscribe_notification' => false,
]);
$this->emailCol = $this->list->getEmailField()->custom_field_name;
$this->firstNameCol = $this->list->fields()->where('tag', 'FIRST_NAME')->first()->custom_field_name;
});
test('case-insensitive tag matching: EMAIL / Email / email all map to email column', function () {
foreach (['EMAIL', 'Email', 'email'] as $key) {
[$row, $errors] = SubscriberRowNormalizer::normalize([$key => '[email protected]'], $this->list);
expect($errors)->toBe([]);
expect($row[$this->emailCol])->toBe('[email protected]');
}
});
test('email lowercased + whitespace + BOM stripped', function () {
[$row, $errors] = SubscriberRowNormalizer::normalize([
'EMAIL' => " [email protected] ",
'FIRST_NAME' => 'Alice',
], $this->list);
expect($errors)->toBe([]);
expect($row[$this->emailCol])->toBe('[email protected]');
expect($row[$this->firstNameCol])->toBe('Alice');
});
test('missing EMAIL → row rejected with errors', function () {
[$row, $errors] = SubscriberRowNormalizer::normalize(['FIRST_NAME' => 'X'], $this->list);
expect($row)->toBeNull();
expect($errors)->toHaveKey('EMAIL');
});
test('invalid email format → row rejected', function () {
[$row, $errors] = SubscriberRowNormalizer::normalize(['EMAIL' => 'not-an-email'], $this->list);
expect($row)->toBeNull();
expect($errors['EMAIL'])->toContain('invalid email format');
});
test('unknown JSON field → soft error, row still kept', function () {
[$row, $errors] = SubscriberRowNormalizer::normalize([
'EMAIL' => '[email protected]',
'BIRTHDAY' => '1990-01-01', // no BIRTHDAY field on this list
], $this->list);
expect($row)->not->toBeNull();
expect($row[$this->emailCol])->toBe('[email protected]');
expect($errors)->toHaveKey('BIRTHDAY');
expect($errors['BIRTHDAY'])->toContain('unknown field');
});
test('status passes through allow-list, invalid falls back to subscribed', function () {
[$row1, ] = SubscriberRowNormalizer::normalize(['EMAIL' => '[email protected]', 'status' => 'unsubscribed'], $this->list);
expect($row1['status'])->toBe('unsubscribed');
[$row2, ] = SubscriberRowNormalizer::normalize(['EMAIL' => '[email protected]', 'status' => 'bogus'], $this->list);
expect($row2['status'])->toBe('subscribed');
// Missing status → default subscribed (for INSERT path)
[$row3, ] = SubscriberRowNormalizer::normalize(['EMAIL' => '[email protected]'], $this->list);
expect($row3['status'])->toBe('subscribed');
});
test('tags accept array → JSON-encoded', function () {
[$row, ] = SubscriberRowNormalizer::normalize([
'EMAIL' => '[email protected]',
'tags' => ['vip', 'newsletter'],
], $this->list);
expect(json_decode($row['tags'], true))->toBe(['vip', 'newsletter']);
});
test('tags accept comma-string → JSON-encoded array', function () {
[$row, ] = SubscriberRowNormalizer::normalize([
'EMAIL' => '[email protected]',
'tags' => 'vip, newsletter, beta',
], $this->list);
expect(json_decode($row['tags'], true))->toBe(['vip', 'newsletter', 'beta']);
});
test('empty tags array or null → tags stored as null', function () {
[$row1, ] = SubscriberRowNormalizer::normalize(['EMAIL' => '[email protected]', 'tags' => []], $this->list);
expect($row1['tags'])->toBeNull();
[$row2, ] = SubscriberRowNormalizer::normalize(['EMAIL' => '[email protected]', 'tags' => null], $this->list);
expect($row2['tags'])->toBeNull();
});
test('tag matching uses TAG not LABEL — "First Name" label ≠ tag', function () {
// The list's FIRST_NAME field has label "First name" (or similar). The
// contract says only TAG matches — sending label should be unknown_field.
[$row, $errors] = SubscriberRowNormalizer::normalize([
'EMAIL' => '[email protected]',
'First name' => 'Alice', // label, not tag
], $this->list);
expect($errors)->toHaveKey('First name');
expect($row[$this->firstNameCol] ?? null)->toBeNull();
});