File: /home/xedaptot/ai.naniguide.com/tests/Feature/MailListImportSettingsTest.php
<?php
/**
* MailListImportSettingsTest — covers the new wizard "Settings" step and the
* accompanying backend changes to MailList::import().
*
* Behavior matrix (14 cases) — for each (mode, overwriteStatus, overwriteTags)
* combination, assert:
* - status (per-row preserved or overwritten as expected)
* - tags (per-row preserved or overwritten as expected)
* - mapped custom fields (always refreshed in mode=overwrite)
* - verification_status (always preserved across every mode)
* - subscription_type, uid, created_at (always preserved on UPDATE)
* - new email (always inserted regardless of mode)
*
* Plus the regression test that pins the original bug fix: a CSV without a
* status column must NOT silently reset existing `unsubscribed` rows back to
* `subscribed`.
*
* Test infrastructure:
* - DatabaseTransactions to roll back after each test (developer's local DB
* is preserved — see AdsR3ServiceRefactorTest for the pattern).
* - Gate::before bypasses MailListPolicy::addMoreSubscribers so tests don't
* need a fully wired RBAC role + permission. The policy isn't what we're
* exercising here.
* - QuotasService is bound to a fake that always allows — same rationale.
*/
use App\Library\MailListFieldMapping;
use App\Model\Customer;
use App\Model\Field;
use App\Model\MailList;
use App\Model\Subscriber;
use App\Model\User;
use App\Services\Plans\Quotas\QuotasService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Gate;
use Tests\TestCase;
uses(TestCase::class);
uses(DatabaseTransactions::class);
beforeEach(function () {
// Bypass MailListPolicy::addMoreSubscribers — RBAC isn't under test here.
Gate::before(function ($user, $ability) {
if (in_array($ability, ['addMoreSubscribers', 'import'], true)) {
return true;
}
return null;
});
// Bind a fake QuotasService so plan-quota checks always allow.
// (Real QuotasService has 13 constructor deps; Mockery sidesteps them.)
// shouldIgnoreMissing() short-circuits any other method that the surrounding
// updateCachedInfo() / quota-display code paths may reach.
$fakeQuotas = Mockery::mock(QuotasService::class)->shouldIgnoreMissing(true);
$fakeQuotas->shouldReceive('canAccept')->andReturn(true);
$fakeQuotas->shouldReceive('canAcceptOnList')->andReturn(true);
app()->instance(QuotasService::class, $fakeQuotas);
// Customer + first user (User factory not wired — go raw).
$this->customer = Customer::forceCreate([
'uid' => uniqid('importtest_c_'),
'timezone' => 'UTC',
'status' => 'active',
]);
$this->user = User::forceCreate([
'customer_id' => $this->customer->id,
'uid' => uniqid('importtest_u_'),
'email' => uniqid('u') . '@import-test.local',
'password' => bcrypt('x'),
]);
// MailList — forceCreate avoids the MailListFactory → Contact::factory() chain.
// Only columns that exist on the legacy mailixa_test schema (no `status`,
// no `contact_id` — schema drift, see memory project_migration_consolidation_drift.md).
$this->list = MailList::forceCreate([
'uid' => uniqid('importtest_l_'),
'customer_id' => $this->customer->id,
'name' => 'Import Test List',
'from_email' => '[email protected]',
'from_name' => 'Import Test',
'send_welcome_email' => false,
'subscribe_confirmation' => false,
'unsubscribe_notification' => false,
]);
// EMAIL + FIRST_NAME + LAST_NAME are auto-created by the MailList::created
// boot event (see MailList::createDefaultFields). Reuse them; add CITY for
// the unmapped-field test case.
$this->emailField = $this->list->getEmailField();
$this->firstNameField = $this->list->fields()->where('tag', 'FIRST_NAME')->first();
$this->cityField = $this->list->createField('text', 'CITY', 'City', null, false, true, false);
// Convenience: shortcuts to column names on the subscribers table.
$this->firstNameCol = $this->firstNameField->custom_field_name;
$this->cityCol = $this->cityField->custom_field_name;
$this->emailCol = $this->emailField->custom_field_name;
// Seed 3 existing subscribers with distinct starting state so we can detect
// any unintended bleed-over between rows.
$this->aliceUid = uniqid('s_a_');
$this->bobUid = uniqid('s_b_');
$this->carolUid = uniqid('s_c_');
DB::table('subscribers')->insert([
[
'uid' => $this->aliceUid,
'mail_list_id' => $this->list->id,
'email' => '[email protected]',
'status' => Subscriber::STATUS_UNSUBSCRIBED,
'subscription_type' => 'manual',
'tags' => json_encode(['VIP', 'old']),
'verification_status' => Subscriber::VERIFICATION_STATUS_DELIVERABLE,
$this->firstNameCol => 'AliceOld',
$this->cityCol => 'OldCity',
'created_at' => now()->subDay(),
'updated_at' => now()->subDay(),
],
[
'uid' => $this->bobUid,
'mail_list_id' => $this->list->id,
'email' => '[email protected]',
'status' => Subscriber::STATUS_SUBSCRIBED,
'subscription_type' => 'manual',
'tags' => json_encode([]),
'verification_status' => Subscriber::VERIFICATION_STATUS_UNDELIVERABLE,
$this->firstNameCol => 'BobOld',
$this->cityCol => 'OldCity',
'created_at' => now()->subDay(),
'updated_at' => now()->subDay(),
],
[
'uid' => $this->carolUid,
'mail_list_id' => $this->list->id,
'email' => '[email protected]',
'status' => Subscriber::STATUS_UNCONFIRMED,
'subscription_type' => 'manual',
'tags' => json_encode(['legacy']),
'verification_status' => null,
$this->firstNameCol => 'CarolOld',
$this->cityCol => null,
'created_at' => now()->subDay(),
'updated_at' => now()->subDay(),
],
]);
// Mapping: CSV header => Field id. (The 'tags'/'status' preserved fields
// don't need an entry here — they're matched by column name only.)
$this->mapping = [
'EMAIL' => $this->emailField->id,
'FIRST_NAME' => $this->firstNameField->id,
'CITY' => $this->cityField->id,
];
});
afterEach(function () {
// Hard cleanup — DatabaseTransactions rolls back rows, but doesn't drop
// the temp tables created by createTmpTableFromMapping. The import code
// already drops them on success; this is belt-and-suspenders.
$tmp = DB::getTablePrefix() . '__tmp_subscribers_' . ($this->list->uid ?? '');
if ($tmp !== DB::getTablePrefix() . '__tmp_subscribers_') {
DB::statement("DROP TABLE IF EXISTS {$tmp}");
}
});
/**
* Build a CSV file for import. $rows is a list of associative arrays; $columns
* is the ordered list of column headers (lets us control casing).
*/
function makeCsv(array $columns, array $rows): string
{
$dir = storage_path('app/tmp/import-test');
File::ensureDirectoryExists($dir);
$path = $dir . '/csv-' . uniqid() . '.csv';
$fh = fopen($path, 'w');
fputcsv($fh, $columns);
foreach ($rows as $row) {
$line = [];
foreach ($columns as $col) {
$line[] = $row[$col] ?? '';
}
fputcsv($fh, $line);
}
fclose($fh);
return $path;
}
/**
* Reload a subscriber row from DB by email. Returns a stdClass with all columns.
*/
function reloadSub(MailList $list, string $email): object
{
return DB::table('subscribers')
->where('mail_list_id', $list->id)
->where('email', $email)
->first();
}
// ════════════════════════════════════════════════════════════════════════════
// Behavior matrix — UPDATE path
// ════════════════════════════════════════════════════════════════════════════
test('case 1 — mode=overwrite, both flags off: status + tags preserved, custom fields refreshed', function () {
$csv = makeCsv(
['EMAIL', 'FIRST_NAME', 'CITY', 'status', 'tags'],
[
['EMAIL' => '[email protected]', 'FIRST_NAME' => 'AliceNew', 'CITY' => 'NewCity', 'status' => 'subscribed', 'tags' => 'new1,new2'],
['EMAIL' => '[email protected]', 'FIRST_NAME' => 'BobNew', 'CITY' => 'NewCity', 'status' => 'unsubscribed', 'tags' => 'newB'],
['EMAIL' => '[email protected]', 'FIRST_NAME' => 'DaveNew', 'CITY' => 'NewCity', 'status' => 'subscribed', 'tags' => 'newD'],
]
);
$this->list->import($csv, $this->mapping, []);
$alice = reloadSub($this->list, '[email protected]');
expect($alice->status)->toBe(Subscriber::STATUS_UNSUBSCRIBED); // preserved
expect(json_decode($alice->tags, true))->toBe(['VIP', 'old']); // preserved
expect($alice->{$this->firstNameCol})->toBe('AliceNew'); // refreshed
expect($alice->{$this->cityCol})->toBe('NewCity'); // refreshed
expect($alice->verification_status)->toBe(Subscriber::VERIFICATION_STATUS_DELIVERABLE); // preserved
expect($alice->uid)->toBe($this->aliceUid); // preserved
$bob = reloadSub($this->list, '[email protected]');
expect($bob->status)->toBe(Subscriber::STATUS_SUBSCRIBED); // preserved
expect(json_decode($bob->tags, true))->toBe([]); // preserved
expect($bob->{$this->firstNameCol})->toBe('BobNew'); // refreshed
$dave = reloadSub($this->list, '[email protected]');
expect($dave)->not->toBeNull(); // inserted
expect($dave->status)->toBe(Subscriber::STATUS_SUBSCRIBED);
expect(DB::table('subscribers')->where('mail_list_id', $this->list->id)->count())->toBe(4);
});
test('case 2 — mode=overwrite, overwriteStatus=true: status overwritten, tags preserved', function () {
$csv = makeCsv(
['EMAIL', 'FIRST_NAME', 'CITY', 'status', 'tags'],
[['EMAIL' => '[email protected]', 'FIRST_NAME' => 'AliceNew', 'CITY' => 'NewCity', 'status' => 'subscribed', 'tags' => 'new1,new2']]
);
$this->list->import($csv, $this->mapping, ['mode' => 'overwrite', 'overwriteStatus' => true]);
$alice = reloadSub($this->list, '[email protected]');
expect($alice->status)->toBe(Subscriber::STATUS_SUBSCRIBED); // overwritten
expect(json_decode($alice->tags, true))->toBe(['VIP', 'old']); // preserved
expect($alice->{$this->firstNameCol})->toBe('AliceNew'); // refreshed
expect($alice->verification_status)->toBe(Subscriber::VERIFICATION_STATUS_DELIVERABLE);
});
test('case 3 — mode=overwrite, overwriteTags=true: tags overwritten, status preserved', function () {
$csv = makeCsv(
['EMAIL', 'FIRST_NAME', 'CITY', 'status', 'tags'],
[['EMAIL' => '[email protected]', 'FIRST_NAME' => 'AliceNew', 'CITY' => 'NewCity', 'status' => 'subscribed', 'tags' => 'new1,new2']]
);
$this->list->import($csv, $this->mapping, ['mode' => 'overwrite', 'overwriteTags' => true]);
$alice = reloadSub($this->list, '[email protected]');
expect($alice->status)->toBe(Subscriber::STATUS_UNSUBSCRIBED); // preserved
expect(json_decode($alice->tags, true))->toBe(['new1', 'new2']); // overwritten
expect($alice->{$this->firstNameCol})->toBe('AliceNew'); // refreshed
});
test('case 4 — mode=overwrite, both flags on: everything overwritten, verification preserved', function () {
$csv = makeCsv(
['EMAIL', 'FIRST_NAME', 'CITY', 'status', 'tags'],
[['EMAIL' => '[email protected]', 'FIRST_NAME' => 'AliceNew', 'CITY' => 'NewCity', 'status' => 'subscribed', 'tags' => 'new1,new2']]
);
$this->list->import($csv, $this->mapping, [
'mode' => 'overwrite',
'overwriteStatus' => true,
'overwriteTags' => true,
]);
$alice = reloadSub($this->list, '[email protected]');
expect($alice->status)->toBe(Subscriber::STATUS_SUBSCRIBED);
expect(json_decode($alice->tags, true))->toBe(['new1', 'new2']);
expect($alice->{$this->firstNameCol})->toBe('AliceNew');
expect($alice->{$this->cityCol})->toBe('NewCity');
expect($alice->verification_status)->toBe(Subscriber::VERIFICATION_STATUS_DELIVERABLE); // ALWAYS preserved
expect($alice->uid)->toBe($this->aliceUid);
});
// ════════════════════════════════════════════════════════════════════════════
// Behavior matrix — SKIP path
// ════════════════════════════════════════════════════════════════════════════
test('case 5 — mode=skip: existing rows fully untouched, new emails still inserted', function () {
$csv = makeCsv(
['EMAIL', 'FIRST_NAME', 'CITY'],
[
['EMAIL' => '[email protected]', 'FIRST_NAME' => 'WouldBeChanged', 'CITY' => 'WouldBeChanged'],
['EMAIL' => '[email protected]', 'FIRST_NAME' => 'DaveNew', 'CITY' => 'NewCity'],
]
);
$this->list->import($csv, $this->mapping, ['mode' => 'skip']);
$alice = reloadSub($this->list, '[email protected]');
expect($alice->status)->toBe(Subscriber::STATUS_UNSUBSCRIBED);
expect(json_decode($alice->tags, true))->toBe(['VIP', 'old']);
expect($alice->{$this->firstNameCol})->toBe('AliceOld'); // NOT refreshed
expect($alice->{$this->cityCol})->toBe('OldCity'); // NOT refreshed
expect($alice->verification_status)->toBe(Subscriber::VERIFICATION_STATUS_DELIVERABLE);
$dave = reloadSub($this->list, '[email protected]');
expect($dave)->not->toBeNull();
expect($dave->status)->toBe(Subscriber::STATUS_SUBSCRIBED);
expect(DB::table('subscribers')->where('mail_list_id', $this->list->id)->count())->toBe(4);
});
test('case 6 — mode=skip with overwriteStatus=true: overwrite flag is ignored when skipping', function () {
$csv = makeCsv(
['EMAIL', 'FIRST_NAME', 'CITY', 'status'],
[['EMAIL' => '[email protected]', 'FIRST_NAME' => 'WouldBeChanged', 'CITY' => 'X', 'status' => 'subscribed']]
);
$this->list->import($csv, $this->mapping, [
'mode' => 'skip',
'overwriteStatus' => true, // intentionally set — should be ignored
]);
$alice = reloadSub($this->list, '[email protected]');
expect($alice->status)->toBe(Subscriber::STATUS_UNSUBSCRIBED); // still untouched
expect($alice->{$this->firstNameCol})->toBe('AliceOld');
});
// ════════════════════════════════════════════════════════════════════════════
// INSERT path — status defaulting for brand-new emails
// ════════════════════════════════════════════════════════════════════════════
test('case 7 — INSERT: new email with no status column gets default subscribed', function () {
$csv = makeCsv(
['EMAIL', 'FIRST_NAME', 'CITY'],
[['EMAIL' => '[email protected]', 'FIRST_NAME' => 'Dave', 'CITY' => 'City']]
);
$this->list->import($csv, $this->mapping, ['mode' => 'skip']);
$dave = reloadSub($this->list, '[email protected]');
expect($dave->status)->toBe(Subscriber::STATUS_SUBSCRIBED);
});
test('case 8 — INSERT: new email with invalid status value falls back to subscribed', function () {
$csv = makeCsv(
['EMAIL', 'FIRST_NAME', 'CITY', 'status'],
[['EMAIL' => '[email protected]', 'FIRST_NAME' => 'Dave', 'CITY' => 'City', 'status' => 'subscribed_typo']]
);
$this->list->import($csv, $this->mapping, ['mode' => 'skip']);
$dave = reloadSub($this->list, '[email protected]');
expect($dave->status)->toBe(Subscriber::STATUS_SUBSCRIBED);
});
test('case 9 — INSERT: new email with valid unsubscribed status is respected', function () {
$csv = makeCsv(
['EMAIL', 'FIRST_NAME', 'CITY', 'status'],
[['EMAIL' => '[email protected]', 'FIRST_NAME' => 'Dave', 'CITY' => 'City', 'status' => 'unsubscribed']]
);
$this->list->import($csv, $this->mapping, ['mode' => 'skip']);
$dave = reloadSub($this->list, '[email protected]');
expect($dave->status)->toBe(Subscriber::STATUS_UNSUBSCRIBED);
});
// ════════════════════════════════════════════════════════════════════════════
// Cross-cutting invariants
// ════════════════════════════════════════════════════════════════════════════
test('case 10 — verification_status is preserved across every mode/flag combo', function () {
$csv = makeCsv(
['EMAIL', 'FIRST_NAME', 'CITY', 'status', 'tags'],
[['EMAIL' => '[email protected]', 'FIRST_NAME' => 'A', 'CITY' => 'C', 'status' => 'subscribed', 'tags' => 't']]
);
$combos = [
['mode' => 'overwrite', 'overwriteStatus' => false, 'overwriteTags' => false],
['mode' => 'overwrite', 'overwriteStatus' => true, 'overwriteTags' => false],
['mode' => 'overwrite', 'overwriteStatus' => false, 'overwriteTags' => true],
['mode' => 'overwrite', 'overwriteStatus' => true, 'overwriteTags' => true],
['mode' => 'skip'],
];
foreach ($combos as $opts) {
// Need a fresh CSV per iteration since import() deletes the file on success.
$iterCsv = makeCsv(
['EMAIL', 'FIRST_NAME', 'CITY', 'status', 'tags'],
[['EMAIL' => '[email protected]', 'FIRST_NAME' => 'A', 'CITY' => 'C', 'status' => 'subscribed', 'tags' => 't']]
);
$this->list->import($iterCsv, $this->mapping, $opts);
$alice = reloadSub($this->list, '[email protected]');
expect($alice->verification_status)
->toBe(Subscriber::VERIFICATION_STATUS_DELIVERABLE)
->and(true)->toBeTrue(); // satisfy single-assertion-per-iter sanity
}
// Bonus: original CSV was created but unused — clean up.
@unlink($csv);
});
test('case 11 — UPDATE preserves subscription_type, uid, created_at, import_batch_id', function () {
$csv = makeCsv(
['EMAIL', 'FIRST_NAME', 'CITY', 'status', 'tags'],
[['EMAIL' => '[email protected]', 'FIRST_NAME' => 'A', 'CITY' => 'C', 'status' => 'subscribed', 'tags' => 'new']]
);
$before = reloadSub($this->list, '[email protected]');
$this->list->import($csv, $this->mapping, [
'mode' => 'overwrite', 'overwriteStatus' => true, 'overwriteTags' => true,
]);
$after = reloadSub($this->list, '[email protected]');
expect($after->uid)->toBe($before->uid);
expect($after->subscription_type)->toBe($before->subscription_type);
expect($after->created_at)->toBe($before->created_at);
expect($after->import_batch_id)->toBe($before->import_batch_id); // null stays null
});
test('case 12 — unmapped custom field is preserved (CITY not in mapping)', function () {
$mappingMinusCity = [
'EMAIL' => $this->emailField->id,
'FIRST_NAME' => $this->firstNameField->id,
];
$csv = makeCsv(
['EMAIL', 'FIRST_NAME', 'CITY'],
[['EMAIL' => '[email protected]', 'FIRST_NAME' => 'AliceNew', 'CITY' => 'WouldBeIgnored']]
);
$this->list->import($csv, $mappingMinusCity, ['mode' => 'overwrite']);
$alice = reloadSub($this->list, '[email protected]');
expect($alice->{$this->firstNameCol})->toBe('AliceNew'); // mapped → refreshed
expect($alice->{$this->cityCol})->toBe('OldCity'); // unmapped → preserved
});
test('case 13 — overwriteTags=true with empty CSV cell wipes existing tags (user opted in)', function () {
$csv = makeCsv(
['EMAIL', 'FIRST_NAME', 'CITY', 'tags'],
[['EMAIL' => '[email protected]', 'FIRST_NAME' => 'A', 'CITY' => 'C', 'tags' => '']]
);
$this->list->import($csv, $this->mapping, ['mode' => 'overwrite', 'overwriteTags' => true]);
$alice = reloadSub($this->list, '[email protected]');
// Empty CSV tags cell → import preserves the empty value through to UPDATE.
// The bottom line for the user: opted-in overwrite + empty cell = old tags
// are no longer authoritative (decoded JSON is empty/null/[]).
$decoded = $alice->tags === null ? null : json_decode($alice->tags, true);
expect($decoded)->toBeIn([null, [], false]);
});
// ════════════════════════════════════════════════════════════════════════════
// Regression — the original bug the wizard fixes
// ════════════════════════════════════════════════════════════════════════════
test('case 14 — REGRESSION: CSV without status column does NOT reset unsubscribed rows', function () {
// The original bug: importing a CSV with no `status` column silently set
// every matching subscriber to `subscribed`, blowing away opt-outs.
$csv = makeCsv(
['EMAIL', 'FIRST_NAME', 'CITY'], // <- no status column
[['EMAIL' => '[email protected]', 'FIRST_NAME' => 'AliceNew', 'CITY' => 'NewCity']]
);
// Default options — mimics what the wizard sends when user accepts defaults.
$this->list->import($csv, $this->mapping, []);
$alice = reloadSub($this->list, '[email protected]');
expect($alice->status)->toBe(Subscriber::STATUS_UNSUBSCRIBED); // opt-out preserved
});
// ════════════════════════════════════════════════════════════════════════════
// Case-insensitive column detection (uses processRecord normalization)
// ════════════════════════════════════════════════════════════════════════════
test('case 15 — processRecord normalizes capitalized Status header to lowercase status key', function () {
$map = MailListFieldMapping::parse($this->mapping, $this->list);
$rec = $map->processRecord([
'EMAIL' => '[email protected]',
'FIRST_NAME' => 'A',
'CITY' => 'C',
'Status' => 'unsubscribed', // capitalized — must still be picked up
]);
expect($rec)->toHaveKey('status');
expect($rec['status'])->toBe('unsubscribed');
});
test('case 16 — processRecord normalizes uppercase TAGS header to lowercase tags key', function () {
$map = MailListFieldMapping::parse($this->mapping, $this->list);
$rec = $map->processRecord([
'EMAIL' => '[email protected]',
'FIRST_NAME' => 'A',
'CITY' => 'C',
'TAGS' => 'a,b,c', // uppercase — must still be picked up
]);
expect($rec)->toHaveKey('tags');
expect($rec['tags'])->toBe('a,b,c');
});
test('case 17 — INVALID mode throws LogicException (fall-through guard)', function () {
$csv = makeCsv(['EMAIL'], [['EMAIL' => '[email protected]']]);
expect(fn () => $this->list->import($csv, $this->mapping, ['mode' => 'bogus']))
->toThrow(\Exception::class); // wrapped by the outer try/catch
});
// ════════════════════════════════════════════════════════════════════════════
// Default option merge — what callers get when they pass partial options
// ════════════════════════════════════════════════════════════════════════════
test('case 18 — empty options array uses safe defaults (mode=overwrite, both flags off)', function () {
// This is the contract: default behavior never touches status or tags.
// Same assertion as case 1 but isolated to the contract aspect.
$csv = makeCsv(
['EMAIL', 'FIRST_NAME', 'CITY', 'status', 'tags'],
[['EMAIL' => '[email protected]', 'FIRST_NAME' => 'X', 'CITY' => 'X', 'status' => 'subscribed', 'tags' => 'x']]
);
$this->list->import($csv, $this->mapping); // no options arg at all
$alice = reloadSub($this->list, '[email protected]');
expect($alice->status)->toBe(Subscriber::STATUS_UNSUBSCRIBED);
expect(json_decode($alice->tags, true))->toBe(['VIP', 'old']);
});