Skip to content

Commit

Permalink
Add skip and meta to resource in time entry endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
korridor committed Sep 19, 2024
1 parent dad9430 commit 9278bbd
Show file tree
Hide file tree
Showing 5 changed files with 122 additions and 36 deletions.
75 changes: 43 additions & 32 deletions app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Extensions\Scramble;

use App\Http\Resources\PaginatedResourceCollection;
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
use Dedoc\Scramble\Extensions\TypeToSchemaExtension;
use Dedoc\Scramble\Support\Generator\Response;
use Dedoc\Scramble\Support\Generator\Schema;
Expand Down Expand Up @@ -44,39 +45,49 @@ public function toSchema(Type $type): ?OpenApiObjectType
return null;
}

$type = new OpenApiObjectType;
$type->addProperty('data', (new ArrayType)->setItems($collectingType));
$type->addProperty(
'links',
(new OpenApiObjectType)
->addProperty('first', (new StringType)->nullable(true))
->addProperty('last', (new StringType)->nullable(true))
->addProperty('prev', (new StringType)->nullable(true))
->addProperty('next', (new StringType)->nullable(true))
->setRequired(['first', 'last', 'prev', 'next'])
);
$type->addProperty(
'meta',
(new OpenApiObjectType)
->addProperty('current_page', new IntegerType)
->addProperty('from', (new IntegerType)->nullable(true))
->addProperty('last_page', new IntegerType)
->addProperty('links', (new ArrayType)->setItems(
(new OpenApiObjectType)
->addProperty('url', (new StringType)->nullable(true))
->addProperty('label', new StringType)
->addProperty('active', new BooleanType)
->setRequired(['url', 'label', 'active'])
)->setDescription('Generated paginator links.'))
->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.'))
->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.'))
->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.'))
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total'])
);
$type->setRequired(['data', 'links', 'meta']);
$newType = new OpenApiObjectType;
$newType->addProperty('data', (new ArrayType)->setItems($collectingType));
if ($type instanceof ObjectType && $type->isInstanceOf(TimeEntryCollection::class)) {
$newType->addProperty(
'meta',
(new OpenApiObjectType)
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
->setRequired(['total'])

Check warning on line 55 in app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php

View check run for this annotation

Codecov / codecov/patch

app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php#L48-L55

Added lines #L48 - L55 were not covered by tests
);
$newType->setRequired(['data', 'meta']);

Check warning on line 57 in app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php

View check run for this annotation

Codecov / codecov/patch

app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php#L57

Added line #L57 was not covered by tests
} else {
$newType->addProperty(
'links',
(new OpenApiObjectType)
->addProperty('first', (new StringType)->nullable(true))
->addProperty('last', (new StringType)->nullable(true))
->addProperty('prev', (new StringType)->nullable(true))
->addProperty('next', (new StringType)->nullable(true))
->setRequired(['first', 'last', 'prev', 'next'])

Check warning on line 66 in app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php

View check run for this annotation

Codecov / codecov/patch

app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php#L59-L66

Added lines #L59 - L66 were not covered by tests
);
$newType->addProperty(
'meta',
(new OpenApiObjectType)
->addProperty('current_page', new IntegerType)
->addProperty('from', (new IntegerType)->nullable(true))
->addProperty('last_page', new IntegerType)
->addProperty('links', (new ArrayType)->setItems(
(new OpenApiObjectType)
->addProperty('url', (new StringType)->nullable(true))
->addProperty('label', new StringType)
->addProperty('active', new BooleanType)
->setRequired(['url', 'label', 'active'])
)->setDescription('Generated paginator links.'))
->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.'))
->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.'))
->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.'))
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total'])

Check warning on line 85 in app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php

View check run for this annotation

Codecov / codecov/patch

app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php#L68-L85

Added lines #L68 - L85 were not covered by tests
);
$newType->setRequired(['data', 'links', 'meta']);

Check warning on line 87 in app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php

View check run for this annotation

Codecov / codecov/patch

app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php#L87

Added line #L87 was not covered by tests
}

