HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
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']);
});