feat(admin): star/tags marks, service product-type filter, campaign bulk actions, SMTP test

- Star (is_starred) and colored tags for services, customers, orders, invoices:
  Markable trait, tags/taggables tables, PATCH administrator/marks/{type}/{key},
  tag CRUD, list filters starred / tag_ids[]
- GET administrator/services accepts product_type (falls back to product type
  for legacy rows without services.type)
- Mail campaigns: bulk cancel / retry failed / delete; cancelled campaigns are
  skipped by SendMailCampaignRecipient
- POST administrator/settings/test-smtp sends a test email through the saved
  mail transport

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-15 21:53:41 +07:00
co-authored by Claude Opus 5.5
parent 46aa138eec
commit 40bf6b54f9
33 changed files with 780 additions and 11 deletions
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin;
use App\Jobs\Mail\SendMailCampaignRecipient;
use App\Models\Mail;
use App\Models\MailCampaign;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail as MailFacade;
use Illuminate\Support\Facades\Queue;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class MailCampaignBulkActionTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
$this->admin = User::factory()->create();
$this->admin->assignRole('Full Administrator');
}
private function campaign(string $status, int $sent = 0, int $failed = 0): MailCampaign
{
return MailCampaign::create([
'name' => 'C', 'subject' => 'S', 'message' => '<p>x</p>', 'recipient_type' => 'selected',
'total_recipients' => 2, 'sent_count' => $sent, 'failed_count' => $failed, 'status' => $status,
]);
}
public function test_cancel_stops_pending_jobs_and_keeps_finished_campaigns(): void
{
MailFacade::fake();
$sending = $this->campaign('sending');
$done = $this->campaign('completed', 2);
$this->actingAs($this->admin, 'admin')
->postJson('/api/v1/administrator/mail-campaigns/bulk-action', ['action' => 'cancel', 'uuids' => [$sending->uuid, $done->uuid]])
->assertOk();
$this->assertSame('cancelled', $sending->fresh()->status);
$this->assertSame('completed', $done->fresh()->status);
(new SendMailCampaignRecipient($sending->id, null, 'a@example.com'))->handle(app(\App\Services\Email\MailCampaignRenderer::class));
MailFacade::assertNothingSent();
$this->assertSame(0, $sending->fresh()->sent_count);
}
public function test_retry_requeues_only_failed_recipients(): void
{
Queue::fake();
$campaign = $this->campaign('completed', 1, 1);
foreach (['ok@example.com' => 'sent', 'bad@example.com' => 'failed'] as $to => $status) {
Mail::create(['campaign_id' => $campaign->id, 'template_id' => 0, 'subject' => 'S', 'to' => $to, 'message' => 'x', 'status' => $status]);
}
$this->actingAs($this->admin, 'admin')
->postJson('/api/v1/administrator/mail-campaigns/bulk-action', ['action' => 'retry-failed', 'uuids' => [$campaign->uuid]])
->assertOk();
$campaign->refresh();
$this->assertSame('sending', $campaign->status);
$this->assertSame(0, $campaign->failed_count);
$this->assertDatabaseMissing('mail', ['to' => 'bad@example.com']);
Queue::assertPushed(SendMailCampaignRecipient::class, 1);
}
public function test_delete_skips_campaigns_still_sending(): void
{
$sending = $this->campaign('queued');
$done = $this->campaign('failed', 0, 2);
$this->actingAs($this->admin, 'admin')
->postJson('/api/v1/administrator/mail-campaigns/bulk-delete', ['uuids' => [$sending->uuid, $done->uuid]])
->assertOk();
$this->assertNotNull($sending->fresh());
$this->assertNull($done->fresh());
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\Order;
use App\Models\Product;
use App\Models\Service;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\DataProvider;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class MarkTest extends TestCase
{
use RefreshDatabase;
private function admin(string ...$permissions): User
{
$role = Role::create(['name' => 'role-' . uniqid(), 'guard_name' => 'admin']);
foreach ($permissions as $permission) {
$role->givePermissionTo(Permission::firstOrCreate(['name' => $permission, 'guard_name' => 'admin']));
}
$admin = User::factory()->create();
$admin->assignRole($role);
return $admin;
}
public function test_stars_and_tags_customer_then_filters_list(): void
{
$admin = $this->admin('customer.view', 'customer.edit');
$customer = Customer::factory()->create();
Customer::factory()->create();
$tagId = $this->actingAs($admin, 'admin')
->postJson('/api/v1/administrator/tags', ['title' => 'VIP', 'color' => '#ff0000'])
->assertCreated()
->json('data.id');
$this->actingAs($admin, 'admin')
->patchJson("/api/v1/administrator/marks/customers/{$customer->uuid}", [
'is_starred' => true,
'tag_ids' => [$tagId],
])
->assertOk()
->assertJsonPath('data.is_starred', true)
->assertJsonPath('data.tags.0.title', 'VIP');
$this->actingAs($admin, 'admin')
->getJson('/api/v1/administrator/customers?starred=1')
->assertOk()
->assertJsonCount(1, 'data.items')
->assertJsonPath('data.items.0.uuid', $customer->uuid)
->assertJsonPath('data.items.0.tags.0.color', '#ff0000');
$this->actingAs($admin, 'admin')
->getJson("/api/v1/administrator/customers?tag_ids[]={$tagId}")
->assertJsonCount(1, 'data.items');
$this->actingAs($admin, 'admin')
->patchJson("/api/v1/administrator/marks/customers/{$customer->uuid}", ['tag_ids' => []])
->assertOk()
->assertJsonCount(0, 'data.tags');
$this->assertTrue($customer->fresh()->is_starred);
}
public function test_requires_module_edit_permission_and_valid_input(): void
{
Permission::firstOrCreate(['name' => 'customer.edit', 'guard_name' => 'admin']);
$customer = Customer::factory()->create();
$this->actingAs($this->admin('order.edit'), 'admin')
->patchJson("/api/v1/administrator/marks/customers/{$customer->uuid}", ['is_starred' => true])
->assertForbidden();
$admin = $this->admin('customer.edit');
$this->actingAs($admin, 'admin')
->patchJson("/api/v1/administrator/marks/customers/{$customer->uuid}", ['tag_ids' => [999]])
->assertUnprocessable();
$this->actingAs($admin, 'admin')
->postJson('/api/v1/administrator/tags', ['title' => 'X', 'color' => 'red;'])
->assertUnprocessable();
}
public function test_deleting_tag_detaches_it(): void
{
$customer = Customer::factory()->create();
$tag = Tag::create(['title' => 'Nợ', 'color' => '#000000']);
$customer->tags()->attach($tag);
$this->actingAs($this->admin('setting.manager'), 'admin')
->deleteJson("/api/v1/administrator/tags/{$tag->id}")
->assertOk();
$this->assertDatabaseCount('taggables', 0);
}
public static function listEndpoints(): array
{
return [
'services' => ['services', Service::class, '/api/v1/administrator/services', 'service'],
'orders' => ['orders', Order::class, '/api/v1/administrator/orders', 'domain'],
'invoices' => ['invoices', Invoice::class, '/api/v1/administrator/invoices', 'domain'],
];
}
/**
* @param class-string<\Illuminate\Database\Eloquent\Model> $model
*/
#[DataProvider('listEndpoints')]
public function test_marks_and_filters_billing_lists(string $type, string $model, string $listUrl, string $productType): void
{
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
$admin = User::factory()->create();
$admin->assignRole('Full Administrator');
$customer = Customer::factory()->create();
$product = Product::factory()->create(['product_type' => $productType]);
foreach (['a.vn', 'b.vn'] as $serverName) {
$this->actingAs($admin, 'admin')->postJson(
"/api/v1/administrator/customers/{$customer->uuid}/services",
['product_id' => $product->id, 'payment_method' => 'transfer', 'server_name' => $serverName, 'amount_paid' => 0],
)->assertCreated();
}
$tag = Tag::create(['title' => 'Gấp', 'color' => '#fa8c16']);
$target = $model::query()->latest('id')->firstOrFail();
$this->actingAs($admin, 'admin')
->patchJson("/api/v1/administrator/marks/{$type}/{$target->id}", ['is_starred' => true, 'tag_ids' => [$tag->id]])
->assertOk();
$items = $this->actingAs($admin, 'admin')
->getJson("{$listUrl}?starred=1&tag_ids[]={$tag->id}")
->assertOk()
->json('data.items');
$this->assertSame([$target->id], array_column($items, 'id'));
$this->assertTrue($items[0]['is_starred']);
$this->assertSame('Gấp', $items[0]['tags'][0]['title']);
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\Service;
use App\Models\Customer;
use App\Models\Product;
use App\Models\Service;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class ServiceListProductTypeFilterTest extends TestCase
{
use RefreshDatabase;
public function test_filters_services_by_product_type_including_legacy_rows_without_type(): void
{
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
$admin = User::factory()->create();
$admin->assignRole('Full Administrator');
$customer = Customer::factory()->create();
foreach (['email' => 'mail.vn', 'hardware' => 'box.vn'] as $type => $serverName) {
$this->actingAs($admin, 'admin')->postJson(
"/api/v1/administrator/customers/{$customer->uuid}/services",
['product_id' => Product::factory()->create(['product_type' => $type])->id, 'payment_method' => 'transfer', 'server_name' => $serverName, 'amount_paid' => 0],
)->assertCreated();
}
Service::where('server_name', 'box.vn')->update(['type' => null]);
$email = $this->actingAs($admin, 'admin')->getJson('/api/v1/administrator/services?product_type=email')->assertOk();
$this->assertSame(['mail.vn'], array_column($email->json('data.items'), 'server_name'));
$this->assertSame('email', $email->json('data.items.0.product.product_type'));
$hardware = $this->actingAs($admin, 'admin')->getJson('/api/v1/administrator/services?product_type=hardware')->assertOk();
$this->assertSame(['box.vn'], array_column($hardware->json('data.items'), 'server_name'));
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\Setting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class SmtpTestMailTest extends TestCase
{
use RefreshDatabase;
private function admin(): User
{
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
$admin = User::factory()->create();
$admin->assignRole('Full Administrator');
return $admin;
}
public function test_sends_test_email_to_given_address(): void
{
$this->actingAs($this->admin(), 'admin')
->postJson('/api/v1/administrator/settings/test-smtp', ['email' => 'ops@example.com'])
->assertOk();
$messages = app('mail.manager')->mailer('array')->getSymfonyTransport()->messages();
$this->assertCount(1, $messages);
$this->assertSame('ops@example.com', $messages->first()->getEnvelope()->getRecipients()[0]->getAddress());
}
public function test_rejects_invalid_address(): void
{
$this->actingAs($this->admin(), 'admin')
->postJson('/api/v1/administrator/settings/test-smtp', ['email' => 'not-an-email'])
->assertUnprocessable();
}
}