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/Api/SubscribersBulkApiTest.php
<?php

/**
 * SubscribersBulkApiTest — end-to-end coverage of POST /api/v1/lists/{uid}/subscribers/bulk.
 *
 * The sync API funnels through SubscriberRowNormalizer → MailList::bulkUpsert,
 * both of which already have direct tests. This file pins the HTTP contract:
 *   - request shape (`subscribers[]` + `mode` + flags)
 *   - response shape (received/inserted/updated/failed/batch_id/errors[])
 *   - status codes (200/400/403/404/413/422)
 *   - auth via api_token query param
 *   - DB state after successful call
 */

use App\Events\MailListImported;
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\Event;
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('bulkapi_c_'), 'timezone' => 'UTC', 'status' => 'active',
    ]);
    // User::creating sets api_token via str_random(60); read it back rather
    // than try to pre-set it (the boot hook clobbers our value).
    $this->user = User::forceCreate([
        'customer_id' => $this->customer->id,
        'uid'         => uniqid('bulkapi_u_'),
        'email'       => uniqid('u').'@bulkapi.local',
        'password'    => bcrypt('x'),
    ]);
    $this->apiToken = $this->user->fresh()->api_token;

    $this->list = MailList::forceCreate([
        'uid'                      => uniqid('bulkapi_l_'),
        'customer_id'              => $this->customer->id,
        'name'                     => 'Bulk API Test',
        'from_email'               => '[email protected]',
        'from_name'                => 'B',
        'send_welcome_email'       => false,
        'subscribe_confirmation'   => false,
        'unsubscribe_notification' => false,
    ]);

    $this->url = "/api/v1/lists/{$this->list->uid}/subscribers/bulk?api_token={$this->apiToken}";
});

// ════════════════════════════════════════════════════════════════════════════
//  Happy path
// ════════════════════════════════════════════════════════════════════════════

test('POST /bulk inserts every row and returns counts', function () {
    $r = $this->postJson($this->url, [
        'subscribers' => [
            ['EMAIL' => '[email protected]', 'FIRST_NAME' => 'Alice', 'status' => 'subscribed',   'tags' => ['vip']],
            ['EMAIL' => '[email protected]',   'FIRST_NAME' => 'Bob',   'status' => 'unsubscribed', 'tags' => ['lapsed']],
            ['EMAIL' => '[email protected]', 'FIRST_NAME' => 'Carol', 'status' => 'unconfirmed',  'tags' => []],
        ],
    ]);

    $r->assertOk();
    $r->assertJson([
        'list_uid' => $this->list->uid,
        'received' => 3,
        'inserted' => 3,
        'updated'  => 0,
        'failed'   => 0,
    ]);
    expect($r->json('batch_id'))->not->toBeEmpty();
    expect($r->json('errors'))->toBe([]);

    // DB state check on a sample row.
    $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(['vip']);
});

