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/be.naniguide.com/tests/Feature/MailListBulkUpsertTest.php
<?php

/**
 * MailListBulkUpsertTest — direct tests for the new core method.
 *
 * Unlike the CSV-driven tests, these tests feed canonical row arrays
 * (column-keyed, status validated, tags JSON-encoded) straight into
 * bulkUpsert() — no file I/O, no CSV parsing. This is the surface the
 * upcoming JSON API will exercise.
 *
 * Rationale: keeping a dedicated test layer for bulkUpsert lets us catch
 * regressions in the SQL pipeline independently of the CSV adapter. If a
 * CSV-shaped test fails, we now know whether the bug is in the adapter
 * (CSV-specific) or in the engine (SQL-level).
 */

use App\Model\Customer;
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\Gate;
use Tests\TestCase;

uses(TestCase::class);
uses(DatabaseTransactions::class);

beforeEach(function () {
    Gate::before(fn ($u, $a) => in_array($a, ['addMoreSubscribers', 'import'], true) ? true : null);

    $fakeQuotas = Mockery::mock(QuotasService::class)->shouldIgnoreMissing(true);
    $fakeQuotas->shouldReceive('canAccept')->andReturn(true);
    $fakeQuotas->shouldReceive('canAcceptOnList')->andReturn(true);
    app()->instance(QuotasService::class, $fakeQuotas);

    $this->customer = Customer::forceCreate([
        'uid'      => uniqid('bu_c_'),
        'timezone' => 'UTC',
        'status'   => 'active',
    ]);
    $this->user = User::forceCreate([
        'customer_id' => $this->customer->id,
        'uid'         => uniqid('bu_u_'),
        'email'       => uniqid('u') . '@bulk.local',
        'password'    => bcrypt('x'),
    ]);
    $this->list = MailList::forceCreate([
        'uid'                      => uniqid('bu_l_'),
        'customer_id'              => $this->customer->id,
        'name'                     => 'Bulk Upsert Test List',
        'from_email'               => '[email protected]',
        'from_name'                => 'B',
        'send_welcome_email'       => false,
        'subscribe_confirmation'   => false,
        'unsubscribe_notification' => false,
    ]);

    $this->emailField     = $this->list->getEmailField();
    $this->firstNameField = $this->list->fields()->where('tag', 'FIRST_NAME')->first();
    $this->emailCol     = $this->emailField->custom_field_name;
    $this->firstNameCol = $this->firstNameField->custom_field_name;
});

afterEach(function () {
    // The tmp table inside bulkUpsert() is always DROPped in a finally{} block,
    // so this is defensive — only kicks in if a test bombs mid-flight.
    if (isset($this->list)) {
        DB::statement("DROP TABLE IF EXISTS " . DB::getTablePrefix() . "__tmp_subscribers_{$this->list->uid}");
    }
});

/**
 * Build a canonical row keyed by subscribers table columns.
 * Caller passes field-tag friendly inputs; helper translates to column names.
 */
function canon(array $tagged, string $emailCol, string $firstNameCol): array
{
    $out = [];
    foreach ($tagged as $k => $v) {
        $out[match (strtoupper($k)) {
            'EMAIL'      => $emailCol,
            'FIRST_NAME' => $firstNameCol,
            default      => $k,
        }] = $v;
    }
    return $out;
}

// ════════════════════════════════════════════════════════════════════════════
//  Happy path — insert into empty list
// ════════════════════════════════════════════════════════════════════════════

test('bulkUpsert into empty list inserts every row with correct counts', function () {
    $rows = [
        canon(['EMAIL' => '[email protected]', 'FIRST_NAME' => 'A', 'status' => 'subscribed',   'tags' => json_encode(['vip'])], $this->emailCol, $this->firstNameCol),
        canon(['EMAIL' => '[email protected]', 'FIRST_NAME' => 'B', 'status' => 'unsubscribed', 'tags' => json_encode([])     ], $this->emailCol, $this->firstNameCol),
        canon(['EMAIL' => '[email protected]', 'FIRST_NAME' => 'C', 'status' => 'unconfirmed',  'tags' => json_encode(['x'])  ], $this->emailCol, $this->firstNameCol),
    ];

    $counts = $this->list->bulkUpsert($rows);

    expect($counts['inserted'])->toBe(3);
    expect($counts['updated'])->toBe(0);
    expect($counts['failed'])->toBe(0);
    expect($counts['batch_id'])->not->toBeEmpty();

    $sub = DB::table('subscribers')
        ->where('mail_list_id', $this->list->id)
        ->where('email', '[email protected]')
        ->first();
    expect($sub->status)->toBe('subscribed');
    expect(json_decode($sub->tags, true))->toBe(['vip']);
    expect($sub->{$this->firstNameCol})->toBe('A');
    expect($sub->subscription_type)->toBe(Subscriber::SUBSCRIPTION_TYPE_IMPORTED);
});

