diff --git a/tests/Unit/Filters/ScheduleStatusFilterTest.php b/tests/Unit/Filters/ScheduleStatusFilterTest.php new file mode 100644 index 00000000..88cb3caf --- /dev/null +++ b/tests/Unit/Filters/ScheduleStatusFilterTest.php @@ -0,0 +1,103 @@ +inTheFuture()->create(); + Schedule::factory()->inProgress()->create(); + Schedule::factory()->inThePast()->create(); + + $query = Schedule::query(); + + (new ScheduleStatusFilter)($query, ScheduleStatusEnum::upcoming->value, 'status'); + + expect($query->get()) + ->toHaveCount(1) + ->first()->id->toBe($upcoming->id); +}); + +it('filters by an enum instance', function () { + Schedule::factory()->inTheFuture()->create(); + $inProgress = Schedule::factory()->inProgress()->create(); + Schedule::factory()->inThePast()->create(); + + $query = Schedule::query(); + + (new ScheduleStatusFilter)($query, ScheduleStatusEnum::in_progress, 'status'); + + expect($query->get()) + ->toHaveCount(1) + ->first()->id->toBe($inProgress->id); +}); + +it('leaves the query untouched when given an invalid value', function () { + Schedule::factory()->inTheFuture()->create(); + Schedule::factory()->inProgress()->create(); + Schedule::factory()->inThePast()->create(); + + $query = Schedule::query(); + + (new ScheduleStatusFilter)($query, 999, 'status'); + + expect($query->get())->toHaveCount(3); +}); + +it('leaves the query untouched when given a non-numeric string', function () { + Schedule::factory()->inTheFuture()->create(); + Schedule::factory()->inProgress()->create(); + + $query = Schedule::query(); + + (new ScheduleStatusFilter)($query, 'not-a-status', 'status'); + + expect($query->get())->toHaveCount(2); +}); + +it('filters by multiple statuses', function () { + Schedule::factory()->inTheFuture()->create(); + $inProgress = Schedule::factory()->inProgress()->create(); + $completed = Schedule::factory()->completed()->create(); + + $query = Schedule::query(); + + (new ScheduleStatusFilter)($query, [ + ScheduleStatusEnum::in_progress->value, + ScheduleStatusEnum::complete->value, + ], 'status'); + + expect($query->pluck('id')->all()) + ->toHaveCount(2) + ->toContain($inProgress->id, $completed->id); +}); + +it('ignores invalid entries in a multi-value filter', function () { + Schedule::factory()->inTheFuture()->create(); + $inProgress = Schedule::factory()->inProgress()->create(); + Schedule::factory()->inThePast()->create(); + + $query = Schedule::query(); + + (new ScheduleStatusFilter)($query, [ + ScheduleStatusEnum::in_progress->value, + 999, + 'bogus', + ], 'status'); + + expect($query->get()) + ->toHaveCount(1) + ->first()->id->toBe($inProgress->id); +}); + +it('applies no status constraint when every multi-value entry is invalid', function () { + Schedule::factory()->inTheFuture()->create(); + Schedule::factory()->inProgress()->create(); + Schedule::factory()->inThePast()->create(); + + $query = Schedule::query(); + + (new ScheduleStatusFilter)($query, [999, 'bogus'], 'status'); + + expect($query->get())->toHaveCount(3); +}); diff --git a/tests/Unit/Http/Middleware/SetAppLocaleTest.php b/tests/Unit/Http/Middleware/SetAppLocaleTest.php new file mode 100644 index 00000000..25017774 --- /dev/null +++ b/tests/Unit/Http/Middleware/SetAppLocaleTest.php @@ -0,0 +1,57 @@ + [ + 'en' => 'English', + 'fr' => 'Français', + 'de' => 'Deutsch', + ]]); + + app()->setLocale('en'); +}); + +it('leaves the app locale untouched when no user is authenticated', function () { + $request = Request::create('/'); + + (new SetAppLocale)->handle($request, fn () => null); + + expect(app()->getLocale())->toBe('en'); +}); + +it('uses the authenticated user\'s preferred locale when available', function () { + $user = User::factory()->create(['preferred_locale' => 'fr']); + $request = Request::create('/'); + $request->setUserResolver(fn () => $user); + + (new SetAppLocale)->handle($request, fn () => null); + + expect(app()->getLocale())->toBe('fr'); +}); + +it('falls back to the request preferred language when the user has no preferred locale', function () { + $user = User::factory()->create(['preferred_locale' => null]); + $request = Request::create('/', 'GET', server: ['HTTP_ACCEPT_LANGUAGE' => 'de-DE,de;q=0.9,en;q=0.8']); + $request->setUserResolver(fn () => $user); + + (new SetAppLocale)->handle($request, fn () => null); + + expect(app()->getLocale())->toBe('de'); +}); + +it('passes the request through to the next middleware', function () { + $request = Request::create('/'); + $called = false; + + (new SetAppLocale)->handle($request, function ($passed) use (&$called, $request) { + expect($passed)->toBe($request); + $called = true; + + return 'ok'; + }); + + expect($called)->toBeTrue(); +}); diff --git a/tests/Unit/Listeners/WebhookCallEventListenerTest.php b/tests/Unit/Listeners/WebhookCallEventListenerTest.php new file mode 100644 index 00000000..ebc86b2f --- /dev/null +++ b/tests/Unit/Listeners/WebhookCallEventListenerTest.php @@ -0,0 +1,94 @@ +url, + payload: ['event' => WebhookEventEnum::component_created->value, 'body' => []], + headers: [], + meta: [ + 'subscription_id' => $subscription->id, + 'event' => WebhookEventEnum::component_created->value, + ], + tags: [], + attempt: $attempt, + response: $response, + errorType: null, + errorMessage: null, + uuid: 'test-uuid', + transferStats: $stats, + ); +} + +it('records a successful webhook attempt', function () { + $subscription = WebhookSubscription::factory()->create(); + + $event = makeWebhookEvent( + WebhookCallSucceededEvent::class, + $subscription, + response: new Response(200), + stats: new TransferStats( + new GuzzleHttp\Psr7\Request('POST', $subscription->url), + new Response(200), + 0.42, + ), + ); + + app(WebhookCallEventListener::class)->handle($event); + + $attempt = WebhookAttempt::query()->firstOrFail(); + + expect($attempt) + ->subscription_id->toBe($subscription->id) + ->event->toBe(WebhookEventEnum::component_created) + ->attempt->toBe(1) + ->response_code->toBe(200) + ->transfer_time->toEqual(0.42); + + expect(json_decode($attempt->payload, true)) + ->toBe(['event' => WebhookEventEnum::component_created->value, 'body' => []]); +}); + +it('records a failed webhook attempt without response or transfer stats', function () { + $subscription = WebhookSubscription::factory()->create(); + + $event = makeWebhookEvent( + WebhookCallFailedEvent::class, + $subscription, + attempt: 3, + ); + + app(WebhookCallEventListener::class)->handle($event); + + $attempt = WebhookAttempt::query()->firstOrFail(); + + expect($attempt) + ->subscription_id->toBe($subscription->id) + ->attempt->toBe(3) + ->response_code->toBeNull() + ->transfer_time->toBeNull(); +}); + +it('recalculates the subscription success rate after recording an attempt', function () { + $subscription = WebhookSubscription::factory()->create(['success_rate_24h' => 0]); + + $event = makeWebhookEvent( + WebhookCallSucceededEvent::class, + $subscription, + response: new Response(204), + ); + + app(WebhookCallEventListener::class)->handle($event); + + expect($subscription->fresh()->success_rate_24h)->toBe('100.00%'); +}); diff --git a/tests/Unit/Models/WebhookAttemptTest.php b/tests/Unit/Models/WebhookAttemptTest.php new file mode 100644 index 00000000..68959c8d --- /dev/null +++ b/tests/Unit/Models/WebhookAttemptTest.php @@ -0,0 +1,45 @@ +make(['response_code' => $code]); + + expect($attempt->isSuccess())->toBeTrue(); +})->with([200, 201, 204, 299]); + +it('is not successful when the response code is outside 2xx', function (?int $code) { + $attempt = WebhookAttempt::factory()->make(['response_code' => $code]); + + expect($attempt->isSuccess())->toBeFalse(); +})->with([100, 199, 300, 400, 500, null]); + +it('scopes to successful attempts', function () { + $subscription = WebhookSubscription::factory()->create(); + WebhookAttempt::factory()->count(2)->create(['subscription_id' => $subscription->id, 'response_code' => 200]); + WebhookAttempt::factory()->create(['subscription_id' => $subscription->id, 'response_code' => 500]); + WebhookAttempt::factory()->create(['subscription_id' => $subscription->id, 'response_code' => 404]); + + expect(WebhookAttempt::query()->whereSuccessful()->count())->toBe(2); +}); + +it('prunes attempts older than the configured retention window', function () { + config(['cachet.webhooks.logs.prune_logs_after_days' => 7]); + $subscription = WebhookSubscription::factory()->create(); + + $fresh = WebhookAttempt::factory()->create([ + 'subscription_id' => $subscription->id, + 'created_at' => Carbon::now()->subDays(1), + ]); + $stale = WebhookAttempt::factory()->create([ + 'subscription_id' => $subscription->id, + 'created_at' => Carbon::now()->subDays(30), + ]); + + $prunable = (new WebhookAttempt)->prunable()->pluck('id'); + + expect($prunable->all())->toBe([$stale->id]) + ->and($prunable)->not->toContain($fresh->id); +});