Add notification system, notification bell, admin/account tests, and footer legal links
- 6 notification classes: PaymentSucceeded, PaymentFailed, SubscriptionCreated, SubscriptionCancelled, ServiceProvisioned, InvoiceGenerated (mail + database) - Wire notifications to existing event listeners + new subscription listeners - NotificationBell component in Account and Admin layouts - NotificationController with index, markAsRead, markAllAsRead endpoints - 62 new Pest tests: AdminPanelTest (admin CRUD) + CustomerAccountTest (account features) - Add Legal links column to marketing footer - 114 tests passing (623 assertions) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Account;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class NotificationController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$notifications = $request->user()
|
||||||
|
->notifications()
|
||||||
|
->latest()
|
||||||
|
->take(20)
|
||||||
|
->get()
|
||||||
|
->map(fn ($notification) => [
|
||||||
|
'id' => $notification->id,
|
||||||
|
'type' => $notification->data['type'] ?? 'unknown',
|
||||||
|
'message' => $notification->data['message'] ?? '',
|
||||||
|
'data' => $notification->data,
|
||||||
|
'read' => $notification->read_at !== null,
|
||||||
|
'created_at' => $notification->created_at->diffForHumans(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$unreadCount = $request->user()->unreadNotifications()->count();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'notifications' => $notifications,
|
||||||
|
'unread_count' => $unreadCount,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsRead(Request $request, string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$notification = $request->user()
|
||||||
|
->notifications()
|
||||||
|
->findOrFail($id);
|
||||||
|
|
||||||
|
$notification->markAsRead();
|
||||||
|
|
||||||
|
return response()->json(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAllAsRead(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$request->user()->unreadNotifications->markAsRead();
|
||||||
|
|
||||||
|
return response()->json(['success' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Listeners;
|
namespace App\Listeners;
|
||||||
|
|
||||||
use App\Events\PaymentFailed;
|
use App\Events\PaymentFailed;
|
||||||
|
use App\Notifications\PaymentFailedNotification;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class HandlePaymentFailed
|
class HandlePaymentFailed
|
||||||
@@ -18,5 +19,12 @@ class HandlePaymentFailed
|
|||||||
'currency' => $event->currency,
|
'currency' => $event->currency,
|
||||||
'reason' => $event->reason,
|
'reason' => $event->reason,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$event->user->notify(new PaymentFailedNotification(
|
||||||
|
gateway: $event->gateway,
|
||||||
|
amount: $event->amount,
|
||||||
|
currency: $event->currency,
|
||||||
|
reason: $event->reason,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Listeners;
|
namespace App\Listeners;
|
||||||
|
|
||||||
use App\Events\PaymentSucceeded;
|
use App\Events\PaymentSucceeded;
|
||||||
|
use App\Notifications\PaymentSucceededNotification;
|
||||||
use App\Services\Billing\DunningService;
|
use App\Services\Billing\DunningService;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Laravel\Cashier\Subscription;
|
use Laravel\Cashier\Subscription;
|
||||||
@@ -22,6 +23,8 @@ class HandlePaymentSucceeded
|
|||||||
'amount' => $event->transaction->amount,
|
'amount' => $event->transaction->amount,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$event->user->notify(new PaymentSucceededNotification($event->transaction));
|
||||||
|
|
||||||
// Reactivate any suspended services if the user pays an overdue subscription
|
// Reactivate any suspended services if the user pays an overdue subscription
|
||||||
$subscriptionId = $event->transaction->subscription_id;
|
$subscriptionId = $event->transaction->subscription_id;
|
||||||
|
|
||||||
|
|||||||
22
website/app/Listeners/HandleSubscriptionCancelled.php
Normal file
22
website/app/Listeners/HandleSubscriptionCancelled.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Events\SubscriptionCancelled;
|
||||||
|
use App\Notifications\SubscriptionCancelledNotification;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class HandleSubscriptionCancelled
|
||||||
|
{
|
||||||
|
public function handle(SubscriptionCancelled $event): void
|
||||||
|
{
|
||||||
|
Log::info("Subscription cancelled for user #{$event->user->id}", [
|
||||||
|
'subscription_id' => $event->subscription->id,
|
||||||
|
'type' => $event->subscription->type,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$event->user->notify(new SubscriptionCancelledNotification($event->subscription));
|
||||||
|
}
|
||||||
|
}
|
||||||
22
website/app/Listeners/HandleSubscriptionCreated.php
Normal file
22
website/app/Listeners/HandleSubscriptionCreated.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Events\SubscriptionCreated;
|
||||||
|
use App\Notifications\SubscriptionCreatedNotification;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class HandleSubscriptionCreated
|
||||||
|
{
|
||||||
|
public function handle(SubscriptionCreated $event): void
|
||||||
|
{
|
||||||
|
Log::info("Subscription created for user #{$event->user->id}", [
|
||||||
|
'subscription_id' => $event->subscription->id,
|
||||||
|
'type' => $event->subscription->type,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$event->user->notify(new SubscriptionCreatedNotification($event->subscription));
|
||||||
|
}
|
||||||
|
}
|
||||||
63
website/app/Notifications/InvoiceGeneratedNotification.php
Normal file
63
website/app/Notifications/InvoiceGeneratedNotification.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\Invoice;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class InvoiceGeneratedNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public Invoice $invoice,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return array<int, string> */
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail', 'database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$amount = number_format((float) $this->invoice->total, 2);
|
||||||
|
$currency = strtoupper($this->invoice->currency);
|
||||||
|
$invoiceUrl = 'https://'.config('app.domains.account').'/billing/invoices';
|
||||||
|
|
||||||
|
$mail = (new MailMessage)
|
||||||
|
->subject("Invoice #{$this->invoice->number} - {$currency} {$amount}")
|
||||||
|
->greeting("Hello {$notifiable->name},")
|
||||||
|
->line('A new invoice has been generated for your account.')
|
||||||
|
->line("Invoice: **#{$this->invoice->number}**")
|
||||||
|
->line("Amount: **{$currency} {$amount}**");
|
||||||
|
|
||||||
|
if ($this->invoice->due_date) {
|
||||||
|
$dueDate = $this->invoice->due_date->format('M j, Y');
|
||||||
|
$mail->line("Due date: **{$dueDate}**");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mail
|
||||||
|
->action('View Invoices', $invoiceUrl)
|
||||||
|
->line('Thank you for your business!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'invoice_generated',
|
||||||
|
'invoice_id' => $this->invoice->id,
|
||||||
|
'invoice_number' => $this->invoice->number,
|
||||||
|
'total' => $this->invoice->total,
|
||||||
|
'currency' => $this->invoice->currency,
|
||||||
|
'due_date' => $this->invoice->due_date?->toDateString(),
|
||||||
|
'message' => "Invoice #{$this->invoice->number} generated for {$this->invoice->currency} {$this->invoice->total}.",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
57
website/app/Notifications/PaymentFailedNotification.php
Normal file
57
website/app/Notifications/PaymentFailedNotification.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class PaymentFailedNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public string $gateway,
|
||||||
|
public float $amount,
|
||||||
|
public string $currency,
|
||||||
|
public string $reason,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return array<int, string> */
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail', 'database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$amount = number_format($this->amount, 2);
|
||||||
|
$currency = strtoupper($this->currency);
|
||||||
|
$billingUrl = 'https://'.config('app.domains.account').'/billing';
|
||||||
|
|
||||||
|
return (new MailMessage)
|
||||||
|
->subject('Payment Failed - Action Required')
|
||||||
|
->greeting("Hello {$notifiable->name},")
|
||||||
|
->line("We were unable to process your payment of **{$currency} {$amount}**.")
|
||||||
|
->line("Reason: {$this->reason}")
|
||||||
|
->line('Please update your payment method to avoid service interruption.')
|
||||||
|
->action('Update Payment Method', $billingUrl)
|
||||||
|
->line('If you need assistance, please contact our support team.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'payment_failed',
|
||||||
|
'amount' => $this->amount,
|
||||||
|
'currency' => $this->currency,
|
||||||
|
'gateway' => $this->gateway,
|
||||||
|
'reason' => $this->reason,
|
||||||
|
'message' => "Payment of {$this->currency} {$this->amount} failed: {$this->reason}",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
55
website/app/Notifications/PaymentSucceededNotification.php
Normal file
55
website/app/Notifications/PaymentSucceededNotification.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\PaymentTransaction;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class PaymentSucceededNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public PaymentTransaction $transaction,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return array<int, string> */
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail', 'database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$amount = number_format((float) $this->transaction->amount, 2);
|
||||||
|
$currency = strtoupper($this->transaction->currency);
|
||||||
|
$billingUrl = 'https://'.config('app.domains.account').'/billing';
|
||||||
|
|
||||||
|
return (new MailMessage)
|
||||||
|
->subject("Payment of {$currency} {$amount} Received")
|
||||||
|
->greeting("Hello {$notifiable->name}!")
|
||||||
|
->line("We've successfully processed your payment of **{$currency} {$amount}**.")
|
||||||
|
->line("Payment method: {$this->transaction->payment_method}")
|
||||||
|
->line("Transaction ID: {$this->transaction->gateway_transaction_id}")
|
||||||
|
->action('View Billing History', $billingUrl)
|
||||||
|
->line('Thank you for choosing EZSCALE!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'payment_succeeded',
|
||||||
|
'transaction_id' => $this->transaction->id,
|
||||||
|
'amount' => $this->transaction->amount,
|
||||||
|
'currency' => $this->transaction->currency,
|
||||||
|
'gateway' => $this->transaction->gateway,
|
||||||
|
'message' => "Payment of {$this->transaction->currency} {$this->transaction->amount} processed successfully.",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
61
website/app/Notifications/ServiceProvisionedNotification.php
Normal file
61
website/app/Notifications/ServiceProvisionedNotification.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\Service;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class ServiceProvisionedNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public Service $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return array<int, string> */
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail', 'database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$serviceUrl = 'https://'.config('app.domains.account').'/services/'.$this->service->id;
|
||||||
|
|
||||||
|
$mail = (new MailMessage)
|
||||||
|
->subject('Your Service is Ready!')
|
||||||
|
->greeting("Hello {$notifiable->name}!")
|
||||||
|
->line("Your **{$this->service->service_type}** service has been provisioned and is ready to use.");
|
||||||
|
|
||||||
|
if ($this->service->hostname) {
|
||||||
|
$mail->line("Hostname: **{$this->service->hostname}**");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->service->ipv4_address) {
|
||||||
|
$mail->line("IP Address: **{$this->service->ipv4_address}**");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mail
|
||||||
|
->action('View Service Details', $serviceUrl)
|
||||||
|
->line('If you need help getting started, check our knowledge base or contact support.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'service_provisioned',
|
||||||
|
'service_id' => $this->service->id,
|
||||||
|
'service_type' => $this->service->service_type,
|
||||||
|
'hostname' => $this->service->hostname,
|
||||||
|
'ip_address' => $this->service->ipv4_address,
|
||||||
|
'message' => "Your {$this->service->service_type} service is ready!",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use Laravel\Cashier\Subscription;
|
||||||
|
|
||||||
|
class SubscriptionCancelledNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public Subscription $subscription,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return array<int, string> */
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail', 'database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$pricingUrl = 'https://'.config('app.domains.marketing').'/pricing';
|
||||||
|
|
||||||
|
return (new MailMessage)
|
||||||
|
->subject('Subscription Cancelled')
|
||||||
|
->greeting("Hello {$notifiable->name},")
|
||||||
|
->line("Your subscription **{$this->subscription->type}** has been cancelled.")
|
||||||
|
->line('Your service will remain active until the end of your current billing period.')
|
||||||
|
->line('You can resubscribe at any time to continue using our services.')
|
||||||
|
->action('View Plans', $pricingUrl)
|
||||||
|
->line('We hope to see you again soon!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'subscription_cancelled',
|
||||||
|
'subscription_id' => $this->subscription->id,
|
||||||
|
'subscription_type' => $this->subscription->type,
|
||||||
|
'message' => "Subscription \"{$this->subscription->type}\" has been cancelled.",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use Laravel\Cashier\Subscription;
|
||||||
|
|
||||||
|
class SubscriptionCreatedNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public Subscription $subscription,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return array<int, string> */
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail', 'database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$dashboardUrl = 'https://'.config('app.domains.account').'/dashboard';
|
||||||
|
|
||||||
|
return (new MailMessage)
|
||||||
|
->subject('Subscription Confirmed')
|
||||||
|
->greeting("Welcome aboard, {$notifiable->name}!")
|
||||||
|
->line("Your subscription **{$this->subscription->type}** has been created successfully.")
|
||||||
|
->line('Your service is being provisioned and will be ready shortly.')
|
||||||
|
->action('Go to Dashboard', $dashboardUrl)
|
||||||
|
->line('Thank you for choosing EZSCALE!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'subscription_created',
|
||||||
|
'subscription_id' => $this->subscription->id,
|
||||||
|
'subscription_type' => $this->subscription->type,
|
||||||
|
'message' => "Subscription \"{$this->subscription->type}\" created successfully.",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('notifications', function (Blueprint $table) {
|
||||||
|
$table->uuid('id')->primary();
|
||||||
|
$table->string('type');
|
||||||
|
$table->morphs('notifiable');
|
||||||
|
$table->text('data');
|
||||||
|
$table->timestamp('read_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('notifications');
|
||||||
|
}
|
||||||
|
};
|
||||||
163
website/resources/ts/Components/NotificationBell.vue
Normal file
163
website/resources/ts/Components/NotificationBell.vue
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
interface NotificationItem {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
data: Record<string, unknown>
|
||||||
|
read: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifications = ref<NotificationItem[]>([])
|
||||||
|
const unreadCount = ref<number>(0)
|
||||||
|
const loading = ref<boolean>(false)
|
||||||
|
const menu = ref<boolean>(false)
|
||||||
|
|
||||||
|
function resolveIcon(type: string): string {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
payment_succeeded: 'tabler-credit-card',
|
||||||
|
payment_failed: 'tabler-credit-card-off',
|
||||||
|
subscription_created: 'tabler-rosette-discount-check',
|
||||||
|
subscription_cancelled: 'tabler-circle-x',
|
||||||
|
service_provisioned: 'tabler-server',
|
||||||
|
invoice_generated: 'tabler-file-invoice',
|
||||||
|
}
|
||||||
|
|
||||||
|
return icons[type] ?? 'tabler-bell'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveColor(type: string): string {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
payment_succeeded: 'success',
|
||||||
|
payment_failed: 'error',
|
||||||
|
subscription_created: 'primary',
|
||||||
|
subscription_cancelled: 'warning',
|
||||||
|
service_provisioned: 'info',
|
||||||
|
invoice_generated: 'secondary',
|
||||||
|
}
|
||||||
|
|
||||||
|
return colors[type] ?? 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchNotifications(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/notifications')
|
||||||
|
notifications.value = response.data.notifications
|
||||||
|
unreadCount.value = response.data.unread_count
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Silently fail for notification fetch
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAsRead(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await axios.post(`/notifications/${id}/read`)
|
||||||
|
const notification = notifications.value.find(n => n.id === id)
|
||||||
|
if (notification) {
|
||||||
|
notification.read = true
|
||||||
|
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllAsRead(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await axios.post('/notifications/read-all')
|
||||||
|
notifications.value.forEach(n => { n.read = true })
|
||||||
|
unreadCount.value = 0
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchNotifications()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VMenu
|
||||||
|
v-model="menu"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
offset="14px"
|
||||||
|
@update:model-value="(val: boolean) => { if (val) fetchNotifications() }"
|
||||||
|
>
|
||||||
|
<template #activator="{ props: menuProps }">
|
||||||
|
<VBadge
|
||||||
|
:content="unreadCount"
|
||||||
|
:model-value="unreadCount > 0"
|
||||||
|
color="error"
|
||||||
|
overlap
|
||||||
|
>
|
||||||
|
<VBtn
|
||||||
|
icon="tabler-bell"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
v-bind="menuProps"
|
||||||
|
/>
|
||||||
|
</VBadge>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<VCard width="380" max-height="500">
|
||||||
|
<VCardTitle class="d-flex align-center justify-space-between pa-4">
|
||||||
|
<span class="text-body-1 font-weight-bold">Notifications</span>
|
||||||
|
<VBtn
|
||||||
|
v-if="unreadCount > 0"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
@click="markAllAsRead"
|
||||||
|
>
|
||||||
|
Mark all read
|
||||||
|
</VBtn>
|
||||||
|
</VCardTitle>
|
||||||
|
|
||||||
|
<VDivider />
|
||||||
|
|
||||||
|
<VList v-if="notifications.length > 0" density="compact" class="py-0">
|
||||||
|
<VListItem
|
||||||
|
v-for="notification in notifications"
|
||||||
|
:key="notification.id"
|
||||||
|
:class="{ 'bg-surface-variant': !notification.read }"
|
||||||
|
@click="markAsRead(notification.id)"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VAvatar
|
||||||
|
size="36"
|
||||||
|
:color="resolveColor(notification.type)"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
<VIcon :icon="resolveIcon(notification.type)" size="20" />
|
||||||
|
</VAvatar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<VListItemTitle class="text-body-2 font-weight-medium">
|
||||||
|
{{ notification.message }}
|
||||||
|
</VListItemTitle>
|
||||||
|
<VListItemSubtitle class="text-caption">
|
||||||
|
{{ notification.created_at }}
|
||||||
|
</VListItemSubtitle>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
|
||||||
|
<div v-else class="text-center pa-8">
|
||||||
|
<VIcon icon="tabler-bell-off" size="32" color="disabled" class="mb-2" />
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
No notifications
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VCard>
|
||||||
|
</VMenu>
|
||||||
|
</template>
|
||||||
@@ -4,6 +4,7 @@ import { computed } from 'vue'
|
|||||||
import { useTheme } from 'vuetify'
|
import { useTheme } from 'vuetify'
|
||||||
import { accountNavItems } from '@/navigation/account'
|
import { accountNavItems } from '@/navigation/account'
|
||||||
import FlashMessages from '@/Components/FlashMessages.vue'
|
import FlashMessages from '@/Components/FlashMessages.vue'
|
||||||
|
import NotificationBell from '@/Components/NotificationBell.vue'
|
||||||
import ThemeSwitcher from '@/Components/ThemeSwitcher.vue'
|
import ThemeSwitcher from '@/Components/ThemeSwitcher.vue'
|
||||||
import logoWhite from '@images/ezscale_logo_white.png'
|
import logoWhite from '@images/ezscale_logo_white.png'
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ function isActive(matchPrefix: string): boolean {
|
|||||||
<VSpacer />
|
<VSpacer />
|
||||||
|
|
||||||
<div class="d-flex align-center ga-2">
|
<div class="d-flex align-center ga-2">
|
||||||
|
<NotificationBell />
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
|
|
||||||
<span v-if="user" class="text-body-2">
|
<span v-if="user" class="text-body-2">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { computed } from 'vue'
|
|||||||
import { useTheme } from 'vuetify'
|
import { useTheme } from 'vuetify'
|
||||||
import { adminNavItems } from '@/navigation/admin'
|
import { adminNavItems } from '@/navigation/admin'
|
||||||
import FlashMessages from '@/Components/FlashMessages.vue'
|
import FlashMessages from '@/Components/FlashMessages.vue'
|
||||||
|
import NotificationBell from '@/Components/NotificationBell.vue'
|
||||||
import ThemeSwitcher from '@/Components/ThemeSwitcher.vue'
|
import ThemeSwitcher from '@/Components/ThemeSwitcher.vue'
|
||||||
import logoWhite from '@images/ezscale_logo_white.png'
|
import logoWhite from '@images/ezscale_logo_white.png'
|
||||||
|
|
||||||
@@ -82,6 +83,7 @@ function isActive(matchPrefix: string): boolean {
|
|||||||
</VBtn>
|
</VBtn>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<NotificationBell />
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
|
|
||||||
<span v-if="user" class="text-body-2">
|
<span v-if="user" class="text-body-2">
|
||||||
|
|||||||
@@ -31,10 +31,15 @@ const footerLinks = {
|
|||||||
{ title: 'Blog', href: '/blog' },
|
{ title: 'Blog', href: '/blog' },
|
||||||
],
|
],
|
||||||
support: [
|
support: [
|
||||||
{ title: 'Help Center', href: '/support' },
|
{ title: 'Help Center', href: 'https://ezscale.support' },
|
||||||
{ title: 'Documentation', href: '/docs' },
|
{ title: 'Knowledge Base', href: 'https://ezscale.support/en/knowledgebase' },
|
||||||
{ title: 'API Reference', href: '/api' },
|
{ title: 'Status Page', href: 'https://status.ezscale.cloud' },
|
||||||
{ title: 'Status Page', href: '/status' },
|
],
|
||||||
|
legal: [
|
||||||
|
{ title: 'Terms of Service', href: '/terms-of-service' },
|
||||||
|
{ title: 'Privacy Policy', href: '/privacy-policy' },
|
||||||
|
{ title: 'Acceptable Use', href: '/acceptable-use' },
|
||||||
|
{ title: 'SLA', href: '/sla' },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +131,7 @@ const socialLinks = [
|
|||||||
<VContainer>
|
<VContainer>
|
||||||
<VRow>
|
<VRow>
|
||||||
<!-- Logo + Description + Newsletter -->
|
<!-- Logo + Description + Newsletter -->
|
||||||
<VCol cols="12" md="5">
|
<VCol cols="12" md="4">
|
||||||
<div
|
<div
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
:class="$vuetify.display.smAndDown ? 'w-100' : 'w-75'"
|
:class="$vuetify.display.smAndDown ? 'w-100' : 'w-75'"
|
||||||
@@ -205,7 +210,7 @@ const socialLinks = [
|
|||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
<!-- Support -->
|
<!-- Support -->
|
||||||
<VCol cols="12" md="3" sm="4">
|
<VCol md="2" sm="4" xs="6">
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<h6 class="footer-title text-h6 mb-6">
|
<h6 class="footer-title text-h6 mb-6">
|
||||||
Support
|
Support
|
||||||
@@ -226,6 +231,29 @@ const socialLinks = [
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
|
<!-- Legal -->
|
||||||
|
<VCol md="2" sm="4" xs="6">
|
||||||
|
<div class="footer-links">
|
||||||
|
<h6 class="footer-title text-h6 mb-6">
|
||||||
|
Legal
|
||||||
|
</h6>
|
||||||
|
<ul style="list-style: none; padding: 0;">
|
||||||
|
<li
|
||||||
|
v-for="link in footerLinks.legal"
|
||||||
|
:key="link.href"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
:href="link.href"
|
||||||
|
class="text-white-variant"
|
||||||
|
>
|
||||||
|
{{ link.title }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
</VContainer>
|
</VContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
use App\Http\Controllers\Account\BillingController;
|
use App\Http\Controllers\Account\BillingController;
|
||||||
use App\Http\Controllers\Account\CheckoutController;
|
use App\Http\Controllers\Account\CheckoutController;
|
||||||
use App\Http\Controllers\Account\DashboardController;
|
use App\Http\Controllers\Account\DashboardController;
|
||||||
|
use App\Http\Controllers\Account\NotificationController;
|
||||||
use App\Http\Controllers\Account\PlanController;
|
use App\Http\Controllers\Account\PlanController;
|
||||||
use App\Http\Controllers\Account\ProfileController;
|
use App\Http\Controllers\Account\ProfileController;
|
||||||
use App\Http\Controllers\Account\ServiceController;
|
use App\Http\Controllers\Account\ServiceController;
|
||||||
@@ -48,3 +49,8 @@ Route::get('/billing/invoices', [BillingController::class, 'invoices'])->name('a
|
|||||||
Route::get('/billing/invoices/{invoice}/download', [BillingController::class, 'downloadInvoice'])->name('account.billing.invoices.download');
|
Route::get('/billing/invoices/{invoice}/download', [BillingController::class, 'downloadInvoice'])->name('account.billing.invoices.download');
|
||||||
Route::get('/billing/transactions', [BillingController::class, 'transactions'])->name('account.billing.transactions');
|
Route::get('/billing/transactions', [BillingController::class, 'transactions'])->name('account.billing.transactions');
|
||||||
Route::post('/billing/setup-intent', [BillingController::class, 'setupIntent'])->name('account.billing.setup-intent');
|
Route::post('/billing/setup-intent', [BillingController::class, 'setupIntent'])->name('account.billing.setup-intent');
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
Route::get('/notifications', [NotificationController::class, 'index'])->name('account.notifications.index');
|
||||||
|
Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('account.notifications.read');
|
||||||
|
Route::post('/notifications/read-all', [NotificationController::class, 'markAllAsRead'])->name('account.notifications.read-all');
|
||||||
|
|||||||
284
website/tests/Feature/Account/CustomerAccountTest.php
Normal file
284
website/tests/Feature/Account/CustomerAccountTest.php
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Invoice;
|
||||||
|
use App\Models\Plan;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RoleAndPermissionSeeder;
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->seed(RoleAndPermissionSeeder::class);
|
||||||
|
$this->accountUrl = 'http://'.config('app.domains.account');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dashboard', function (): void {
|
||||||
|
it('allows a customer to view the dashboard', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->accountUrl.'/dashboard')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Dashboard')
|
||||||
|
->has('activeServicesCount')
|
||||||
|
->has('activeSubscriptionsCount')
|
||||||
|
->has('pendingInvoicesAmount')
|
||||||
|
->has('latestInvoices')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects guests to login', function (): void {
|
||||||
|
$this->get($this->accountUrl.'/dashboard')
|
||||||
|
->assertRedirect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows correct active services count', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
Service::factory()->count(3)->create(['user_id' => $customer->id, 'status' => 'active']);
|
||||||
|
Service::factory()->create(['user_id' => $customer->id, 'status' => 'suspended']);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->accountUrl.'/dashboard')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Dashboard')
|
||||||
|
->where('activeServicesCount', 3)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows correct pending invoices amount', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
Invoice::factory()->create(['user_id' => $customer->id, 'status' => 'pending', 'total' => 50.00]);
|
||||||
|
Invoice::factory()->create(['user_id' => $customer->id, 'status' => 'overdue', 'total' => 25.50]);
|
||||||
|
Invoice::factory()->create(['user_id' => $customer->id, 'status' => 'paid', 'total' => 100.00]);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->accountUrl.'/dashboard')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Dashboard')
|
||||||
|
->where('pendingInvoicesAmount', '75.50')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Profile', function (): void {
|
||||||
|
it('allows a customer to view their profile', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->accountUrl.'/profile')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Profile/Show')
|
||||||
|
->has('user')
|
||||||
|
->has('twoFactorEnabled')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a customer to update their profile', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->put($this->accountUrl.'/profile', [
|
||||||
|
'first_name' => 'Jane',
|
||||||
|
'last_name' => 'Smith',
|
||||||
|
'phone' => '+1234567890',
|
||||||
|
'company' => 'Acme Inc',
|
||||||
|
])
|
||||||
|
->assertRedirect()
|
||||||
|
->assertSessionHas('success', 'Profile updated successfully.');
|
||||||
|
|
||||||
|
$customer->refresh();
|
||||||
|
expect($customer->name)->toBe('Jane Smith');
|
||||||
|
expect($customer->phone)->toBe('+1234567890');
|
||||||
|
expect($customer->company)->toBe('Acme Inc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates profile update requires first and last name', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->put($this->accountUrl.'/profile', [
|
||||||
|
'first_name' => '',
|
||||||
|
'last_name' => '',
|
||||||
|
])
|
||||||
|
->assertSessionHasErrors(['first_name', 'last_name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a customer to update their password', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->put($this->accountUrl.'/profile/password', [
|
||||||
|
'current_password' => 'password',
|
||||||
|
'password' => 'NewSecurePass1!',
|
||||||
|
'password_confirmation' => 'NewSecurePass1!',
|
||||||
|
])
|
||||||
|
->assertRedirect()
|
||||||
|
->assertSessionHas('success', 'Password updated successfully.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects password update with wrong current password', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->put($this->accountUrl.'/profile/password', [
|
||||||
|
'current_password' => 'wrong-password',
|
||||||
|
'password' => 'NewSecurePass1!',
|
||||||
|
'password_confirmation' => 'NewSecurePass1!',
|
||||||
|
])
|
||||||
|
->assertSessionHasErrors('current_password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects password update when confirmation does not match', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->put($this->accountUrl.'/profile/password', [
|
||||||
|
'current_password' => 'password',
|
||||||
|
'password' => 'NewSecurePass1!',
|
||||||
|
'password_confirmation' => 'DifferentPass2!',
|
||||||
|
])
|
||||||
|
->assertSessionHasErrors('password');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Plans', function (): void {
|
||||||
|
it('displays active plans to a customer', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
Plan::factory()->count(3)->create(['status' => 'active']);
|
||||||
|
Plan::factory()->create(['status' => 'inactive']);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->accountUrl.'/plans')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Plans/Index')
|
||||||
|
->has('plansByType')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays a single plan detail page', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$plan = Plan::factory()->create(['status' => 'active']);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->accountUrl.'/plans/'.$plan->id)
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Plans/Show')
|
||||||
|
->has('plan')
|
||||||
|
->where('plan.id', $plan->id)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Services', function (): void {
|
||||||
|
it('displays only the customers own services', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$otherUser = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
Service::factory()->count(2)->create(['user_id' => $customer->id]);
|
||||||
|
Service::factory()->count(3)->create(['user_id' => $otherUser->id]);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->accountUrl.'/services')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Services/Index')
|
||||||
|
->has('services', 2)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a customer to view their own service detail', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$service = Service::factory()->create(['user_id' => $customer->id]);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->accountUrl.'/services/'.$service->id)
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Services/Show')
|
||||||
|
->has('service')
|
||||||
|
->where('service.id', $service->id)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids a customer from viewing another users service', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$otherUser = User::factory()->customer()->create();
|
||||||
|
$service = Service::factory()->create(['user_id' => $otherUser->id]);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->accountUrl.'/services/'.$service->id)
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Subscriptions', function (): void {
|
||||||
|
it('displays the subscriptions list page', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->accountUrl.'/subscriptions')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Subscriptions/Index')
|
||||||
|
->has('subscriptions')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires authentication to view subscriptions', function (): void {
|
||||||
|
$this->get($this->accountUrl.'/subscriptions')
|
||||||
|
->assertRedirect();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Billing', function (): void {
|
||||||
|
it('displays the billing index page', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->accountUrl.'/billing')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Billing/Index')
|
||||||
|
->has('paymentMethods')
|
||||||
|
->has('invoices')
|
||||||
|
->has('transactions')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays the invoices page with customer invoices', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
Invoice::factory()->count(3)->create(['user_id' => $customer->id]);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->accountUrl.'/billing/invoices')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Billing/Invoices')
|
||||||
|
->has('invoices.data', 3)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays the transactions page', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->accountUrl.'/billing/transactions')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Billing/Transactions')
|
||||||
|
->has('transactions')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires authentication to view billing', function (): void {
|
||||||
|
$this->get($this->accountUrl.'/billing')
|
||||||
|
->assertRedirect();
|
||||||
|
});
|
||||||
|
});
|
||||||
635
website/tests/Feature/Admin/AdminPanelTest.php
Normal file
635
website/tests/Feature/Admin/AdminPanelTest.php
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\Coupon;
|
||||||
|
use App\Models\Invoice;
|
||||||
|
use App\Models\Plan;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RoleAndPermissionSeeder;
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->seed(RoleAndPermissionSeeder::class);
|
||||||
|
$this->adminUrl = 'http://'.config('app.domains.admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dashboard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Dashboard', function (): void {
|
||||||
|
it('allows admin to access the dashboard', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/dashboard')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Dashboard')
|
||||||
|
->has('totalCustomers')
|
||||||
|
->has('mrr')
|
||||||
|
->has('totalRevenue')
|
||||||
|
->has('activeServices')
|
||||||
|
->has('recentInvoices')
|
||||||
|
->has('recentSubscriptions')
|
||||||
|
->has('popularPlans')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies customer access to the admin dashboard', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->adminUrl.'/dashboard')
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects guest to login when accessing admin dashboard', function (): void {
|
||||||
|
$this->get($this->adminUrl.'/dashboard')
|
||||||
|
->assertRedirect();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Customer Management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Customer Management', function (): void {
|
||||||
|
it('displays the customer list page', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
User::factory()->customer()->count(3)->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/customers')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Customers/Index')
|
||||||
|
->has('customers.data', 3)
|
||||||
|
->has('filters')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters customers by search query', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
User::factory()->customer()->create(['name' => 'Alice Wonderland']);
|
||||||
|
User::factory()->customer()->create(['name' => 'Bob Builder']);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/customers?search=Alice')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Customers/Index')
|
||||||
|
->has('customers.data', 1)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a customer detail page with services and invoices', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
Service::factory()->create(['user_id' => $customer->id]);
|
||||||
|
Invoice::factory()->count(2)->create(['user_id' => $customer->id]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/customers/'.$customer->id)
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Customers/Show')
|
||||||
|
->has('customer')
|
||||||
|
->has('recentInvoices')
|
||||||
|
->has('auditLogs')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suspends a customer account', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->post($this->adminUrl.'/customers/'.$customer->id.'/suspend')
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect($customer->fresh()->status)->toBe('suspended');
|
||||||
|
$this->assertDatabaseHas('audit_logs', [
|
||||||
|
'user_id' => $customer->id,
|
||||||
|
'admin_id' => $admin->id,
|
||||||
|
'action' => 'suspend_account',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unsuspends a customer account', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$customer = User::factory()->customer()->suspended()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->post($this->adminUrl.'/customers/'.$customer->id.'/unsuspend')
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect($customer->fresh()->status)->toBe('active');
|
||||||
|
$this->assertDatabaseHas('audit_logs', [
|
||||||
|
'user_id' => $customer->id,
|
||||||
|
'admin_id' => $admin->id,
|
||||||
|
'action' => 'unsuspend_account',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plan Management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Plan Management', function (): void {
|
||||||
|
it('displays the plan index page', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
Plan::factory()->count(4)->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/plans')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Plans/Index')
|
||||||
|
->has('plans', 4)
|
||||||
|
->has('filters')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters plans by service type', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
Plan::factory()->create(['service_type' => 'vps']);
|
||||||
|
Plan::factory()->create(['service_type' => 'dedicated']);
|
||||||
|
Plan::factory()->create(['service_type' => 'vps']);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/plans?service_type=vps')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Plans/Index')
|
||||||
|
->has('plans', 2)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays the create plan page', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/plans/create')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Plans/Create')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores a new plan', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->post($this->adminUrl.'/plans', [
|
||||||
|
'name' => 'Starter VPS',
|
||||||
|
'slug' => 'starter-vps',
|
||||||
|
'description' => 'A basic VPS plan',
|
||||||
|
'service_type' => 'vps',
|
||||||
|
'price' => 9.99,
|
||||||
|
'billing_cycle' => 'monthly',
|
||||||
|
'features' => [
|
||||||
|
['key' => 'cpu', 'value' => '2 vCPU'],
|
||||||
|
['key' => 'ram', 'value' => '4GB'],
|
||||||
|
],
|
||||||
|
'stock_quantity' => 100,
|
||||||
|
'sort_order' => 1,
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('plans', [
|
||||||
|
'name' => 'Starter VPS',
|
||||||
|
'slug' => 'starter-vps',
|
||||||
|
'service_type' => 'vps',
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates required fields when storing a plan', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->post($this->adminUrl.'/plans', [])
|
||||||
|
->assertSessionHasErrors(['name', 'slug', 'service_type', 'price', 'billing_cycle']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays the edit plan page', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$plan = Plan::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/plans/'.$plan->id.'/edit')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Plans/Edit')
|
||||||
|
->has('plan')
|
||||||
|
->has('subscribersCount')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates an existing plan', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$plan = Plan::factory()->create(['name' => 'Old Name', 'slug' => 'old-name']);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->put($this->adminUrl.'/plans/'.$plan->id, [
|
||||||
|
'name' => 'New Name',
|
||||||
|
'slug' => 'new-name',
|
||||||
|
'service_type' => $plan->service_type,
|
||||||
|
'price' => 19.99,
|
||||||
|
'billing_cycle' => 'monthly',
|
||||||
|
'sort_order' => 0,
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect($plan->fresh())
|
||||||
|
->name->toBe('New Name')
|
||||||
|
->slug->toBe('new-name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('archives a plan on destroy', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$plan = Plan::factory()->create(['status' => 'active']);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->delete($this->adminUrl.'/plans/'.$plan->id)
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect($plan->fresh()->status)->toBe('inactive');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Service Management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Service Management', function (): void {
|
||||||
|
it('displays the service list page', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
Service::factory()->count(3)->create(['user_id' => $customer->id]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/services')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Services/Index')
|
||||||
|
->has('services.data', 3)
|
||||||
|
->has('filters')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a service detail page', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$service = Service::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/services/'.$service->id)
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Services/Show')
|
||||||
|
->has('service')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suspends a service', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$service = Service::factory()->create(['status' => 'active']);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->post($this->adminUrl.'/services/'.$service->id.'/suspend')
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect($service->fresh()->status)->toBe('suspended');
|
||||||
|
$this->assertDatabaseHas('audit_logs', [
|
||||||
|
'user_id' => $service->user_id,
|
||||||
|
'admin_id' => $admin->id,
|
||||||
|
'action' => 'suspend_service',
|
||||||
|
'resource_type' => 'service',
|
||||||
|
'resource_id' => $service->id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unsuspends a service', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$service = Service::factory()->suspended()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->post($this->adminUrl.'/services/'.$service->id.'/unsuspend')
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect($service->fresh()->status)->toBe('active');
|
||||||
|
$this->assertDatabaseHas('audit_logs', [
|
||||||
|
'action' => 'unsuspend_service',
|
||||||
|
'resource_id' => $service->id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('terminates a service', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$service = Service::factory()->create(['status' => 'active']);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->post($this->adminUrl.'/services/'.$service->id.'/terminate')
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$freshService = $service->fresh();
|
||||||
|
expect($freshService->status)->toBe('terminated');
|
||||||
|
expect($freshService->terminated_at)->not->toBeNull();
|
||||||
|
$this->assertDatabaseHas('audit_logs', [
|
||||||
|
'action' => 'terminate_service',
|
||||||
|
'resource_id' => $service->id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Invoice Management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Invoice Management', function (): void {
|
||||||
|
it('displays the invoice list page', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
Invoice::factory()->count(5)->create(['user_id' => $customer->id]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/invoices')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Invoices/Index')
|
||||||
|
->has('invoices.data', 5)
|
||||||
|
->has('filters')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters invoices by status', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
Invoice::factory()->count(2)->create(['user_id' => $customer->id, 'status' => 'paid']);
|
||||||
|
Invoice::factory()->count(3)->create(['user_id' => $customer->id, 'status' => 'draft']);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/invoices?status=paid')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Invoices/Index')
|
||||||
|
->has('invoices.data', 2)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an invoice detail page', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$invoice = Invoice::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/invoices/'.$invoice->id)
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Invoices/Show')
|
||||||
|
->has('invoice')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('voids an invoice', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$invoice = Invoice::factory()->create(['status' => 'sent']);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->post($this->adminUrl.'/invoices/'.$invoice->id.'/void')
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect($invoice->fresh()->status)->toBe('void');
|
||||||
|
$this->assertDatabaseHas('audit_logs', [
|
||||||
|
'user_id' => $invoice->user_id,
|
||||||
|
'admin_id' => $admin->id,
|
||||||
|
'action' => 'void_invoice',
|
||||||
|
'resource_type' => 'invoice',
|
||||||
|
'resource_id' => $invoice->id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Coupon Management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Coupon Management', function (): void {
|
||||||
|
it('displays the coupon index page', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
Coupon::factory()->count(3)->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/coupons')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Coupons/Index')
|
||||||
|
->has('coupons.data', 3)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays the create coupon page', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/coupons/create')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Coupons/Create')
|
||||||
|
->has('plans')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores a new coupon', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->post($this->adminUrl.'/coupons', [
|
||||||
|
'code' => 'SAVE20',
|
||||||
|
'type' => 'percentage',
|
||||||
|
'value' => 20,
|
||||||
|
'max_uses' => 100,
|
||||||
|
'expires_at' => now()->addMonth()->toDateTimeString(),
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('coupons', [
|
||||||
|
'code' => 'SAVE20',
|
||||||
|
'type' => 'percentage',
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates required fields when storing a coupon', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->post($this->adminUrl.'/coupons', [])
|
||||||
|
->assertSessionHasErrors(['code', 'type', 'value']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents duplicate coupon codes', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
Coupon::factory()->create(['code' => 'EXISTING']);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->post($this->adminUrl.'/coupons', [
|
||||||
|
'code' => 'EXISTING',
|
||||||
|
'type' => 'percentage',
|
||||||
|
'value' => 10,
|
||||||
|
])
|
||||||
|
->assertSessionHasErrors(['code']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays the edit coupon page', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$coupon = Coupon::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/coupons/'.$coupon->id.'/edit')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Coupons/Edit')
|
||||||
|
->has('coupon')
|
||||||
|
->has('plans')
|
||||||
|
->has('redemptions')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates an existing coupon', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$coupon = Coupon::factory()->create(['code' => 'OLD10', 'value' => 10]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->put($this->adminUrl.'/coupons/'.$coupon->id, [
|
||||||
|
'code' => 'NEW25',
|
||||||
|
'type' => 'fixed',
|
||||||
|
'value' => 25,
|
||||||
|
'max_uses' => 50,
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$fresh = $coupon->fresh();
|
||||||
|
expect($fresh->code)->toBe('NEW25');
|
||||||
|
expect((float) $fresh->value)->toBe(25.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deactivates a coupon on destroy', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$coupon = Coupon::factory()->create(['active' => true]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->delete($this->adminUrl.'/coupons/'.$coupon->id)
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect($coupon->fresh()->active)->toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Audit Logs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Audit Logs', function (): void {
|
||||||
|
it('displays the audit log index page', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
AuditLog::factory()->count(5)->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/audit-logs')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/AuditLogs/Index')
|
||||||
|
->has('auditLogs.data', 5)
|
||||||
|
->has('actions')
|
||||||
|
->has('filters')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters audit logs by action', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
AuditLog::factory()->count(2)->create(['action' => 'login']);
|
||||||
|
AuditLog::factory()->count(3)->create(['action' => 'payment_failed']);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/audit-logs?action=login')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/AuditLogs/Index')
|
||||||
|
->has('auditLogs.data', 2)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies customer access to audit logs', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->adminUrl.'/audit-logs')
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Settings
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Settings', function (): void {
|
||||||
|
it('displays the settings index page', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/settings')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Settings/Index')
|
||||||
|
->has('settings')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates general settings', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->put($this->adminUrl.'/settings', [
|
||||||
|
'group' => 'general',
|
||||||
|
'company_name' => 'EZSCALE Cloud',
|
||||||
|
'company_email' => 'hello@ezscale.cloud',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect(Setting::get('company_name'))->toBe('EZSCALE Cloud');
|
||||||
|
expect(Setting::get('company_email'))->toBe('hello@ezscale.cloud');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates billing settings', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->put($this->adminUrl.'/settings', [
|
||||||
|
'group' => 'billing',
|
||||||
|
'default_currency' => 'USD',
|
||||||
|
'grace_period_days' => 14,
|
||||||
|
'suspension_warning_days' => 5,
|
||||||
|
'auto_terminate_days' => 30,
|
||||||
|
'bandwidth_overage_rate' => 0.10,
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect(Setting::get('grace_period_days'))->toBe('14');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates settings update with invalid data', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->put($this->adminUrl.'/settings', [
|
||||||
|
'group' => 'general',
|
||||||
|
'company_email' => 'not-an-email',
|
||||||
|
])
|
||||||
|
->assertSessionHasErrors(['company_email']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies customer access to settings', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->adminUrl.'/settings')
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user