// ════════════════════════════════════════════════════════════════════════════
//  Empty input — no-op
// ════════════════════════════════════════════════════════════════════════════

test('bulkUpsert with empty rows returns zero counts without touching DB', function () {
    DB::table('subscribers')->insert([
        'uid'             => uniqid(),
        'mail_list_id'    => $this->list->id,
        'email'           => '[email protected]',
        'status'          => 'subscribed',
        $this->emailCol   => '[email protected]',
        'created_at'      => now(),
        'updated_at'      => now(),
    ]);

    $counts = $this->list->bulkUpsert([]);

    expect($counts)->toMatchArray(['inserted' => 0, 'updated' => 0, 'failed' => 0]);
    expect(DB::table('subscribers')->where('mail_list_id', $this->list->id)->count())->toBe(1);
});

// ════════════════════════════════════════════════════════════════════════════
//  Invalid mode — explicit guard
// ════════════════════════════════════════════════════════════════════════════

test('bulkUpsert with invalid mode throws LogicException (no fall-through)', function () {
    $rows = [canon(['EMAIL' => '[email protected]', 'status' => 'subscribed'], $this->emailCol, $this->firstNameCol)];

    expect(fn () => $this->list->bulkUpsert($rows, ['mode' => 'wipe-everything']))
        ->toThrow(\LogicException::class);
});

// ════════════════════════════════════════════════════════════════════════════
//  Email column missing — refuse the call
// ════════════════════════════════════════════════════════════════════════════

test('bulkUpsert without email column in rows throws', function () {
    $rows = [[ $this->firstNameCol => 'Joe', 'status' => 'subscribed' ]]; // no email

    expect(fn () => $this->list->bulkUpsert($rows))
        ->toThrow(\Exception::class, "email column");
});

// ════════════════════════════════════════════════════════════════════════════
//  Upsert behavior matrix on canonical rows
// ════════════════════════════════════════════════════════════════════════════

test('bulkUpsert mode=overwrite default: custom fields refreshed, status + tags preserved', function () {
    // Seed an existing subscriber.
    DB::table('subscribers')->insert([
        'uid'             => uniqid(),
        'mail_list_id'    => $this->list->id,
        'email'           => '[email protected]',
        'status'          => 'unsubscribed',
        'tags'            => json_encode(['oldtag']),
        $this->emailCol   => '[email protected]',
        $this->firstNameCol => 'OldAlice',
        'created_at'      => now(),
        'updated_at'      => now(),
    ]);

    $rows = [
        canon(['EMAIL' => '[email protected]', 'FIRST_NAME' => 'NewAlice', 'status' => 'subscribed', 'tags' => json_encode(['newtag'])], $this->emailCol, $this->firstNameCol),
        canon(['EMAIL' => '[email protected]', 'FIRST_NAME' => 'NewBee', 'status' => 'subscribed', 'tags' => json_encode([])      ], $this->emailCol, $this->firstNameCol),
    ];
    $counts = $this->list->bulkUpsert($rows);

    expect($counts)->toMatchArray(['inserted' => 1, 'updated' => 1]);

    $alice = DB::table('subscribers')->where('mail_list_id', $this->list->id)->where('email', '[email protected]')->first();
    expect($alice->status)->toBe('unsubscribed');                          // preserved (default flags off)
    expect(json_decode($alice->tags, true))->toBe(['oldtag']);             // preserved
    expect($alice->{$this->firstNameCol})->toBe('NewAlice');               // refreshed

    $new = DB::table('subscribers')->where('mail_list_id', $this->list->id)->where('email', '[email protected]')->first();
    expect($new)->not->toBeNull();
    expect($new->status)->toBe('subscribed');
});

