From 02f998fc383aff65a34c15eda861770bd7cd5d35 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Thu, 14 Dec 2023 00:54:58 +0100 Subject: [PATCH] API: Added user info and shifts by user and angeltype, simplified neededAngelTypes --- config/routes.php | 8 +- resources/api/openapi.yml | 143 ++++++++++++- .../Api/Resources/UserDetailResource.php | 23 ++ src/Controllers/Api/ShiftsController.php | 90 ++++++-- src/Controllers/Api/UsersController.php | 26 +++ .../Controllers/Api/ShiftsControllerTest.php | 197 ++++++++++++------ .../Controllers/Api/UsersControllerTest.php | 51 +++++ 7 files changed, 453 insertions(+), 85 deletions(-) create mode 100644 src/Controllers/Api/Resources/UserDetailResource.php create mode 100644 src/Controllers/Api/UsersController.php create mode 100644 tests/Unit/Controllers/Api/UsersControllerTest.php diff --git a/config/routes.php b/config/routes.php index 25d3bc69..ec0c558f 100644 --- a/config/routes.php +++ b/config/routes.php @@ -123,10 +123,16 @@ $route->addGroup( $route->get('/openapi', 'Api\IndexController@openApiV0'); $route->get('/angeltypes', 'Api\AngelTypeController@index'); - $route->get('/news', 'Api\NewsController@index'); + $route->get('/angeltypes/{angeltype_id:\d+}/shifts', 'Api\ShiftsController@entriesByAngeltype'); + $route->get('/locations', 'Api\LocationsController@index'); $route->get('/locations/{location_id:\d+}/shifts', 'Api\ShiftsController@entriesByLocation'); + + $route->get('/news', 'Api\NewsController@index'); + + $route->get('/users/self', 'Api\UsersController@self'); $route->get('/users/{user_id:\d+}/angeltypes', 'Api\AngelTypeController@ofUser'); + $route->get('/users/{user_id:\d+}/shifts', 'Api\ShiftsController@entriesByUser'); $route->addRoute( ['POST', 'PUT', 'DELETE', 'PATCH'], diff --git a/resources/api/openapi.yml b/resources/api/openapi.yml index d70d3e15..70b21dc3 100644 --- a/resources/api/openapi.yml +++ b/resources/api/openapi.yml @@ -21,10 +21,14 @@ servers: description: Your local dev instance tags: + - name: angeltype + description: Angeltypes + - name: location + description: Event locations - name: news description: News and meeting announcements - name: shift - description: Event shifts and location + description: Event shifts - name: user description: User information @@ -321,6 +325,55 @@ components: - pronoun - contact - url + UserDetail: + allOf: + - $ref: '#/components/schemas/User' + - type: object + properties: + email: + type: string + example: user@example.com + tshirt: + type: string + nullable: true + example: XL + dates: + type: object + properties: + planned_arrival: + type: string + nullable: true + format: date-time + description: DateTime in ISO-8601 format + example: 2023-05-13T00:00:00.000000Z + planned_departure: + type: string + nullable: true + format: date-time + description: DateTime in ISO-8601 format + example: 2023-05-23T00:00:00.000000Z + arrival: + type: string + nullable: true + format: date-time + description: Actual arrival date, DateTime in ISO-8601 format + example: 2023-05-23T13:37:42.000000Z + required: + - planned_arrival + - planned_departure + - arrival + language: + type: string + example: en_US + arrived: + type: boolean + example: true + required: + - email + - tshirt + - dates + - language + - arrived security: - bearerAuth: [ ] @@ -329,7 +382,7 @@ paths: /angeltypes: get: tags: - - shift + - angeltype summary: Get a list of angeltypes responses: '200': @@ -348,6 +401,33 @@ paths: '403': $ref: '#/components/responses/ForbiddenError' + /angeltypes/{id}/shifts: + parameters: + - name: id + in: path + required: true + description: The angeltype identifier + example: 42 + schema: + type: integer + get: + tags: + - angeltype + - shift + summary: Get all shifts of the requested angeltype + responses: + '200': + description: Ok + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Shift' + /news: get: tags: @@ -373,7 +453,7 @@ paths: /locations: get: tags: - - shift + - location summary: Get a list of locations responses: '200': @@ -403,6 +483,7 @@ paths: type: integer get: tags: + - location - shift summary: Get all shifts in the requested location responses: @@ -424,6 +505,28 @@ paths: '404': $ref: '#/components/responses/NotFoundError' + /users/self: + get: + tags: + - user + summary: Get the requesting users information + responses: + '200': + description: Ok + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/UserDetail' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '404': + $ref: '#/components/responses/NotFoundError' + /users/{id}/angeltypes: parameters: - name: id @@ -435,6 +538,7 @@ paths: type: integer get: tags: + - angeltype - user summary: Get the users angel types responses: @@ -456,6 +560,39 @@ paths: '404': $ref: '#/components/responses/NotFoundError' + /users/{id}/shifts: + parameters: + - name: id + in: path + required: true + description: The user identifier + example: 42 + schema: + type: integer + get: + tags: + - shift + - user + summary: Get all shifts of the requested user + responses: + '200': + description: Ok + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Shift' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '404': + $ref: '#/components/responses/NotFoundError' + /openapi: get: tags: diff --git a/src/Controllers/Api/Resources/UserDetailResource.php b/src/Controllers/Api/Resources/UserDetailResource.php new file mode 100644 index 00000000..010c2e29 --- /dev/null +++ b/src/Controllers/Api/Resources/UserDetailResource.php @@ -0,0 +1,23 @@ + $this->model->contact->email ?: $this->model->email, + 'tshirt' => $this->model->personalData->shirt_size, + 'language' => $this->model->settings->language, + 'arrived' => $this->model->state->arrived, + 'dates' => [ + 'planned_arrival' => $this->model->personalData->planned_arrival_date, + 'planned_departure' => $this->model->personalData->planned_departure_date, + 'arrival' => $this->model->state->arrival_date, + ], + ]); + } +} diff --git a/src/Controllers/Api/ShiftsController.php b/src/Controllers/Api/ShiftsController.php index 5252928b..854d38c9 100644 --- a/src/Controllers/Api/ShiftsController.php +++ b/src/Controllers/Api/ShiftsController.php @@ -10,13 +10,44 @@ use Engelsystem\Controllers\Api\Resources\ShiftWithEntriesResource; use Engelsystem\Controllers\Api\Resources\UserResource; use Engelsystem\Http\Request; use Engelsystem\Http\Response; +use Engelsystem\Models\AngelType; use Engelsystem\Models\Location; use Engelsystem\Models\Shifts\NeededAngelType; use Engelsystem\Models\Shifts\Shift; +use Engelsystem\Models\Shifts\ShiftEntry; +use Engelsystem\Models\User\User; use Illuminate\Database\Eloquent\Collection; class ShiftsController extends ApiController { + public function entriesByAngeltype(Request $request): Response + { + $id = (int) $request->getAttribute('angeltype_id'); + /** @var AngelType $angeltype */ + $angeltype = AngelType::findOrFail($id); + /** @var ShiftEntry[]|Collection $shifts */ + $shiftEntries = $angeltype->shiftEntries() + ->with([ + 'shift.neededAngelTypes.angelType', + 'shift.location.neededAngelTypes.angelType', + 'shift.shiftEntries.angelType', + 'shift.shiftEntries.user.contact', + 'shift.shiftEntries.user.personalData', + 'shift.shiftType', + 'shift.schedule', + ]) + ->get(); + + /** @var Shift[]|Collection $shifts */ + $shifts = Collection::make( + $shiftEntries + ->pluck('shift') + ->sortBy('start') + ); + + return $this->shiftEntriesResponse($shifts); + } + public function entriesByLocation(Request $request): Response { $locationId = (int) $request->getAttribute('location_id'); @@ -31,10 +62,45 @@ class ShiftsController extends ApiController 'shiftEntries.user.contact', 'shiftEntries.user.personalData', 'shiftType', + 'schedule', ]) ->orderBy('start') ->get(); + return $this->shiftEntriesResponse($shifts); + } + + public function entriesByUser(Request $request): Response + { + $id = (int) $request->getAttribute('user_id'); + /** @var User $user */ + $user = User::findOrFail($id); + /** @var ShiftEntry[]|Collection $shifts */ + $shiftEntries = $user->shiftEntries() + ->with([ + 'shift.neededAngelTypes.angelType', + 'shift.location.neededAngelTypes.angelType', + 'shift.shiftEntries.angelType', + 'shift.shiftEntries.user.contact', + 'shift.shiftEntries.user.personalData', + 'shift.shiftType', + 'shift.schedule', + ]) + ->get(); + + /** @var Shift[]|Collection $shifts */ + $shifts = Collection::make( + $shiftEntries + ->pluck('shift') + ->sortBy('start') + ); + + return $this->shiftEntriesResponse($shifts); + } + + protected function shiftEntriesResponse(Collection $shifts): Response + { + /** @var Collection|Shift[] $shifts */ $shiftEntries = []; // Blob of not-optimized mediocre pseudo-serialization foreach ($shifts as $shift) { @@ -58,7 +124,7 @@ class ShiftsController extends ApiController ]); } - $locationData = new LocationResource($location); + $locationData = new LocationResource($shift->location); $shiftEntries[] = (new ShiftWithEntriesResource($shift))->toArray($locationData, $entries); } @@ -72,22 +138,12 @@ class ShiftsController extends ApiController */ protected function getNeededAngelTypes(Shift $shift): Collection { - // From shift - $neededAngelTypes = $shift->neededAngelTypes; - - // Add from location - foreach ($shift->location->neededAngelTypes as $neededAngelType) { - /** @var NeededAngelType $existingNeededAngelType */ - $existingNeededAngelType = $neededAngelTypes - ->where('angel_type_id', $neededAngelType->angel_type_id) - ->first(); - if (!$existingNeededAngelType) { - $neededAngelTypes[] = clone $neededAngelType; - continue; - } - - $existingNeededAngelType->location_id = $shift->location->id; - $existingNeededAngelType->count += $neededAngelType->count; + if (!$shift->schedule) { + // Get from shift + $neededAngelTypes = $shift->neededAngelTypes; + } else { + // Load instead from location + $neededAngelTypes = $shift->location->neededAngelTypes; } // Add needed angeltypes from additionally added users diff --git a/src/Controllers/Api/UsersController.php b/src/Controllers/Api/UsersController.php new file mode 100644 index 00000000..eeb1e26c --- /dev/null +++ b/src/Controllers/Api/UsersController.php @@ -0,0 +1,26 @@ +auth->user(); + + $data = ['data' => (new UserDetailResource($user))->toArray()]; + return $this->response + ->withContent(json_encode($data)); + } +} diff --git a/tests/Unit/Controllers/Api/ShiftsControllerTest.php b/tests/Unit/Controllers/Api/ShiftsControllerTest.php index 6efc8c75..9c098930 100644 --- a/tests/Unit/Controllers/Api/ShiftsControllerTest.php +++ b/tests/Unit/Controllers/Api/ShiftsControllerTest.php @@ -10,6 +10,8 @@ use Engelsystem\Http\Request; use Engelsystem\Http\Response; use Engelsystem\Models\Location; use Engelsystem\Models\Shifts\NeededAngelType; +use Engelsystem\Models\Shifts\Schedule; +use Engelsystem\Models\Shifts\ScheduleShift; use Engelsystem\Models\Shifts\Shift; use Engelsystem\Models\Shifts\ShiftEntry; use Engelsystem\Models\User\Contact; @@ -18,8 +20,13 @@ use Engelsystem\Models\User\User; class ShiftsControllerTest extends ApiBaseControllerTest { + protected Location $location; + protected Shift $shiftA; + protected Shift $shiftB; + /** * @covers \Engelsystem\Controllers\Api\ShiftsController::entriesByLocation + * @covers \Engelsystem\Controllers\Api\ShiftsController::shiftEntriesResponse * @covers \Engelsystem\Controllers\Api\Resources\ShiftResource::toArray * @covers \Engelsystem\Controllers\Api\Resources\ShiftTypeResource::toArray * @covers \Engelsystem\Controllers\Api\Resources\ShiftWithEntriesResource::toArray @@ -29,64 +36,8 @@ class ShiftsControllerTest extends ApiBaseControllerTest */ public function testEntriesByLocation(): void { - $this->initDatabase(); - - /** @var Location $location */ - $location = Location::factory()->create(); - - // Shifts - /** @var Shift $shiftA */ - $shiftA = Shift::factory(1) - ->create(['location_id' => $location->id, 'start' => Carbon::now()->subHour()]) - ->first(); - /** @var Shift $shiftB */ - $shiftB = Shift::factory(1) - ->create(['location_id' => $location->id, 'start' => Carbon::now()->addHour()]) - ->first(); - - // "Empty" entry to be skipped - NeededAngelType::factory(1)->create(['location_id' => null, 'shift_id' => $shiftA->id, 'count' => 0]); - - // Needed entry by shift - /** @var NeededAngelType $byShift */ - $byShift = NeededAngelType::factory(2) - ->create(['location_id' => null, 'shift_id' => $shiftA->id, 'count' => 2]) - ->first(); - - // Needed entry by location - /** @var NeededAngelType $byLocation */ - $byLocation = NeededAngelType::factory(1) - ->create(['location_id' => $location->id, 'shift_id' => null, 'count' => 3]) - ->first(); - - // Added by both - NeededAngelType::factory(1) - ->create([ - 'location_id' => $location->id, - 'shift_id' => null, - 'angel_type_id' => $byShift->angel_type_id, - 'count' => 3, - ]) - ->first(); - - // By shift - ShiftEntry::factory(2)->create(['shift_id' => $shiftA->id, 'angel_type_id' => $byShift->angel_type_id]); - - // By location - ShiftEntry::factory(1)->create(['shift_id' => $shiftA->id, 'angel_type_id' => $byLocation->angel_type_id]); - - // Additional (not required by shift nor location) - ShiftEntry::factory(1)->create(['shift_id' => $shiftA->id]); - - foreach (User::all() as $user) { - // Generate user data - /** @var User $user */ - PersonalData::factory()->create(['user_id' => $user->id]); - Contact::factory()->create(['user_id' => $user->id]); - } - $request = new Request(); - $request = $request->withAttribute('location_id', $location->id); + $request = $request->withAttribute('location_id', $this->location->id); $controller = new ShiftsController(new Response()); @@ -102,15 +53,15 @@ class ShiftsControllerTest extends ApiBaseControllerTest // First shift $shiftAData = $data['data'][0]; - $this->assertEquals($shiftA->title, $shiftAData['title'], 'Title is equal'); - $this->assertEquals($location->id, $shiftAData['location']['id'], 'Same location'); - $this->assertEquals($shiftA->shiftType->id, $shiftAData['shift_type']['id'], 'Shift type equals'); + $this->assertEquals($this->shiftA->title, $shiftAData['title'], 'Title is equal'); + $this->assertEquals($this->location->id, $shiftAData['location']['id'], 'Same location'); + $this->assertEquals($this->shiftA->shiftType->id, $shiftAData['shift_type']['id'], 'Shift type equals'); $this->assertCount(4, $shiftAData['entries']); // Has users $entriesA = collect($shiftAData['entries'])->sortBy('type.id'); $entry = $entriesA[0]; $this->assertCount(2, $entry['users']); - $this->assertEquals(5, $entry['needs']); + $this->assertEquals(2, $entry['needs']); $user = $entry['users'][0]; $this->assertEquals('/users?action=view&user_id=' . $user['id'], $user['url']); $this->assertCount(0, $entriesA[1]['users']); @@ -119,12 +70,130 @@ class ShiftsControllerTest extends ApiBaseControllerTest // Second (empty) shift $shiftBData = $data['data'][1]; - $this->assertEquals($shiftB->title, $shiftBData['title'], 'Title is equal'); - $this->assertEquals($location->id, $shiftBData['location']['id'], 'Same location'); - $this->assertEquals($shiftB->shiftType->id, $shiftBData['shift_type']['id'], 'Shift type equals'); + $this->assertEquals($this->shiftB->title, $shiftBData['title'], 'Title is equal'); + $this->assertEquals($this->location->id, $shiftBData['location']['id'], 'Same location'); + $this->assertEquals($this->shiftB->shiftType->id, $shiftBData['shift_type']['id'], 'Shift type equals'); $this->assertCount(2, $shiftBData['entries']); // No users $entriesB = collect($shiftBData['entries'])->sortBy('type.id'); $this->assertCount(0, $entriesB[0]['users']); } + + /** + * @covers \Engelsystem\Controllers\Api\ShiftsController::entriesByAngeltype + */ + public function testEntriesByAngeltype(): void + { + /** @var ShiftEntry $firstEntry */ + $firstEntry = $this->shiftA->shiftEntries->first(); + + $request = new Request(); + $request = $request->withAttribute('angeltype_id', $firstEntry->angelType->id); + + $controller = new ShiftsController(new Response()); + + $response = $controller->entriesByAngeltype($request); + $this->validateApiResponse('/angeltypes/{id}/shifts', 'get', $response); + + $this->assertEquals(['application/json'], $response->getHeader('content-type')); + $this->assertJson($response->getContent()); + + $data = json_decode($response->getContent(), true); + $this->assertArrayHasKey('data', $data); + $this->assertCount(2, $data['data']); + + $shift = $data['data'][0]; + $this->assertTrue(count($shift['entries']) >= 1); + } + + /** + * @covers \Engelsystem\Controllers\Api\ShiftsController::entriesByUser + */ + public function testEntriesByUser(): void + { + /** @var ShiftEntry $firstEntry */ + $firstEntry = $this->shiftA->shiftEntries->first(); + + $request = new Request(); + $request = $request->withAttribute('user_id', $firstEntry->user->id); + + $controller = new ShiftsController(new Response()); + + $response = $controller->entriesByUser($request); + $this->validateApiResponse('/users/{id}/shifts', 'get', $response); + + $this->assertEquals(['application/json'], $response->getHeader('content-type')); + $this->assertJson($response->getContent()); + + $data = json_decode($response->getContent(), true); + $this->assertArrayHasKey('data', $data); + $this->assertCount(1, $data['data']); + + $shift = $data['data'][0]; + $this->assertTrue(count($shift['entries']) >= 1); + } + + public function setUp(): void + { + parent::setUp(); + + $this->location = Location::factory()->create(); + $schedule = Schedule::factory()->create(); + + // Shifts + $this->shiftA = Shift::factory(1) + ->create(['location_id' => $this->location->id, 'start' => Carbon::now()->subHour()]) + ->first(); + $this->shiftB = Shift::factory(1) + ->create(['location_id' => $this->location->id, 'start' => Carbon::now()->addHour()]) + ->first(); + (new ScheduleShift(['shift_id' => $this->shiftB->id, 'schedule_id' => $schedule->id, 'guid' => 'a']))->save(); + + // "Empty" entry to be skipped + NeededAngelType::factory(1)->create(['location_id' => null, 'shift_id' => $this->shiftA->id, 'count' => 0]); + + // Needed entry by shift + /** @var NeededAngelType $byShift */ + $byShift = NeededAngelType::factory(2) + ->create(['location_id' => null, 'shift_id' => $this->shiftA->id, 'count' => 2]) + ->first(); + + // Needed entry by location + /** @var NeededAngelType $byLocation */ + $byLocation = NeededAngelType::factory(1) + ->create(['location_id' => $this->location->id, 'shift_id' => null, 'count' => 3]) + ->first(); + + // Added by both + NeededAngelType::factory(1) + ->create([ + 'location_id' => $this->location->id, + 'shift_id' => null, + 'angel_type_id' => $byShift->angel_type_id, + 'count' => 3, + ]) + ->first(); + + // By shift + ShiftEntry::factory(2)->create([ + 'shift_id' => $this->shiftA->id, + 'angel_type_id' => $byShift->angel_type_id, + ]); + + // By location + ShiftEntry::factory(1)->create([ + 'shift_id' => $this->shiftA->id, + 'angel_type_id' => $byLocation->angel_type_id, + ]); + + // Additional (not required by shift nor location) + ShiftEntry::factory(1)->create(['shift_id' => $this->shiftA->id]); + + foreach (User::all() as $user) { + // Generate user data + /** @var User $user */ + PersonalData::factory()->create(['user_id' => $user->id]); + Contact::factory()->create(['user_id' => $user->id]); + } + } } diff --git a/tests/Unit/Controllers/Api/UsersControllerTest.php b/tests/Unit/Controllers/Api/UsersControllerTest.php new file mode 100644 index 00000000..0f99299e --- /dev/null +++ b/tests/Unit/Controllers/Api/UsersControllerTest.php @@ -0,0 +1,51 @@ +has(Contact::factory()) + ->has(PersonalData::factory()) + ->has(Settings::factory()) + ->has(State::factory()) + ->create(); + + /** @var Authenticator|MockObject $auth */ + $auth = $this->createMock(Authenticator::class); + $this->setExpects($auth, 'user', null, $user); + + $controller = new UsersController(new Response(), $auth); + + $response = $controller->self(); + $this->validateApiResponse('/users/self', 'get', $response); + + $this->assertEquals(['application/json'], $response->getHeader('content-type')); + $this->assertJson($response->getContent()); + + $data = json_decode($response->getContent(), true); + $this->assertArrayHasKey('data', $data); + $this->assertArrayHasKey('id', $data['data']); + $this->assertEquals($user->id, $data['data']['id']); + $this->assertArrayHasKey('dates', $data['data']); + } +}