{{ state.status?.train?.origin?.name }} {{ format(departure) }}
-
{{ state.status?.train?.destination?.name }} diff --git a/resources/vue/helpers/DateRange.ts b/resources/vue/helpers/DateRange.ts new file mode 100644 index 000000000..4b9c51711 --- /dev/null +++ b/resources/vue/helpers/DateRange.ts @@ -0,0 +1,45 @@ +import {DateTime, DateTimeFormatOptions} from "luxon"; +import {DateTimeOptions, LocaleOptions} from "luxon/src/datetime"; +import {Dtm} from "./DateTime"; + +export class DtmRange { + dateTimeStart: Dtm; + dateTimeEnd: Dtm; + isSameDay: boolean = false; + + constructor(start: Dtm, end: Dtm) { + this.dateTimeStart = start; + this.dateTimeEnd = end; + } + + static fromISO(start: string, end: string, opts?: DateTimeOptions): DtmRange { + const startDtm: Dtm = new Dtm(start, opts); + const endDtm: Dtm = new Dtm(end, opts); + const startDate: string = start.substring(0, 10); + + const range = new DtmRange(startDtm, endDtm); + range.isSameDay = end.startsWith(startDate); + + return range; + } + + toLocaleDateString( + formatOpts?: DateTimeFormatOptions, + opts?: LocaleOptions, + ): string { + if (this.isSameDay) { + return this.dateTimeStart.toLocaleString(formatOpts, opts); + } + + return `${this.dateTimeStart.toLocaleString(formatOpts, opts)} - ${this.dateTimeEnd.toLocaleString(formatOpts, opts)}`; + } + + toLocaleDateTimeString( + formatOpts?: DateTimeFormatOptions, + opts?: LocaleOptions, + ): string { + formatOpts = formatOpts || DateTime.DATETIME_FULL; + + return `${this.dateTimeStart.toLocaleString(formatOpts, opts)} - ${this.dateTimeEnd.toLocaleString(formatOpts, opts)}`; + } +} diff --git a/resources/vue/helpers/DateTime.ts b/resources/vue/helpers/DateTime.ts new file mode 100644 index 000000000..445a215d5 --- /dev/null +++ b/resources/vue/helpers/DateTime.ts @@ -0,0 +1,41 @@ +import {DateTime, DateTimeFormatOptions} from "luxon"; +import {DateTimeOptions, LocaleOptions} from "luxon/src/datetime"; +import {getActiveLanguage} from "laravel-vue-i18n"; + +export class Dtm { + dateTime: DateTime; + + constructor(date: string, opts?: DateTimeOptions) { + const defaultOpts: DateTimeOptions = { + locale: this.getLocale(), + } + opts = {...defaultOpts, ...opts}; + + this.dateTime = DateTime.fromISO(date, opts); + } + + private getLocale(): string { + let locale: string = getActiveLanguage(); + + if (locale.startsWith('de')) { + return 'de'; + } + + if (locale === '') { + return 'en'; + } + + return locale; + } + + static fromISO(date: string, opts?: DateTimeOptions): Dtm { + return new Dtm(date, opts); + } + + toLocaleString( + formatOpts?: DateTimeFormatOptions, + opts?: LocaleOptions, + ): string { + return this.dateTime.toLocaleString(formatOpts, opts); + } +} diff --git a/routes/api.php b/routes/api.php index a5913050a..961ca5e62 100644 --- a/routes/api.php +++ b/routes/api.php @@ -30,6 +30,7 @@ use App\Http\Controllers\API\v1\TokenController; use App\Http\Controllers\API\v1\TransportController; use App\Http\Controllers\API\v1\TripController; +use App\Http\Controllers\API\v1\TrustedUserController; use App\Http\Controllers\API\v1\UserController; use App\Http\Controllers\API\v1\WebhookController; use App\Http\Controllers\API\v1\YearInReviewController; @@ -172,6 +173,7 @@ Route::apiResource('station', StationController::class); // currently admin/backend only Route::put('station/{oldStationId}/merge/{newStationId}', [StationController::class, 'merge']); // currently admin/backend only + Route::apiResource('user.trusted', TrustedUserController::class)->only(['index', 'store', 'destroy']); Route::apiResource('report', ReportController::class); Route::apiResource('operators', OperatorController::class)->only(['index']); }); diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json index ec495b9a2..432e21d3a 100644 --- a/storage/api-docs/api-docs.json +++ b/storage/api-docs/api-docs.json @@ -1514,6 +1514,11 @@ "mapProvider": { "type": "string", "nullable": true + }, + "friendCheckin": { + "type": "string", + "example": "forbidden", + "nullable": true } }, "type": "object" @@ -3447,8 +3452,8 @@ "tags": [ "Checkin" ], - "summary": "Create a checkin", - "operationId": "createTrainCheckin", + "summary": "Check in to a trip.", + "operationId": "createCheckin", "requestBody": { "required": true, "content": { @@ -3473,11 +3478,21 @@ "400": { "description": "Bad request" }, - "409": { - "description": "Checkin collision" - }, "401": { "description": "Unauthorized" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckinForbiddenWithUsersResponse" + } + } + } + }, + "409": { + "description": "Checkin collision" } }, "security": [ @@ -3646,6 +3661,154 @@ ] } }, + "/user/{user}/trusted": { + "get": { + "tags": [ + "User" + ], + "summary": "Get all trusted users for a user", + "description": "Get all trusted users for the current user or a specific user (admin only).", + "operationId": "trustedUserIndex", + "parameters": [ + { + "name": "user", + "in": "path", + "description": "ID of the user (or string 'self' for current user)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of trusted users" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Internal Server Error" + } + } + }, + "post": { + "tags": [ + "User" + ], + "summary": "Add a user to the trusted users for a user", + "description": "Add a user to the trusted users for the current user or a specific user (admin only).", + "operationId": "trustedUserStore", + "parameters": [ + { + "name": "user", + "in": "path", + "description": "ID of the user (or string 'self' for current user) who want's to trust.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "required": [ + "user_id" + ], + "properties": { + "userId": { + "type": "integer", + "example": "1" + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "example": "2024-07-28T00:00:00Z" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "User added to trusted users" + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/user/{user}/trusted/{trustedId}": { + "delete": { + "tags": [ + "User" + ], + "summary": "Remove a user from the trusted users for a user", + "description": "Remove a user from the trusted users for the current user or a specific user (admin only).", + "operationId": "trustedUserDestroy", + "parameters": [ + { + "name": "user", + "in": "path", + "description": "ID of the user (or string 'self' for current user)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trusted", + "in": "path", + "description": "ID of the trusted user", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "User removed from trusted users" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, "/settings/account": { "delete": { "tags": [ @@ -4569,6 +4732,16 @@ ], "example": "suburban" }, + "FriendCheckinSetting": { + "title": "FriendCheckinSetting", + "type": "string", + "enum": [ + "forbidden", + "friends", + "list" + ], + "example": "forbidden" + }, "CheckinSuccessResource": { "title": "CheckinResponse", "properties": { @@ -5039,6 +5212,20 @@ }, "type": "object" }, + "TrustedUserResource": { + "title": "TrustedUser", + "properties": { + "user": { + "$ref": "#/components/schemas/LightUserResource" + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "example": "2024-07-28T00:00:00Z" + } + }, + "type": "object" + }, "UserAuthResource": { "title": "UserAuth", "properties": { @@ -5138,8 +5325,6 @@ "description": "Fields for creating a train checkin", "properties": { "body": { - "title": "body", - "description": "Text that should be added to the post", "type": "string", "maxLength": 280, "example": "Meine erste Fahrt nach Knuffingen!", @@ -5152,76 +5337,81 @@ "$ref": "#/components/schemas/StatusVisibility" }, "eventId": { - "title": "eventId", "description": "Id of an event the status should be connected to", "type": "integer", + "example": "1", "nullable": true }, "toot": { - "title": "toot", "description": "Should this status be posted to mastodon?", "type": "boolean", "example": "false", "nullable": true }, "chainPost": { - "title": "chainPost", "description": "Should this status be posted to mastodon as a chained post?", "type": "boolean", "example": "false", "nullable": true }, "ibnr": { - "title": "ibnr", - "description": "If true, the `start` and `destination` properties can be supplied as an ibnr. Otherwise they\n * should be given as the Träwelling-ID. Default behavior is `false`.", + "description": "If true, the `start` and `destination` properties can be supplied as an ibnr. Otherwise they should be given as the Träwelling-ID. Default behavior is `false`.", "type": "boolean", "example": "true", "nullable": true }, "tripId": { - "title": "tripId", - "description": "The HAFAS tripId for the to be checked in train", - "example": "1|323306|1|80|17072022" + "description": "The tripId for the to be checked in train", + "type": "string", + "example": "b37ff515-22e1-463c-94de-3ad7964b5cb8", + "nullable": true }, "lineName": { - "title": "lineName", "description": "The line name for the to be checked in train", - "example": "S 4" + "type": "string", + "example": "S 4", + "nullable": true }, "start": { - "title": "start", "description": "The Station-ID of the starting point (see `ibnr`)", "type": "integer", "example": "8000191" }, "destination": { - "title": "destination", - "description": "The Station-ID of the destination (see `ibnr`)", + "description": "The Station-ID of the destination point (see `ibnr`)", "type": "integer", - "example": "8079045" + "example": "8000192" }, "departure": { - "title": "departure", "description": "Timestamp of the departure", + "type": "string", + "format": "date-time", "example": "2022-12-19T20:41:00+01:00" }, "arrival": { - "title": "arrival", "description": "Timestamp of the arrival", + "type": "string", + "format": "date-time", "example": "2022-12-19T20:42:00+01:00" }, "force": { - "title": "force", - "description": "If true, the checkin will be created, even if a colliding checkin exists. No points will be\n * awarded.", + "description": "If true, the checkin will be created, even if a colliding checkin exists. No points will be awarded.", "type": "boolean", "example": "false", "nullable": true + }, + "with": { + "description": "If set, the checkin will be created for all given users as well. The user creating the checkin must be allowed to checkin for the other users. Max. 10 users.", + "type": "array", + "items": { + "type": "integer", + "example": "1" + }, + "example": "[1, 2]", + "nullable": true } }, - "type": "object", - "xml": { - "name": "CheckinRequestBody" - } + "type": "object" }, "EventSuggestion": { "title": "EventSuggestion", @@ -5576,6 +5766,28 @@ "name": "Polyline" } }, + "CheckinForbiddenWithUsersResponse": { + "title": "CheckinForbiddenWithUsersResponse", + "properties": { + "message": { + "description": "example: {\"message\":\"You are not allowed to check in the following users: 1\",\"meta\":{\"invalidUsers\":[1]}}", + "example": "You are not allowed to check in the following users: 1,2" + }, + "meta": { + "properties": { + "invalidUsers": { + "type": "array", + "items": { + "type": "integer", + "example": "1" + } + } + }, + "type": "object" + } + }, + "type": "object" + }, "StatusTag": { "title": "StatusTag", "description": "StatusTag model", diff --git a/tests/ApiTestCase.php b/tests/ApiTestCase.php index 65671d6dd..463c4d2a0 100644 --- a/tests/ApiTestCase.php +++ b/tests/ApiTestCase.php @@ -3,7 +3,6 @@ namespace Tests; use App\Models\User; -use App\Providers\AuthServiceProvider; use Illuminate\Testing\TestResponse; use Laravel\Passport\Passport; @@ -17,8 +16,11 @@ public function setUp(): void { $this->artisan('passport:keys', ['--no-interaction' => true]); } - protected function actAsApiUserWithAllScopes(): void { - Passport::actingAs(User::factory()->create(), ['*']); + protected function actAsApiUserWithAllScopes(User $user = null): void { + if ($user === null) { + $user = User::factory()->create(); + } + Passport::actingAs($user, ['*']); } protected function assertUserResource(TestResponse $response): void { diff --git a/tests/Feature/APIv1/FriendCheckinTest.php b/tests/Feature/APIv1/FriendCheckinTest.php new file mode 100644 index 000000000..e2eaccede --- /dev/null +++ b/tests/Feature/APIv1/FriendCheckinTest.php @@ -0,0 +1,148 @@ +create(); + $this->assertTrue(Gate::forUser($user)->allows('checkin', $user)); + } + + public function testUserCanForbidFriendCheckins(): void { + $userToCheckin = User::factory(['friend_checkin' => FriendCheckinSetting::FORBIDDEN->value])->create(); + $user = User::factory()->create(); + $this->assertFalse(Gate::forUser($user)->allows('checkin', $userToCheckin)); + } + + public function testUserCanAllowCheckinsForFriends(): void { + $userToCheckin = User::factory(['friend_checkin' => FriendCheckinSetting::FRIENDS->value])->create(); + $user = User::factory()->create(); + + $this->assertFalse(Gate::forUser($user->refresh())->allows('checkin', $userToCheckin->refresh())); + + // Create a follow relationship between the two users (following each other = friends) + FollowController::createOrRequestFollow($user, $userToCheckin); + FollowController::createOrRequestFollow($userToCheckin, $user); + + $this->assertTrue(Gate::forUser($user->refresh())->allows('checkin', $userToCheckin->refresh())); + + // check that there are currently no checkins + $this->assertDatabaseCount('train_checkins', 0); + + // check in both users + $trip = Trip::factory()->create(); + + $this->actAsApiUserWithAllScopes($user); + $response = $this->postJson( + uri: '/api/v1/trains/checkin', + data: [ + 'tripId' => $trip->trip_id, + 'lineName' => $trip->linename, + 'start' => $trip->originStation->id, + 'departure' => $trip->departure, + 'destination' => $trip->destinationStation->id, + 'arrival' => $trip->arrival, + 'with' => [ + $userToCheckin->id + ] + ], + ); + $response->assertCreated(); + + $this->assertDatabaseHas('train_checkins', ['user_id' => $user->id, 'trip_id' => $trip->trip_id]); + $this->assertDatabaseHas('train_checkins', ['user_id' => $userToCheckin->id, 'trip_id' => $trip->trip_id]); + + $notification = $userToCheckin->refresh()->notifications->where('type', YouHaveBeenCheckedIn::class)->last(); + $this->assertStringContainsString($user->username, YouHaveBeenCheckedIn::getLead($notification->data)); + $this->assertStringContainsString($trip->originStation->name, YouHaveBeenCheckedIn::getNotice($notification->data)); + $this->assertStringContainsString($userToCheckin->statuses->last()->id, YouHaveBeenCheckedIn::getLink($notification->data)); + } + + public function testUserCanAllowCheckinsForTrustedUsers(): void { + $userToCheckin = User::factory(['friend_checkin' => FriendCheckinSetting::LIST->value])->create(); + $user = User::factory()->create(); + + $this->assertFalse(Gate::forUser($user->fresh())->allows('checkin', $userToCheckin->fresh())); + + // Create a trusted relationship between the two users + $this->actAsApiUserWithAllScopes($userToCheckin); + $response = $this->postJson( + uri: "/api/v1/user/{$userToCheckin->id}/trusted", + data: ['userId' => $user->id] + ); + $response->assertCreated(); + + $this->assertTrue(Gate::forUser($user->fresh())->allows('checkin', $userToCheckin->fresh())); + } + + public function testUserCannotCheckinMoreThen10Users(): void { + $usersToCheckin = User::factory()->count(11)->create(); + $user = User::factory()->create(); + + $trip = Trip::factory()->create(); + + $this->actAsApiUserWithAllScopes($user); + $response = $this->postJson( + uri: '/api/v1/trains/checkin', + data: [ + 'tripId' => $trip->trip_id, + 'lineName' => $trip->linename, + 'start' => $trip->originStation->id, + 'departure' => $trip->departure, + 'destination' => $trip->destinationStation->id, + 'arrival' => $trip->arrival, + 'with' => $usersToCheckin->pluck('id')->toArray() + ], + ); + $response->assertStatus(422); + $response->assertJsonValidationErrors('with'); + } + + public function testErrorResponseShouldContainForbiddenUsers(): void { + $forbiddenUser = User::factory()->create(['friend_checkin' => FriendCheckinSetting::FORBIDDEN->value]); + $allowedUser = User::factory()->create(['friend_checkin' => FriendCheckinSetting::FRIENDS->value]); + $user = User::factory()->create(); + $this->actAsApiUserWithAllScopes($user); + + Follow::create(['user_id' => $user->id, 'follow_id' => $allowedUser->id]); + Follow::create(['user_id' => $allowedUser->id, 'follow_id' => $user->id]); + + $trip = Trip::factory()->create(); + + $response = $this->postJson( + uri: '/api/v1/trains/checkin', + data: [ + 'tripId' => $trip->trip_id, + 'lineName' => $trip->linename, + 'start' => $trip->originStation->id, + 'departure' => $trip->departure, + 'destination' => $trip->destinationStation->id, + 'arrival' => $trip->arrival, + 'with' => [ + $forbiddenUser->id, + $allowedUser->id + ] + ], + ); + $response->assertStatus(403); + $response->assertJsonStructure(['message', 'meta' => ['invalidUsers']]); + $this->assertContains($forbiddenUser->id, $response->json('meta.invalidUsers')); + $this->assertNotContains($allowedUser->id, $response->json('meta.invalidUsers')); + } +} + diff --git a/tests/Feature/APIv1/TrustedUserTest.php b/tests/Feature/APIv1/TrustedUserTest.php new file mode 100644 index 000000000..899021a7f --- /dev/null +++ b/tests/Feature/APIv1/TrustedUserTest.php @@ -0,0 +1,122 @@ +create(); + $trustedUser = User::factory()->count(12)->create(); + $this->actAsApiUserWithAllScopes($user); + + foreach ($trustedUser as $userToTrust) { + $response = $this->postJson("/api/v1/user/self/trusted", ['userId' => $userToTrust->id]); + $response->assertCreated(); + } + + // list trusted users + $response = $this->getJson("/api/v1/user/self/trusted"); + $response->assertOk(); + $response->assertJsonCount(10, 'data'); + $response->assertJsonStructure([ + 'data', + 'links' => ['first', 'last', 'prev', 'next'], + 'meta' => ['path', 'per_page', 'next_cursor', 'prev_cursor'], + ]); + + //try next cursor + $nextCursorResponse = $this->getJson($response->json('links.next')); + $nextCursorResponse->assertOk(); + + $nextCursorResponse->dump(); + //TODO: why isn't the cursor working? Every request is showing from the beginning. + } + + public function testStoreAndDeleteTrustedUserForYourself(): void { + $user = User::factory()->create(); + $trustedUser = User::factory()->create(); + $this->actAsApiUserWithAllScopes($user); + + // trust user + $response = $this->postJson("/api/v1/user/{$user->id}/trusted", [ + 'userId' => $trustedUser->id, + 'expiresAt' => now()->addDay()->toIso8601String(), + ]); + $response->assertCreated(); + $this->assertDatabaseHas('trusted_users', ['user_id' => $user->id, 'trusted_id' => $trustedUser->id]); + + // list trusted users + $response = $this->getJson("/api/v1/user/{$user->id}/trusted"); + $response->assertOk(); + $response->assertJsonCount(1, 'data'); + $response->assertJsonFragment(['id' => $trustedUser->id]); + + // test, that the cleanup script does not delete the trusted user + $this->assertDatabaseCount('trusted_users', 1); + $this->assertEquals(0, $this->artisan('app:clean-db:trusted-user')); + $this->assertDatabaseCount('trusted_users', 1); + + $this->travel(2)->days(); + + // should not list expired trusted users, even if in database. + $response = $this->getJson("/api/v1/user/{$user->id}/trusted"); + $response->assertOk(); + $response->assertJsonCount(0, 'data'); + $response->assertJsonMissing(['id' => $trustedUser->id]); + + // now the cleanup script should delete the trusted user + $this->assertDatabaseCount('trusted_users', 1); + $this->assertEquals(0, $this->artisan('app:clean-db:trusted-user')); + $this->assertDatabaseCount('trusted_users', 0); + } + + public function testStoreAndDeleteTrustedUserForOtherUsersAsNonAdmin(): void { + $user = User::factory()->create(); + $truster = User::factory()->create(); + $trustedUser = User::factory()->create(); + $this->actAsApiUserWithAllScopes($user); + + // trust user + $response = $this->postJson("/api/v1/user/{$truster->id}/trusted", ['userId' => $trustedUser->id]); + $response->assertForbidden(); + + // list trusted users + $response = $this->getJson("/api/v1/user/{$truster->id}/trusted"); + $response->assertForbidden(); + + // untrust user + $response = $this->deleteJson("/api/v1/user/{$truster->id}/trusted/{$trustedUser->id}"); + $response->assertForbidden(); + } + + public function testStoreAndDeleteTrustedUserForOtherUsersAsAdmin(): void { + $user = User::factory()->create()->assignRole('admin'); + $truster = User::factory()->create(); + $trustedUser = User::factory()->create(); + $this->actAsApiUserWithAllScopes($user); + + // trust user + $response = $this->postJson("/api/v1/user/{$truster->id}/trusted", ['userId' => $trustedUser->id]); + $response->assertCreated(); + $this->assertDatabaseHas('trusted_users', ['user_id' => $truster->id, 'trusted_id' => $trustedUser->id]); + + // list trusted users + $response = $this->getJson("/api/v1/user/{$truster->id}/trusted"); + $response->assertOk(); + $response->assertJsonCount(1, 'data'); + $response->assertJsonFragment(['id' => $trustedUser->id]); + + // untrust user + $response = $this->deleteJson("/api/v1/user/{$truster->id}/trusted/{$trustedUser->id}"); + $response->assertNoContent(); + $this->assertDatabaseMissing('trusted_users', ['user_id' => $truster->id, 'trusted_id' => $trustedUser->id]); + } +} + diff --git a/tests/Feature/Profile/UserModelRelationshipTest.php b/tests/Feature/Profile/UserModelRelationshipTest.php new file mode 100644 index 000000000..d2e5ac1d9 --- /dev/null +++ b/tests/Feature/Profile/UserModelRelationshipTest.php @@ -0,0 +1,35 @@ +create(); + $follower = User::factory()->create(); + FollowController::createOrRequestFollow($follower, $user); + + $user->refresh(); + + $this->assertCount(1, $user->userFollowers); + $this->assertEquals($follower->id, $user->userFollowers->first()->id); + } + + public function testFollowingRelationship(): void { + $user = User::factory()->create(); + $following = User::factory()->create(); + FollowController::createOrRequestFollow($user, $following); + + $user->refresh(); + + $this->assertCount(1, $user->userFollowings); + $this->assertEquals($following->id, $user->userFollowings->first()->id); + } +} diff --git a/tests/Unit/Services/ReportServiceTest.php b/tests/Unit/Services/ReportServiceTest.php index 0fea1a859..583854999 100644 --- a/tests/Unit/Services/ReportServiceTest.php +++ b/tests/Unit/Services/ReportServiceTest.php @@ -12,7 +12,7 @@ class ReportServiceTest extends UnitTestCase { /** - * @dataProvider testCheckStringProvider + * @dataProvider checkStringProvider */ public function testCheckString(array $expected, string $haystack): void { $reportService = new ReportService(); @@ -26,7 +26,7 @@ public function testCheckString(array $expected, string $haystack): void { /** - * @dataProvider testCheckStringProvider + * @dataProvider checkStringProvider */ public function testCheckAndReport(array $expected, string $haystack): void { $repository = $this->mock(ReportRepository::class); @@ -50,7 +50,7 @@ public function testCheckAndReport(array $expected, string $haystack): void { $reportService->checkAndReport($haystack, ReportableSubject::TRIP, 1); } - public static function testCheckStringProvider(): array { + public static function checkStringProvider(): array { return [ 'match first word' => [ ['auto'],