test('bulkUpsert mode=overwrite with overwriteStatus + overwriteTags: everything replaced', function () {
    DB::table('subscribers')->insert([
        'uid'             => uniqid(),
        'mail_list_id'    => $this->list->id,
        'email'           => '[email protected]',
        'status'          => 'unsubscribed',
        'tags'            => json_encode(['oldtag']),
        $this->emailCol   => '[email protected]',
        $this->firstNameCol => 'OldAlice',
        'created_at'      => now(),
        'updated_at'      => now(),
    ]);

    $rows = [
        canon(['EMAIL' => '[email protected]', 'FIRST_NAME' => 'NewAlice', 'status' => 'subscribed', 'tags' => json_encode(['newtag'])], $this->emailCol, $this->firstNameCol),
    ];
    $this->list->bulkUpsert($rows, ['mode' => 'overwrite', 'overwriteStatus' => true, 'overwriteTags' => true]);

    $alice = DB::table('subscribers')->where('mail_list_id', $this->list->id)->where('email', '[email protected]')->first();
    expect($alice->status)->toBe('subscribed');
    expect(json_decode($alice->tags, true))->toBe(['newtag']);
    expect($alice->{$this->firstNameCol})->toBe('NewAlice');
});

test('bulkUpsert mode=skip: existing rows untouched, new inserted', function () {
    DB::table('subscribers')->insert([
        'uid'               => uniqid(),
        'mail_list_id'      => $this->list->id,
        'email'             => '[email protected]',
        'status'            => 'unsubscribed',
        'tags'              => json_encode(['oldtag']),
        $this->emailCol     => '[email protected]',
        $this->firstNameCol => 'OldAlice',
        'created_at'        => now(),
        'updated_at'        => now(),
    ]);

    $rows = [
        canon(['EMAIL' => '[email protected]', 'FIRST_NAME' => 'WouldBeIgnored', 'status' => 'subscribed', 'tags' => json_encode(['z'])], $this->emailCol, $this->firstNameCol),
        canon(['EMAIL' => '[email protected]', 'FIRST_NAME' => 'Fresh',          'status' => 'subscribed', 'tags' => json_encode([])  ], $this->emailCol, $this->firstNameCol),
    ];
    $counts = $this->list->bulkUpsert($rows, ['mode' => 'skip']);

    expect($counts)->toMatchArray(['inserted' => 1, 'updated' => 0]);

    $alice = DB::table('subscribers')->where('mail_list_id', $this->list->id)->where('email', '[email protected]')->first();
    expect($alice->status)->toBe('unsubscribed');
    expect($alice->{$this->firstNameCol})->toBe('OldAlice');               // untouched
});

// ════════════════════════════════════════════════════════════════════════════
//  Tmp table collision fix — two parallel bulkUpsert calls on the same list
// ════════════════════════════════════════════════════════════════════════════

test('heterogeneous row shape (some rows missing custom_field keys) — INSERT still works', function () {
    // Regression: live API test caught a `SQLSTATE[21S01] column count does
    // not match value count` when one row had EMAIL+FIRST_NAME and another
    // had only EMAIL. bulkUpsert now pads missing columns with NULL so the
    // multi-row INSERT lines up.
    $rows = [
        // Row A — has both email + first_name
        canon(['EMAIL' => '[email protected]', 'FIRST_NAME' => 'Anna', 'status' => 'subscribed'], $this->emailCol, $this->firstNameCol),
        // Row B — only email (no first_name key at all)
        [$this->emailCol => '[email protected]', 'status' => 'subscribed'],
        // Row C — only email + status
        [$this->emailCol => '[email protected]', 'status' => 'unsubscribed'],
    ];

    $counts = $this->list->bulkUpsert($rows);

    expect($counts['inserted'])->toBe(3);
    expect($counts['updated'])->toBe(0);

    // Row B + C should land with first_name = NULL (the missing key padded).
    $b = DB::table('subscribers')
        ->where('mail_list_id', $this->list->id)
        ->where('email', '[email protected]')->first();
    expect($b)->not->toBeNull();
    expect($b->{$this->firstNameCol})->toBeNull();
});

test('parallel bulkUpsert calls on same list do not collide on tmp table name', function () {
    // Simulate two interleaved imports: open call A's tmp table, then run B
    // start-to-finish, then finish A. Pre-refactor this would fail because
    // both tmp tables would collide on `__tmp_subscribers_{uid}`. Post-fix
    // the uniqid() suffix keeps them separate.
    $rowsA = [canon(['EMAIL' => '[email protected]', 'FIRST_NAME' => 'A', 'status' => 'subscribed', 'tags' => json_encode([])], $this->emailCol, $this->firstNameCol)];
    $rowsB = [canon(['EMAIL' => '[email protected]', 'FIRST_NAME' => 'B', 'status' => 'subscribed', 'tags' => json_encode([])], $this->emailCol, $this->firstNameCol)];

    $this->list->bulkUpsert($rowsA);
    $this->list->bulkUpsert($rowsB);

    // Both rows landed → tmp tables didn't collide.
    expect(DB::table('subscribers')->where('mail_list_id', $this->list->id)->count())->toBe(2);
});