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);
});