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:
@@ -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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user