Add notification inbox, toasts and alert scanner for orders, prints and filament
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 28s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Hickmeister
2026-10-11 21:02:35 +01:00
co-authored by Claude Sonnet 5.5
parent 04be9802dc
commit ab1fdb961f
17 changed files with 805 additions and 1 deletions
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Notifications\DashboardAlert;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Tests\TestCase;
class NotificationInboxTest extends TestCase
{
use LazilyRefreshDatabase;
public function test_guests_cannot_read_notifications(): void
{
$this->getJson(route('notifications.index'))->assertUnauthorized();
}
public function test_user_sees_only_their_notifications_and_can_mark_them_read(): void
{
$user = User::factory()->create();
$other = User::factory()->create();
$user->notify(new DashboardAlert('order', 'New Etsy order #1', 'Buyer · GBP 5.00', 'success', route('shipping')));
$other->notify(new DashboardAlert('order', 'Private', 'Not yours', 'info'));
$response = $this->actingAs($user)->getJson(route('notifications.index'))->assertOk()
->assertJsonPath('unread', 1)->assertJsonCount(1, 'items')
->assertJsonPath('items.0.title', 'New Etsy order #1')->assertJsonPath('items.0.level', 'success');
$id = $response->json('items.0.id');
$this->actingAs($other)->postJson(route('notifications.read', $id))->assertOk();
$this->assertSame(1, $user->unreadNotifications()->count());
$this->actingAs($user)->postJson(route('notifications.read', $id))->assertOk();
$this->assertSame(0, $user->unreadNotifications()->count());
}
public function test_mark_all_read_clears_the_unread_count(): void
{
$user = User::factory()->create();
$user->notify(new DashboardAlert('print', 'One', 'a'));
$user->notify(new DashboardAlert('print', 'Two', 'b'));
$this->actingAs($user)->postJson(route('notifications.read-all'))->assertOk();
$this->actingAs($user)->getJson(route('notifications.index'))->assertJsonPath('unread', 0);
}
public function test_pages_include_the_notification_bell(): void
{
$this->actingAs(User::factory()->create())->get(route('dashboard'))->assertOk()
->assertSee('data-notifications-toggle', false)->assertSee('data-toast-region', false);
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
namespace Tests\Feature;
use App\Models\EtsyConnection;
use App\Models\User;
use App\Services\AlertScanner;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class NotificationScannerTest extends TestCase
{
use LazilyRefreshDatabase;
/** @var array<int, array<string, mixed>> */
private array $receipts = [];
/** @var array<string, mixed> */
private array $live = [];
/** @var array<int, array<string, mixed>> */
private array $spools = [];
private User $admin;
private User $member;
protected function setUp(): void
{
parent::setUp();
$this->admin = User::factory()->create(['role' => 'admin']);
$this->member = User::factory()->create(['role' => 'user']);
config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret']);
config()->set('services.bambuddy', ['url' => 'https://3d.example.test', 'api_key' => 'key']);
EtsyConnection::create(['shop_id' => 42, 'shop_name' => 'HickLab3D', 'access_token' => 'token', 'refresh_token' => 'refresh', 'expires_at' => now()->addHour()]);
Http::preventStrayRequests();
Http::fake(fn (Request $request) => match (true) {
str_contains($request->url(), '/receipts') => Http::response(['results' => $this->receipts]),
str_ends_with($request->url(), '/api/v1/printers/') => Http::response([['id' => 1, 'name' => 'Printy', 'model' => 'X1C']]),
str_ends_with($request->url(), '/printers/1/status') => Http::response($this->live),
str_contains($request->url(), '/inventory/spools') => Http::response($this->spools),
default => Http::response([], 404),
});
}
private function scan(): int
{
Cache::flush();
return app(AlertScanner::class)->scan();
}
/** @return array<string, mixed> */
private function receipt(int $id, ?int $shipBy = null): array
{
return [
'receipt_id' => $id, 'name' => 'Buyer '.$id, 'grandtotal' => ['amount' => 1250, 'divisor' => 100, 'currency_code' => 'GBP'],
'transactions' => $shipBy ? [['expected_ship_date' => $shipBy]] : [],
];
}
public function test_new_orders_notify_admins_only_and_the_first_scan_is_silent(): void
{
$this->receipts = [$this->receipt(1)];
$this->assertSame(0, $this->scan());
$this->receipts = [$this->receipt(2), $this->receipt(1)];
$this->assertSame(1, $this->scan());
$this->assertSame(0, $this->scan());
$notification = $this->admin->notifications()->first();
$this->assertSame('New Etsy order #2', $notification->data['title']);
$this->assertSame('Buyer 2 · GBP 12.50', $notification->data['body']);
$this->assertCount(0, $this->member->notifications);
}
public function test_orders_due_within_a_day_and_overdue_each_notify_once(): void
{
$this->receipts = [$this->receipt(7, time() + 3600 * 12)];
$this->assertSame(1, $this->scan());
$this->assertSame(0, $this->scan());
$this->assertSame('Order #7 is due to ship within 24 hours', $this->admin->notifications()->first()->data['title']);
$this->receipts = [$this->receipt(7, time() - 60)];
$this->assertSame(1, $this->scan());
$this->assertSame(0, $this->scan());
$this->assertContains('Order #7 is overdue to ship', $this->admin->notifications->pluck('data.title')->all());
$this->receipts = [$this->receipt(8, time() + 3600 * 72)];
$this->scan();
$this->assertSame(3, $this->admin->notifications()->count());
$this->assertContains('New Etsy order #8', $this->admin->fresh()->notifications->pluck('data.title')->all());
}
public function test_print_milestones_completion_and_errors_notify_everyone_once(): void
{
$this->live = ['connected' => true, 'state' => 'RUNNING', 'subtask_name' => 'Vase', 'progress' => 30.0, 'remaining_time' => 90, 'hms_errors' => []];
$this->assertSame(0, $this->scan());
$this->live['progress'] = 55.0;
$this->assertSame(1, $this->scan());
$this->assertSame(0, $this->scan());
$this->assertSame('Printy · Vase is 50% complete', $this->member->notifications()->first()->data['title']);
$this->live['progress'] = 80.0;
$this->assertSame(1, $this->scan());
$this->live['hms_errors'] = [['description' => 'Filament tangled']];
$this->assertSame(1, $this->scan());
$this->assertSame(0, $this->scan());
$this->live = ['connected' => true, 'state' => 'FINISH', 'subtask_name' => 'Vase', 'progress' => 100.0, 'hms_errors' => []];
$this->assertSame(1, $this->scan());
$this->assertSame(0, $this->scan());
$this->assertContains('Printy · Vase is 100% complete', $this->admin->notifications->pluck('data.title')->all());
}
public function test_failed_prints_notify_as_errors(): void
{
$this->live = ['connected' => true, 'state' => 'RUNNING', 'subtask_name' => 'Vase', 'progress' => 10.0, 'hms_errors' => []];
$this->scan();
$this->live['state'] = 'FAILED';
$this->assertSame(1, $this->scan());
$this->assertSame(0, $this->scan());
$notification = $this->admin->notifications()->first();
$this->assertSame('danger', $notification->data['level']);
$this->assertSame('Printy · Vase failed', $notification->data['title']);
}
public function test_low_filament_notifies_once_until_the_spool_is_refilled(): void
{
$this->spools = [
['id' => 1, 'color_name' => 'Azure', 'material' => 'ABS', 'brand' => 'Bambu Lab', 'label_weight' => 1000, 'weight_used' => 850, 'archived_at' => null],
['id' => 2, 'color_name' => 'Black', 'material' => 'PLA', 'label_weight' => 1000, 'weight_used' => 100, 'archived_at' => null],
['id' => 3, 'color_name' => 'Old', 'material' => 'PLA', 'label_weight' => 1000, 'weight_used' => 990, 'archived_at' => '2026-01-01T00:00:00'],
];
$this->assertSame(1, $this->scan());
$this->assertSame(0, $this->scan());
$notification = $this->admin->notifications()->first();
$this->assertSame('Low filament: Azure ABS', $notification->data['title']);
$this->assertSame('Bambu Lab · 15% (about 150 g) left', $notification->data['body']);
$this->spools[0]['weight_used'] = 100;
$this->scan();
$this->spools[0]['weight_used'] = 900;
$this->assertSame(1, $this->scan());
}
}