return $type;
return $newType;

Check warning on line 90 in app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php

View check run for this annotation

Codecov / codecov/patch

app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php#L90

Added line #L90 was not covered by tests
}

/**
Expand Down
14 changes: 12 additions & 2 deletions app/Http/Controllers/Api/V1/TimeEntryController.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ protected function checkPermission(Organization $organization, string $permissio
* If you only need time entries for a specific user, you can filter by `user_id`.
* Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.
*
* @return TimeEntryCollection<TimeEntryResource>
*
* @throws AuthorizationException
*
* @operationId getTimeEntries
Expand Down Expand Up @@ -73,11 +75,14 @@ public function index(Organization $organization, TimeEntryIndexRequest $request
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));

$limit = $request->has('limit') ? (int) $request->input('limit', 100) : 100;
$totalCount = $timeEntriesQuery->count();

$limit = $request->getLimit();
if ($limit > 1000) {
$limit = 1000;
}
$timeEntriesQuery->limit($limit);
$timeEntriesQuery->skip($request->getSkip());

$timeEntries = $timeEntriesQuery->get();

Expand Down Expand Up @@ -111,7 +116,12 @@ public function index(Organization $organization, TimeEntryIndexRequest $request
}
}

return new TimeEntryCollection($timeEntries);
return (new TimeEntryCollection($timeEntries))
->additional([
'meta' => [
'total' => $totalCount,
],
]);
}

/**
Expand Down
15 changes: 15 additions & 0 deletions app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@ public function rules(): array
'min:1',
'max:500',
],
// Skip the first n time entries (default: 0)
'skip' => [
'integer',
'min:0',
],
// Filter makes sure that only time entries of a whole date are returned
'only_full_dates' => [
'string',
Expand All @@ -143,4 +148,14 @@ public function getOnlyFullDates(): bool
{
return $this->input('only_full_dates', 'false') === 'true';
}

public function getLimit(): int
{
return $this->has('limit') ? (int) $this->validated('limit', 100) : 100;
}

public function getSkip(): int
{
return $this->has('skip') ? (int) $this->validated('skip', 0) : 0;
}
}
3 changes: 2 additions & 1 deletion app/Http/Resources/V1/TimeEntry/TimeEntryCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@

namespace App\Http\Resources\V1\TimeEntry;

use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection;

class TimeEntryCollection extends ResourceCollection
class TimeEntryCollection extends ResourceCollection implements PaginatedResourceCollection
{
/**
* The resource that this resource collects.
Expand Down
51 changes: 50 additions & 1 deletion tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use Illuminate\Testing\Fluent\AssertableJson;
use Laravel\Passport\Passport;
use PHPUnit\Framework\Attributes\UsesClass;
use Ramsey\Uuid\Type\Time;
use TiMacDonald\Log\LogEntry;

#[UsesClass(TimeEntryController::class)]
Expand Down Expand Up @@ -163,6 +164,7 @@ public function test_index_endpoint_returns_only_active_time_entries(): void
// Assert
$response->assertStatus(200);
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('meta.total', 1);
$response->assertJsonPath('data.0.id', $activeTimeEntry->getKey());
}

Expand All @@ -186,13 +188,13 @@ public function test_index_endpoint_returns_only_non_active_time_entries(): void
// Assert
$response->assertStatus(200);
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('meta.total', 1);
$response->assertJsonPath('data.0.id', $nonActiveTimeEntries->getKey());
}

public function test_index_endpoint_filter_only_full_dates_returns_time_entries_for_the_whole_day_case_less_time_entries_than_limit(): void
{
// Arrange

$data = $this->createUserWithPermission([
'time-entries:view:own',
]);
Expand All @@ -210,6 +212,7 @@ public function test_index_endpoint_filter_only_full_dates_returns_time_entries_
// Assert
$response->assertStatus(200);
$response->assertJsonCount(3, 'data');
$response->assertJsonPath('meta.total', 3);
}

public function test_index_endpoint_filter_only_full_dates_returns_time_entries_for_the_whole_day_case_more_time_entries_than_limit(): void
Expand Down Expand Up @@ -237,6 +240,7 @@ public function test_index_endpoint_filter_only_full_dates_returns_time_entries_
// Assert
$response->assertStatus(200);
$response->assertJsonCount(3, 'data');
$response->assertJsonPath('meta.total', 6);
}

public function test_index_endpoint_filter_only_full_dates_returns_time_entries_for_the_whole_day_case_more_time_entries_than_limit_with_a_timezone_edge_case(): void
Expand Down Expand Up @@ -285,6 +289,7 @@ public function test_index_endpoint_filter_only_full_dates_returns_time_entries_
// Assert
$response->assertStatus(200);
$response->assertJsonCount(2, 'data');
$response->assertJsonPath('meta.total', 7);
}

public function test_index_endpoint_filter_only_full_dates_returns_time_entries_for_the_whole_day_case_more_time_entries_in_latest_day_than_limit(): void
Expand Down Expand Up @@ -318,6 +323,7 @@ public function test_index_endpoint_filter_only_full_dates_returns_time_entries_
// Assert
$response->assertStatus(200);
$response->assertJsonCount(7, 'data');
$response->assertJsonPath('meta.total', 10);
Log::assertLogged(fn (LogEntry $log) => $log->level === 'warning'
&& $log->message === 'User has has more than 5 time entries on one date'
);
Expand Down Expand Up @@ -359,6 +365,8 @@ public function test_index_endpoint_before_filter_returns_time_entries_before_da
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->has('meta')
->where('meta.total', 4)
->count('data', 4)
->where('data.0.id', $timeEntriesDirectlyBeforeLimit->getKey())
->where('data.1.id', $timeEntriesBeforeSorted->get(0)->getKey())
Expand Down Expand Up @@ -397,6 +405,8 @@ public function test_index_endpoint_after_filter_returns_time_entries_after_date
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->has('meta')
->where('meta.total', 4)
->count('data', 4)
->where('data.0.id', $timeEntriesAfterSorted->get(0)->getKey())
->where('data.1.id', $timeEntriesAfterSorted->get(1)->getKey())
Expand Down Expand Up @@ -448,11 +458,50 @@ public function test_index_endpoint_with_all_available_filters(): void
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->has('meta')
->where('meta.total', 1)
->count('data', 1)
->where('data.0.id', $timeEntry1->getKey())
);
}

public function test_index_endpoint_with_limit_skip_and_only_full_dates_deactivated(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:own',
]);
$project1 = Project::factory()->forOrganization($data->organization)->create();
$project2 = Project::factory()->forOrganization($data->organization)->create();
TimeEntry::factory()->forMember($data->member)->forProject($project1)->forOrganization($data->organization)->create([
'start' => Carbon::now()->subDays(2),
]);
$timeEntry = TimeEntry::factory()->forMember($data->member)->forProject($project1)->forOrganization($data->organization)->create([
'start' => Carbon::now()->subDays(3),
]);
TimeEntry::factory()->forMember($data->member)->forProject($project1)->forOrganization($data->organization)->create([
'start' => Carbon::now()->subDays(4),
]);
TimeEntry::factory()->forMember($data->member)->forProject($project2)->forOrganization($data->organization)->create();
Passport::actingAs($data->user);

// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'member_id' => $data->member->getKey(),
'project_ids' => [$project1->getKey()],
'limit' => 1,
'skip' => 1,
'only_full_dates' => 'false',
]));

// Assert
$response->assertStatus(200);
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('meta.total', 3);
$response->assertJsonPath('data.*.id', [$timeEntry->getKey()]);
}

public function test_aggregate_endpoint_fails_if_user_has_no_permission_to_view_time_entries(): void
{
// Arrange
Expand Down

0 comments on commit 9278bbd

Please sign in to comment.