test('POST /bulk with mode=skip leaves existing rows untouched', function () {
    // Seed alice.
    DB::table('subscribers')->insert([
        'uid' => uniqid(), 'mail_list_id' => $this->list->id,
        'email' => '[email protected]', 'status' => 'unsubscribed',
        $this->list->getEmailField()->custom_field_name => '[email protected]',
        'created_at' => now(), 'updated_at' => now(),
    ]);

    $r = $this->postJson($this->url, [
        'subscribers' => [
            ['EMAIL' => '[email protected]', 'FIRST_NAME' => 'WouldBeIgnored', 'status' => 'subscribed'],
            ['EMAIL' => '[email protected]', 'FIRST_NAME' => 'New'],
        ],
        'mode' => 'skip',
    ]);

    $r->assertOk();
    expect($r->json('inserted'))->toBe(1);
    expect($r->json('updated'))->toBe(0);

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

test('POST /bulk overwrite_status=1 honored end-to-end', function () {
    DB::table('subscribers')->insert([
        'uid' => uniqid(), 'mail_list_id' => $this->list->id,
        'email' => '[email protected]', 'status' => 'unsubscribed',
        $this->list->getEmailField()->custom_field_name => '[email protected]',
        'created_at' => now(), 'updated_at' => now(),
    ]);

    $r = $this->postJson($this->url, [
        'subscribers' => [
            ['EMAIL' => '[email protected]', 'status' => 'subscribed'],
        ],
        'overwrite_status' => true,
    ]);

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

// ════════════════════════════════════════════════════════════════════════════
//  Validation / error cases
// ════════════════════════════════════════════════════════════════════════════

test('POST /bulk rejects missing subscribers array with 400', function () {
    $this->postJson($this->url, [])->assertStatus(400);
});

test('POST /bulk rejects invalid mode with 422', function () {
    $r = $this->postJson($this->url, [
        'subscribers' => [['EMAIL' => '[email protected]']],
        'mode' => 'wipe-everything',
    ]);
    $r->assertStatus(422);
});

test('POST /bulk accepts large payloads inline — caller decides sync vs async', function () {
    // Sync endpoint has no row cap as of the "trust the caller" cleanup.
    // We still run a non-trivial 50-row payload (not 1000+, to keep test fast)
    // to confirm there is no 413 / hardcoded refusal in the path.
    $rows = [];
    for ($i = 0; $i < 50; $i++) {
        $rows[] = ['EMAIL' => "user{$i}@bulkapi.local"];
    }
    $r = $this->postJson($this->url, ['subscribers' => $rows]);
    $r->assertOk();
    expect($r->json('inserted'))->toBe(50);
});

test('POST /bulk partial success: invalid rows go to errors[], valid rows still inserted', function () {
    $r = $this->postJson($this->url, [
        'subscribers' => [
            ['EMAIL' => '[email protected]'],
            ['EMAIL' => 'not-an-email'],         // invalid format → error
            ['FIRST_NAME' => 'Joe'],             // missing email → error
            ['EMAIL' => '[email protected]'],
        ],
    ]);

    $r->assertOk();
    expect($r->json('inserted'))->toBe(2);
    expect($r->json('failed'))->toBe(2);
    expect($r->json('errors'))->toHaveCount(2);

    $errs = $r->json('errors');
    expect($errs[0]['index'])->toBe(1);
    expect($errs[0]['errors']['EMAIL'])->toContain('invalid email format');
    expect($errs[1]['index'])->toBe(2);
    expect($errs[1]['errors'])->toHaveKey('EMAIL');
});

test('POST /bulk unknown field is a soft warning, row still imports', function () {
    $r = $this->postJson($this->url, [
        'subscribers' => [
            ['EMAIL' => '[email protected]', 'BIRTHDAY' => '1990-01-01'],
        ],
    ]);
    $r->assertOk();
    expect($r->json('inserted'))->toBe(1);
    expect($r->json('failed'))->toBe(0);          // warning, not failure
    expect($r->json('errors.0.warning'))->toBeTrue();
    expect($r->json('errors.0.errors'))->toHaveKey('BIRTHDAY');
});

// ════════════════════════════════════════════════════════════════════════════
//  Auth
// ════════════════════════════════════════════════════════════════════════════

test('POST /bulk without api_token returns 401', function () {
    $this->postJson("/api/v1/lists/{$this->list->uid}/subscribers/bulk", [
        'subscribers' => [['EMAIL' => '[email protected]']],
    ])->assertStatus(401);
});

test('POST /bulk on non-existent list returns 404', function () {
    $this->postJson("/api/v1/lists/does-not-exist/subscribers/bulk?api_token={$this->apiToken}", [
        'subscribers' => [['EMAIL' => '[email protected]']],
    ])->assertStatus(404);
});

// ════════════════════════════════════════════════════════════════════════════
//  Event parity — sync API fires MailListImported just like the CSV adapter,
//  so downstream listeners (blacklist sweep, cache, audit) don't silently
//  miss imports that come through the API.
// ════════════════════════════════════════════════════════════════════════════

test('POST /bulk fires MailListImported event with the same shape as the CSV adapter', function () {
    Event::fake([MailListImported::class]);

    $this->postJson($this->url, [
        'subscribers' => [
            ['EMAIL' => '[email protected]'],
            ['EMAIL' => '[email protected]'],
        ],
    ])->assertOk();

    Event::assertDispatched(MailListImported::class, function ($event) {
        return $event->list->id === $this->list->id
            && !empty($event->importBatchId);
    });
});

test('POST /bulk refreshes SubscriberCount cache so UI badge reflects new total', function () {
    // Regression: without updateCachedInfo() after bulkUpsert, the list's
    // SubscriberCount cache key (read by the navbar badge, customer dashboard,
    // and segment percentages) stays stale until something else flushes it.
    \App\Library\Cache\AppCache::for($this->list)->put('SubscriberCount', 0);
    expect(\App\Library\Cache\AppCache::for($this->list)->read('SubscriberCount', 0))->toBe(0);

    $this->postJson($this->url, [
        'subscribers' => [
            ['EMAIL' => '[email protected]'],
            ['EMAIL' => '[email protected]'],
            ['EMAIL' => '[email protected]'],
        ],
    ])->assertOk();

    expect(\App\Library\Cache\AppCache::for($this->list)->read('SubscriberCount', 0))->toBe(3);
});