Stage 15 — PWA complete: install prompt + Web Push notifications

Dependency:
- minishlink/web-push v10 (VAPID JWT + aes128gcm payload encryption)
- Dockerfile: add curl, mbstring, gmp extensions (web-push needs ext-curl)

VAPID:
- config/webpush.php from env; `php artisan push:vapid` generates keypair
- Shared platform keypair; .env.example has empty placeholders

Schema:
- push_subscriptions (user/company, endpoint unique, p256dh, auth, encoding)

WebPushService:
- send / sendToUser / dispatch via WebPush::flush
- Auto-prunes subscriptions reported expired (404/410)

Subscribe flow:
- POST /push/subscribe + /push/unsubscribe (auth, tenant)
- Tenant panel JS subscribes after SW registration with VAPID public key

Service worker (/sw.js):
- Cache v2, push listener → showNotification, notificationclick → focus/open

Install prompt:
- Floating "Instalează aplicația" button wired to beforeinstallprompt

Staff push:
- WorkOrder master_id change → push to assigned mechanic
- Settings "Test notificare push" action

Tests (6 new):
- subscribe stores + upserts; requires auth (401); validation (422);
  service configured; sendToUser with no subs returns zero

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 05:11:18 +00:00
parent e48ef1b755
commit c413004930
16 changed files with 840 additions and 8 deletions
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace Tests\Feature;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Tenant\PushSubscription;
use App\Models\Tenant\User;
use App\Services\Notifications\WebPushService;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PushSubscriptionTest extends TestCase
{
use RefreshDatabase;
public function test_subscribe_stores_subscription_for_user(): void
{
[$company, $user] = $this->makeUser('sub1');
$this->actingAs($user)
->postJson('http://sub1.service.mir.md/push/subscribe', [
'endpoint' => 'https://push.example.com/abc123',
'keys' => ['p256dh' => 'PUBLICKEY', 'auth' => 'AUTHTOKEN'],
'contentEncoding' => 'aes128gcm',
])
->assertOk()
->assertJson(['ok' => true]);
$this->assertDatabaseHas('push_subscriptions', [
'endpoint' => 'https://push.example.com/abc123',
'user_id' => $user->id,
'public_key' => 'PUBLICKEY',
'content_encoding' => 'aes128gcm',
]);
}
public function test_subscribe_upserts_on_duplicate_endpoint(): void
{
[$company, $user] = $this->makeUser('sub2');
$payload = [
'endpoint' => 'https://push.example.com/dup',
'keys' => ['p256dh' => 'KEY1', 'auth' => 'AUTH1'],
];
$this->actingAs($user)->postJson('http://sub2.service.mir.md/push/subscribe', $payload)->assertOk();
$payload['keys']['p256dh'] = 'KEY2';
$this->actingAs($user)->postJson('http://sub2.service.mir.md/push/subscribe', $payload)->assertOk();
$this->assertEquals(1, PushSubscription::where('endpoint', 'https://push.example.com/dup')->count());
$this->assertEquals('KEY2', PushSubscription::where('endpoint', 'https://push.example.com/dup')->value('public_key'));
}
public function test_subscribe_requires_auth(): void
{
$this->makeUser('sub3');
$this->postJson('http://sub3.service.mir.md/push/subscribe', [
'endpoint' => 'https://push.example.com/noauth',
'keys' => ['p256dh' => 'X', 'auth' => 'Y'],
])->assertStatus(401);
}
public function test_validation_rejects_missing_keys(): void
{
[$company, $user] = $this->makeUser('sub4');
$this->actingAs($user)
->postJson('http://sub4.service.mir.md/push/subscribe', [
'endpoint' => 'https://push.example.com/x',
])
->assertStatus(422);
}
public function test_webpush_service_reports_configured(): void
{
$svc = app(WebPushService::class);
$this->assertTrue($svc->configured());
$this->assertNotEmpty($svc->publicKey());
}
public function test_send_to_user_with_no_subscriptions_returns_zero(): void
{
[$company, $user] = $this->makeUser('sub5');
$r = app(WebPushService::class)->sendToUser($user->id, 'T', 'B');
$this->assertEquals(0, $r['sent']);
$this->assertEquals(0, $r['pruned']);
}
private function makeUser(string $slug): array
{
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
$company = Company::create([
'plan_id' => $plan->id,
'slug' => $slug,
'name' => ucfirst($slug),
'status' => 'active',
]);
app(TenantManager::class)->setCurrent($company);
$user = User::create([
'company_id' => $company->id,
'name' => 'Push User',
'email' => $slug . '@example.com',
'password' => bcrypt('secret'),
'role' => 'admin',
'status' => 'active',
]);
return [$company, $user];
}
}