feat(graphql): add middleware to mutations (#1120)

This commit is contained in:
Kyrch
2026-03-05 07:30:09 -03:00
committed by GitHub
parent 93a48173e5
commit 8386535251
39 changed files with 419 additions and 104 deletions
@@ -24,7 +24,7 @@ class ManageSongPerformances
/** @var Collection<int, array{alias: string|null, as: string|null}> */
protected Collection $groups;
/** @var Collection<int, array<string, mixed>> */
/** @var Collection<int, non-empty-array<string, mixed>> */
protected Collection $performances;
/** @var Collection<int, array<string, mixed>> */
@@ -99,7 +99,10 @@ class ManageSongPerformances
DB::beginTransaction();
$this->performances = $this->performances->map(
fn (array $performance) => $performance + [$performance[Performance::ATTRIBUTE_SONG] = $this->song->getKey()]
fn (array $performance): array => [
...$performance,
Performance::ATTRIBUTE_SONG => $this->song->getKey(),
]
);
// Multiple queries because upsert does not dispatch an event.
@@ -121,7 +124,7 @@ class ManageSongPerformances
Performance::ATTRIBUTE_ARTIST_ID,
$this->performances->filter(fn (array $performance): bool => $performance[Performance::ATTRIBUTE_ARTIST_TYPE] === Relation::getMorphAlias(Membership::class))
->map(fn (array $performanceMembership) => Arr::get($performanceMembership, Performance::ATTRIBUTE_ARTIST_ID))
->all(),
->all(),
);
$membershipPerformances->each(fn (Performance $performance) => $performance->delete());
@@ -9,6 +9,7 @@ use App\Filament\Actions\BaseAction;
use App\Models\Admin\Dump;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DownloadDumpAction extends BaseAction
{
@@ -29,6 +30,6 @@ class DownloadDumpAction extends BaseAction
$this->authorize('view');
$this->action(fn (Dump $record) => new DownloadAction($record)->download());
$this->action(fn (Dump $record): StreamedResponse => new DownloadAction($record)->download());
}
}
@@ -121,7 +121,7 @@ class DumpResource extends BaseResource
}
/**
* @return array<int, \Filament\Actions\Action|\Filament\Actions\ActionGroup>
* @return array<int, \Filament\Actions\Action|ActionGroup>
*/
public static function getRecordActions(): array
{
+6
View File
@@ -10,6 +10,7 @@ use GraphQL\Error\DebugFlag;
use GraphQL\Error\Error as GraphQLError;
use GraphQL\Error\FormattedError;
use GraphQL\Server\RequestError;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Config;
@@ -92,6 +93,11 @@ class ErrorHandler
$error['extensions']['validation'] = $previous->getValidatorMessages()->getMessages();
}
if ($previous instanceof AuthorizationException) {
$error['message'] = $previous->getMessage();
$error['extensions']['category'] = 'authorization';
}
if ($previous instanceof ProvidesErrorCategory) {
$error['extensions']['category'] = $previous->getCategory();
}
+64 -14
View File
@@ -4,31 +4,35 @@ declare(strict_types=1);
namespace App\GraphQL\Resolvers;
use App\Actions\Http\Api\DestroyAction;
use App\Actions\Http\Api\StoreAction;
use App\Actions\Http\Api\UpdateAction;
use App\GraphQL\Schema\Mutations\BaseMutation;
use Illuminate\Pipeline\Pipeline;
use Illuminate\Routing\ControllerMiddlewareOptions;
use Illuminate\Routing\FiltersControllerMiddleware;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Validator;
/**
* @template TModel of \Illuminate\Database\Eloquent\Model
*/
abstract class BaseResolver
{
final public const MODEL = 'model';
/**
* @param StoreAction<TModel> $storeAction
* @param UpdateAction<TModel> $updateAction
* @param DestroyAction<TModel> $destroyAction
* HTTP middleware stack that should be run before the resolver action.
*
* Each element is an array with keys `middleware` and `options`. The
* `options` array is the same structure that the `ControllerMiddlewareOptions`
* helper understands (see `only` / `except`). This mirrors the behaviour of
* Laravel controllers so you can register middleware exactly the same way:
*
* $this->middleware('auth')->only('store');
* $this->middleware(['auth', 'log'])->except('destroy');
*
* The resolver will evaluate the options against the current action name
* when deciding which middleware to run.
*
* @var array<int, array{middleware:class-string|string,options:array}>
*/
public function __construct(
protected StoreAction $storeAction,
protected UpdateAction $updateAction,
protected DestroyAction $destroyAction,
) {}
protected array $middleware = [];
/**
* Get the attributes and values that were validated.
@@ -50,4 +54,50 @@ abstract class BaseResolver
'model' => Arr::get($args, self::MODEL),
];
}
/**
* Run the middleware pipeline for the given action.
*/
protected function runMiddleware(): void
{
$action = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['function'];
$stack = [];
foreach ($this->middleware as $entry) {
$options = $entry['options'] ?? [];
if (FiltersControllerMiddleware::methodExcludedByOptions($action, $options)) {
continue;
}
$stack[] = $entry['middleware'];
}
if ($stack === []) {
return;
}
resolve(Pipeline::class)
->send(request())
->through($stack)
->thenReturn();
}
/**
* Register middleware on the controller.
*
* @param \Closure|array|string $middleware
* @return ControllerMiddlewareOptions
*/
public function middleware($middleware, array $options = [])
{
foreach ((array) $middleware as $m) {
$this->middleware[] = [
'middleware' => $m,
'options' => &$options,
];
}
return new ControllerMiddlewareOptions($options);
}
}
@@ -7,26 +7,38 @@ namespace App\GraphQL\Resolvers\List\Playlist;
use App\Actions\Http\Api\List\Playlist\Track\DestroyTrackAction;
use App\Actions\Http\Api\List\Playlist\Track\StoreTrackAction;
use App\Actions\Http\Api\List\Playlist\Track\UpdateTrackAction;
use App\Features\AllowPlaylistManagement;
use App\GraphQL\Resolvers\BaseResolver;
use App\GraphQL\Schema\Mutations\Models\List\Playlist\Track\CreatePlaylistTrackMutation;
use App\GraphQL\Schema\Mutations\Models\List\Playlist\Track\UpdatePlaylistTrackMutation;
use App\Http\Middleware\Models\List\PlaylistExceedsTrackLimit;
use App\Models\List\Playlist;
use App\Models\List\Playlist\PlaylistTrack;
use Illuminate\Support\Arr;
use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;
/**
* @extends BaseResolver<PlaylistTrack>
*/
class PlaylistTrackResolver extends BaseResolver
{
final public const string ATTRIBUTE_ENTRY = 'entryId';
final public const string ATTRIBUTE_VIDEO = 'videoId';
public static Playlist $playlist;
public function __construct(Playlist $playlist)
{
static::$playlist = $playlist;
$this->middleware(EnsureFeaturesAreActive::using(AllowPlaylistManagement::class))->only(['store', 'update', 'destroy']);
$this->middleware(PlaylistExceedsTrackLimit::class)->only(['store']);
}
/**
* @param array<string, mixed> $args
*/
public function store($root, array $args): PlaylistTrack
public function store(array $args, StoreTrackAction $action): PlaylistTrack
{
$this->runMiddleware();
$validated = $this->validated($args, CreatePlaylistTrackMutation::class);
$validated += [
@@ -37,16 +49,16 @@ class PlaylistTrackResolver extends BaseResolver
/** @var Playlist $playlist */
$playlist = Arr::pull($validated, 'playlist');
$action = new StoreTrackAction();
return $action->store($playlist, PlaylistTrack::query(), $validated);
}
/**
* @param array<string, mixed> $args
*/
public function update($root, array $args): PlaylistTrack
public function update(array $args, UpdateTrackAction $action): PlaylistTrack
{
$this->runMiddleware();
$validated = $this->validated($args, UpdatePlaylistTrackMutation::class);
$validated += [
@@ -57,21 +69,19 @@ class PlaylistTrackResolver extends BaseResolver
/** @var PlaylistTrack $track */
$track = Arr::pull($validated, self::MODEL);
$action = new UpdateTrackAction();
return $action->update($track->playlist, $track, $validated);
}
/**
* @param array<string, mixed> $args
*/
public function destroy($root, array $args): array
public function destroy(array $args, DestroyTrackAction $action): array
{
$this->runMiddleware();
/** @var PlaylistTrack $track */
$track = Arr::get($args, self::MODEL);
$action = new DestroyTrackAction();
$message = $action->destroy($track->playlist, $track);
return [
@@ -4,23 +4,35 @@ declare(strict_types=1);
namespace App\GraphQL\Resolvers\List;
use App\Actions\Http\Api\DestroyAction;
use App\Actions\Http\Api\StoreAction;
use App\Actions\Http\Api\UpdateAction;
use App\Features\AllowPlaylistManagement;
use App\GraphQL\Resolvers\BaseResolver;
use App\GraphQL\Schema\Mutations\Models\List\Playlist\CreatePlaylistMutation;
use App\GraphQL\Schema\Mutations\Models\List\Playlist\UpdatePlaylistMutation;
use App\Http\Middleware\Models\List\UserExceedsPlaylistLimit;
use App\Models\List\Playlist;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;
/**
* @extends BaseResolver<Playlist>
*/
class PlaylistResolver extends BaseResolver
{
public function __construct()
{
$this->middleware(EnsureFeaturesAreActive::using(AllowPlaylistManagement::class))->only(['store', 'update', 'destroy']);
$this->middleware(UserExceedsPlaylistLimit::class)->only(['store']);
}
/**
* @param array<string, mixed> $args
* @param StoreAction<Playlist> $action
*/
public function store($root, array $args): Playlist
public function store(array $args, StoreAction $action): Playlist
{
$this->runMiddleware();
$validated = $this->validated($args, CreatePlaylistMutation::class);
$parameters = [
@@ -28,31 +40,37 @@ class PlaylistResolver extends BaseResolver
Playlist::ATTRIBUTE_USER => Auth::id(),
];
return $this->storeAction->store(Playlist::query(), $parameters);
return $action->store(Playlist::query(), $parameters);
}
/**
* @param array<string, mixed> $args
* @param UpdateAction<Playlist> $action
*/
public function update($root, array $args): Playlist
public function update(array $args, UpdateAction $action): Playlist
{
$this->runMiddleware();
/** @var Playlist $playlist */
$playlist = Arr::pull($args, self::MODEL);
$validated = $this->validated($args, UpdatePlaylistMutation::class);
return $this->updateAction->update($playlist, $validated);
return $action->update($playlist, $validated);
}
/**
* @param array<string, mixed> $args
* @param DestroyAction<Playlist> $action
*/
public function destroy($root, array $args): array
public function destroy(array $args, DestroyAction $action): array
{
$this->runMiddleware();
/** @var Playlist $playlist */
$playlist = Arr::get($args, self::MODEL);
$message = $this->destroyAction->forceDelete($playlist);
$message = $action->forceDelete($playlist);
return [
'message' => $message,
@@ -5,19 +5,28 @@ declare(strict_types=1);
namespace App\GraphQL\Resolvers\List;
use App\Exceptions\GraphQL\ClientForbiddenException;
use App\Features\AllowExternalProfileManagement;
use App\GraphQL\Resolvers\BaseResolver;
use App\Http\Middleware\Api\EnabledOnlyOnLocalhost;
use App\Models\List\ExternalProfile;
use Illuminate\Support\Arr;
use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;
class SyncExternalProfileResolver extends BaseResolver
{
public function __construct()
{
$this->middleware(EnabledOnlyOnLocalhost::class);
$this->middleware(EnsureFeaturesAreActive::using(AllowExternalProfileManagement::class))->only(['update']);
}
/**
* Start a new sync job.
*
* @param array<string, mixed> $args
*/
public function store($root, array $args): array
public function update(array $args): array
{
$this->runMiddleware();
/** @var ExternalProfile $profile */
$profile = Arr::pull($args, self::MODEL);
@@ -12,9 +12,6 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
/**
* @extends BaseResolver<Like>
*/
class ToggleLikeResolver extends BaseResolver
{
final public const string ATTRIBUTE_ENTRY = 'entry';
@@ -23,8 +20,10 @@ class ToggleLikeResolver extends BaseResolver
/**
* @param array<string, mixed> $args
*/
public function store($root, array $args): ?Like
public function store(array $args): ?Like
{
$this->runMiddleware();
$validated = $this->validated($args, ToggleLikeMutation::class);
/** @var Model&Likeable $likeable */
+6 -7
View File
@@ -4,16 +4,13 @@ declare(strict_types=1);
namespace App\GraphQL\Resolvers\User;
use App\Actions\Http\Api\StoreAction;
use App\GraphQL\Resolvers\BaseResolver;
use App\GraphQL\Schema\Mutations\Models\User\WatchMutation;
use App\Models\User\WatchHistory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
/**
* @extends BaseResolver<WatchHistory>
*/
class WatchResolver extends BaseResolver
{
final public const string ATTRIBUTE_ENTRY = 'entryId';
@@ -21,10 +18,12 @@ class WatchResolver extends BaseResolver
/**
* @param array<string, mixed> $args
* @return WatchHistory
* @param StoreAction<WatchHistory> $action
*/
public function store($root, array $args): Model
public function store(array $args, StoreAction $action): WatchHistory
{
$this->runMiddleware();
$validated = $this->validated($args, WatchMutation::class);
$validated += [
@@ -33,6 +32,6 @@ class WatchResolver extends BaseResolver
WatchHistory::ATTRIBUTE_USER => Auth::id(),
];
return $this->storeAction->store(WatchHistory::query(), $validated);
return $action->store(WatchHistory::query(), $validated);
}
}
@@ -21,15 +21,12 @@ use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;
/**
* @extends BaseResolver<Anime>
*/
class AnimeYearsResolver extends BaseResolver
{
/**
* @param array<string, mixed> $args
*/
public function index(null $root, array $args, $context, ResolveInfo $resolveInfo): mixed
public function index(array $args, ResolveInfo $resolveInfo): mixed
{
$year = Arr::get($args, AnimeYearsQuery::ARGUMENT_YEAR);
@@ -64,7 +61,7 @@ class AnimeYearsResolver extends BaseResolver
*
* @param array<string, mixed> $args
*/
public function resolveSeasonField(array $root, array $args, $context, ResolveInfo $resolveInfo): mixed
public function resolveSeasonField(array $root, array $args): mixed
{
$season = Arr::get($args, AnimeYearSeasonField::ARGUMENT_SEASON);
$year = Arr::get($root, AnimeYearsQuery::ARGUMENT_YEAR);
@@ -85,7 +82,7 @@ class AnimeYearsResolver extends BaseResolver
/**
* Resolve the AnimeYearSeasonAnimeField.
*/
public function resolveAnimeField(array $root, array $args, $context, ResolveInfo $resolveInfo): Paginator
public function resolveAnimeField(array $root, array $args, ResolveInfo $resolveInfo, IndexAction $action): Paginator
{
$season = Arr::get($root, AnimeYearSeasonSeasonField::FIELD);
$year = Arr::get($root, 'year');
@@ -95,8 +92,6 @@ class AnimeYearsResolver extends BaseResolver
->when($season !== null, fn (Builder $query) => $query->where(Anime::ATTRIBUTE_SEASON, $season->value))
->where(Anime::ATTRIBUTE_YEAR, $year);
$action = new IndexAction();
return $action->index($builder, $args, new AnimeType(), $resolveInfo);
}
}
@@ -77,7 +77,9 @@ class AnimeYearSeasonAnimeField extends Field implements DisplayableField
*/
public function resolve($root, array $args, $context, ResolveInfo $resolveInfo): mixed
{
return App::make(AnimeYearsResolver::class)
->resolveAnimeField($root, $args, $context, $resolveInfo);
return App::call(
[App::make(AnimeYearsResolver::class), 'resolveAnimeField'],
['root' => $root, 'args' => $args, 'context' => $context, 'resolveInfo' => $resolveInfo]
);
}
}
@@ -57,7 +57,9 @@ class AnimeYearSeasonField extends Field implements DisplayableField
*/
public function resolve($root, array $args, $context, ResolveInfo $resolveInfo): mixed
{
return App::make(AnimeYearsResolver::class)
->resolveSeasonField($root, $args, $context, $resolveInfo);
return App::call(
[App::make(AnimeYearsResolver::class), 'resolveSeasonField'],
['root' => $root, 'args' => $args, 'context' => $context, 'resolveInfo' => $resolveInfo]
);
}
}
@@ -62,7 +62,9 @@ class SyncExternalProfileMutation extends BaseMutation
*/
public function resolve($root, array $args, $context, ResolveInfo $resolveInfo): mixed
{
return App::make(SyncExternalProfileResolver::class)
->store($root, $args);
return App::call(
[App::make(SyncExternalProfileResolver::class), 'update'],
['root' => $root, 'args' => $args, 'context' => $context, 'resolveInfo' => $resolveInfo]
);
}
}
@@ -31,7 +31,9 @@ class CreatePlaylistMutation extends CreateMutation
*/
public function resolve($root, array $args, $context, ResolveInfo $resolveInfo): mixed
{
return App::make(PlaylistResolver::class)
->store($root, $args);
return App::call(
[App::make(PlaylistResolver::class), 'store'],
['root' => $root, 'args' => $args, 'context' => $context, 'resolveInfo' => $resolveInfo]
);
}
}
@@ -39,7 +39,9 @@ class DeletePlaylistMutation extends DeleteMutation
*/
public function resolve($root, array $args, $context, ResolveInfo $resolveInfo): mixed
{
return App::make(PlaylistResolver::class)
->destroy($root, $args);
return App::call(
[App::make(PlaylistResolver::class), 'destroy'],
['root' => $root, 'args' => $args, 'context' => $context, 'resolveInfo' => $resolveInfo]
);
}
}
@@ -9,6 +9,7 @@ use App\GraphQL\Schema\Mutations\Models\CreateMutation;
use App\GraphQL\Schema\Types\List\Playlist\PlaylistTrackType;
use App\Models\List\Playlist\PlaylistTrack;
use GraphQL\Type\Definition\ResolveInfo;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App;
class CreatePlaylistTrackMutation extends CreateMutation
@@ -31,7 +32,9 @@ class CreatePlaylistTrackMutation extends CreateMutation
*/
public function resolve($root, array $args, $context, ResolveInfo $resolveInfo): mixed
{
return App::make(PlaylistTrackResolver::class)
->store($root, $args);
return App::call(
[App::make(PlaylistTrackResolver::class, ['playlist' => Arr::get($args, 'playlist')]), 'store'],
['root' => $root, 'args' => $args, 'context' => $context, 'resolveInfo' => $resolveInfo]
);
}
}
@@ -11,6 +11,7 @@ use App\GraphQL\Schema\Types\MessageResponseType;
use App\Models\List\Playlist\PlaylistTrack;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App;
use Rebing\GraphQL\Support\Facades\GraphQL;
@@ -39,7 +40,9 @@ class DeletePlaylistTrackMutation extends DeleteMutation
*/
public function resolve($root, array $args, $context, ResolveInfo $resolveInfo): mixed
{
return App::make(PlaylistTrackResolver::class)
->destroy($root, $args);
return App::call(
[App::make(PlaylistTrackResolver::class, ['playlist' => Arr::get($args, 'playlist')]), 'destroy'],
['root' => $root, 'args' => $args, 'context' => $context, 'resolveInfo' => $resolveInfo]
);
}
}
@@ -9,6 +9,7 @@ use App\GraphQL\Schema\Mutations\Models\UpdateMutation;
use App\GraphQL\Schema\Types\List\Playlist\PlaylistTrackType;
use App\Models\List\Playlist\PlaylistTrack;
use GraphQL\Type\Definition\ResolveInfo;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App;
class UpdatePlaylistTrackMutation extends UpdateMutation
@@ -31,7 +32,9 @@ class UpdatePlaylistTrackMutation extends UpdateMutation
*/
public function resolve($root, array $args, $context, ResolveInfo $resolveInfo): mixed
{
return App::make(PlaylistTrackResolver::class)
->update($root, $args);
return App::call(
[App::make(PlaylistTrackResolver::class, ['playlist' => Arr::get($args, 'playlist')]), 'update'],
['root' => $root, 'args' => $args, 'context' => $context, 'resolveInfo' => $resolveInfo]
);
}
}
@@ -31,7 +31,9 @@ class UpdatePlaylistMutation extends UpdateMutation
*/
public function resolve($root, array $args, $context, ResolveInfo $resolveInfo): mixed
{
return App::make(PlaylistResolver::class)
->update($root, $args);
return App::call(
[App::make(PlaylistResolver::class), 'update'],
['root' => $root, 'args' => $args, 'context' => $context, 'resolveInfo' => $resolveInfo]
);
}
}
@@ -73,7 +73,9 @@ class ToggleLikeMutation extends BaseMutation
*/
public function resolve($root, array $args, $context, ResolveInfo $resolveInfo): mixed
{
return App::make(ToggleLikeResolver::class)
->store($root, $args);
return App::call(
[App::make(ToggleLikeResolver::class), 'store'],
['root' => $root, 'args' => $args, 'context' => $context, 'resolveInfo' => $resolveInfo]
);
}
}
@@ -43,7 +43,9 @@ class WatchMutation extends CreateMutation
*/
public function resolve($root, array $args, $context, ResolveInfo $resolveInfo): mixed
{
return App::make(WatchResolver::class)
->store($root, $args);
return App::call(
[App::make(WatchResolver::class), 'store'],
['root' => $root, 'args' => $args, 'context' => $context, 'resolveInfo' => $resolveInfo]
);
}
}
@@ -69,7 +69,9 @@ class AnimeYearsQuery extends BaseQuery
*/
public function resolve($root, array $args, $context, ResolveInfo $resolveInfo)
{
return App::make(AnimeYearsResolver::class)
->index($root, $args, $context, $resolveInfo);
return App::call(
[App::make(AnimeYearsResolver::class), 'index'],
['root' => $root, 'args' => $args, 'context' => $context, 'resolveInfo' => $resolveInfo]
);
}
}
@@ -10,6 +10,7 @@ use App\Http\Api\Field\Field;
use App\Http\Api\Field\List\Playlist\Track\TrackEntryIdField;
use App\Http\Api\Field\List\Playlist\Track\TrackHashidsField;
use App\Http\Api\Field\List\Playlist\Track\TrackIdField;
use App\Http\Api\Field\List\Playlist\Track\TrackPositionField;
use App\Http\Api\Field\List\Playlist\Track\TrackVideoIdField;
use App\Http\Api\Include\AllowedInclude;
use App\Http\Api\Schema\EloquentSchema;
@@ -55,6 +56,7 @@ class ForwardBackwardSchema extends EloquentSchema
new TrackHashidsField($this),
new TrackEntryIdField($this),
new TrackVideoIdField($this),
new TrackPositionField($this),
];
}
@@ -31,7 +31,9 @@ class PlaylistForwardController extends BaseController
$query->where(PlaylistTrack::ATTRIBUTE_ID, $playlist->first_id);
};
$builder = ForwardPlaylistTrack::query()->treeOf($constraint);
$builder = ForwardPlaylistTrack::query()
->treeOf($constraint)
->orderBy(PlaylistTrack::ATTRIBUTE_POSITION);
$resources = $action->index($builder, $query, $request->schema());
@@ -37,9 +37,6 @@ class SyncExternalProfileController extends Controller
// TODO
}
/**
* Start a new sync job.
*/
public function update(ExternalProfile $externalProfile): JsonResponse
{
$externalProfile->dispatchSyncJob();
@@ -6,6 +6,7 @@ namespace App\Http\Middleware\Models\List;
use App\Constants\Config\PlaylistConstants;
use App\Enums\Auth\SpecialPermission;
use App\GraphQL\Resolvers\List\Playlist\PlaylistTrackResolver;
use App\Models\Auth\User;
use App\Models\List\Playlist;
use Closure;
@@ -22,10 +23,10 @@ class PlaylistExceedsTrackLimit
$trackLimit = intval(Config::get(PlaylistConstants::MAX_TRACKS_QUALIFIED));
/** @var Playlist|null $playlist */
$playlist = $request->route('playlist');
$playlist = $request->route('playlist') ?? PlaylistTrackResolver::$playlist;
/** @var User|null $user */
$user = $request->user('sanctum');
$user = $request->user();
abort_if(intval($playlist?->tracks()?->count()) >= $trackLimit
&& $user?->cannot(SpecialPermission::BYPASS_FEATURE_FLAGS->value), 403, "Playlists cannot contain more than '$trackLimit' tracks.");
@@ -21,7 +21,7 @@ class UserExceedsExternalProfileLimit
$profileLimit = intval(Config::get(ExternalProfileConstants::MAX_PROFILES_QUALIFIED));
/** @var User|null $user */
$user = $request->user('sanctum');
$user = $request->user();
abort_if(intval($user?->externalprofiles()?->count()) >= $profileLimit
&& $user?->cannot(SpecialPermission::BYPASS_FEATURE_FLAGS->value), 403, "User cannot have more than '$profileLimit' external profiles.");
@@ -21,7 +21,7 @@ class UserExceedsPlaylistLimit
$playlistLimit = intval(Config::get(PlaylistConstants::MAX_PLAYLISTS_QUALIFIED));
/** @var User|null $user */
$user = $request->user('sanctum');
$user = $request->user();
abort_if(intval($user?->playlists()?->count()) >= $playlistLimit
&& $user?->cannot(SpecialPermission::BYPASS_FEATURE_FLAGS->value), 403, "User cannot have more than '$playlistLimit' playlists.");
+2 -3
View File
@@ -31,11 +31,10 @@ use App\Pivots\Wiki\ArtistSong;
use Database\Seeders\Auth\Permission\PermissionSeeder;
use Database\Seeders\Auth\Prohibition\ProhibitionSeeder;
use Database\Seeders\Auth\Role\AdminSeeder;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -51,7 +50,7 @@ class AppServiceProvider extends ServiceProvider
DB::prohibitDestructiveCommands(app()->isProduction());
EnsureFeaturesAreActive::whenInactive(fn (Request $request, array $features): Response => new Response(status: 403));
EnsureFeaturesAreActive::whenInactive(fn () => throw new AuthorizationException(code: 403));
ParallelTesting::setUpTestDatabase(function (string $database, int $token): void {
Artisan::call('db:seed', ['--class' => PermissionSeeder::class]);
@@ -29,8 +29,24 @@ class PlaylistTrackFactory extends Factory
*/
public function definition(): array
{
return [
//
];
return [];
}
/**
* Configure the model factory.
*/
public function configure(): static
{
return $this->afterCreating(function (PlaylistTrack $track): void {
if ($track->playlist_id === null) {
return;
}
$position = PlaylistTrack::query()
->whereBelongsTo($track->playlist)
->max(PlaylistTrack::ATTRIBUTE_POSITION) ?? 0;
$track->update([PlaylistTrack::ATTRIBUTE_POSITION => $position + 1]);
});
}
}
+4 -3
View File
@@ -7,8 +7,6 @@ namespace Database\Factories\List;
use App\Enums\Models\List\PlaylistVisibility;
use App\Models\List\Playlist;
use App\Models\List\Playlist\PlaylistTrack;
use App\Models\Wiki\Anime;
use App\Models\Wiki\Anime\AnimeTheme;
use App\Models\Wiki\Anime\Theme\AnimeThemeEntry;
use App\Models\Wiki\Video;
use App\Pivots\Wiki\AnimeThemeEntryVideo;
@@ -60,7 +58,7 @@ class PlaylistFactory extends Factory
$last = Arr::last($tracks);
$entryVideo = AnimeThemeEntryVideo::factory()
->for(AnimeThemeEntry::factory()->for(AnimeTheme::factory()->for(Anime::factory())))
->for(AnimeThemeEntry::factory())
->for(Video::factory())
->createOne();
@@ -71,10 +69,12 @@ class PlaylistFactory extends Factory
->createOne();
if ($index === 1) {
$track->moveToStart();
$playlist->first()->associate($track)->save();
}
if ($last !== null) {
$track->moveAfter($last);
$last->next()->associate($track);
$last->save();
@@ -83,6 +83,7 @@ class PlaylistFactory extends Factory
}
if ($index === $count) {
$track->moveToEnd();
$playlist->last()->associate($track);
$playlist->save();
}
@@ -5,10 +5,12 @@ declare(strict_types=1);
use App\Enums\Auth\CrudPermission;
use App\Enums\Models\List\PlaylistVisibility;
use App\Events\List\Playlist\PlaylistCreated;
use App\Features\AllowPlaylistManagement;
use App\Models\Auth\User;
use App\Models\List\Playlist;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Event;
use Laravel\Pennant\Feature;
use function Pest\Laravel\actingAs;
@@ -52,7 +54,30 @@ test('forbidden', function () {
$response->assertJsonPath('errors.0.extensions.category', 'authorization');
});
test('forbidden if feature flag is disabled', function () {
Feature::deactivate(AllowPlaylistManagement::class);
$user = User::factory()
->withPermissions(CrudPermission::CREATE->format(Playlist::class))
->createOne();
actingAs($user);
$response = graphql([
'query' => $this->mutation,
'variables' => [
'name' => fake()->word(),
'visibility' => Arr::random(PlaylistVisibility::cases())->name,
],
]);
$response->assertOk();
$response->assertJsonPath('errors.0.extensions.category', 'authorization');
});
it('creates', function () {
Feature::activate(AllowPlaylistManagement::class);
Event::fakeExcept(PlaylistCreated::class);
$user = User::factory()
@@ -4,9 +4,11 @@ declare(strict_types=1);
use App\Enums\Auth\CrudPermission;
use App\Events\List\Playlist\PlaylistCreated;
use App\Features\AllowPlaylistManagement;
use App\Models\Auth\User;
use App\Models\List\Playlist;
use Illuminate\Support\Facades\Event;
use Laravel\Pennant\Feature;
use function Pest\Laravel\actingAs;
@@ -49,7 +51,32 @@ test('forbidden', function () {
$response->assertJsonPath('errors.0.extensions.category', 'authorization');
});
test('forbidden if feature flag is disabled', function () {
Feature::deactivate(AllowPlaylistManagement::class);
Event::fakeExcept(PlaylistCreated::class);
$user = User::factory()
->withPermissions(CrudPermission::DELETE->format(Playlist::class))
->createOne();
actingAs($user);
$response = graphql([
'query' => $this->mutation,
'variables' => [
// Needed for the bind resolver.
'id' => Playlist::factory()->createOne()->hashid,
],
]);
$response->assertOk();
$response->assertJsonPath('errors.0.extensions.category', 'authorization');
});
test('forbidden if not owner', function () {
Feature::activate(AllowPlaylistManagement::class);
Event::fakeExcept(PlaylistCreated::class);
$user = User::factory()
@@ -70,6 +97,8 @@ test('forbidden if not owner', function () {
});
it('deletes', function () {
Feature::activate(AllowPlaylistManagement::class);
Event::fakeExcept(PlaylistCreated::class);
$user = User::factory()
@@ -5,12 +5,14 @@ declare(strict_types=1);
use App\Enums\Auth\CrudPermission;
use App\Events\List\Playlist\PlaylistCreated;
use App\Events\List\Playlist\Track\TrackCreated;
use App\Features\AllowPlaylistManagement;
use App\Models\Auth\User;
use App\Models\List\Playlist;
use App\Models\List\Playlist\PlaylistTrack;
use App\Models\Wiki\Anime\Theme\AnimeThemeEntry;
use App\Models\Wiki\Video;
use Illuminate\Support\Facades\Event;
use Laravel\Pennant\Feature;
use function Pest\Laravel\actingAs;
@@ -57,7 +59,34 @@ test('forbidden', function () {
$response->assertJsonPath('errors.0.extensions.category', 'authorization');
});
test('forbidden if feature flag is disabled', function () {
Feature::deactivate(AllowPlaylistManagement::class);
Event::fakeExcept([PlaylistCreated::class, TrackCreated::class]);
$user = User::factory()
->withPermissions(CrudPermission::CREATE->format(PlaylistTrack::class))
->createOne();
actingAs($user);
$response = graphql([
'query' => $this->mutation,
'variables' => [
// Needed for the bind resolver.
'playlist' => Playlist::factory()->createOne()->hashid,
'entryId' => AnimeThemeEntry::factory()->createOne()->getKey(),
'videoId' => Video::factory()->createOne()->getKey(),
],
]);
$response->assertOk();
$response->assertJsonPath('errors.0.extensions.category', 'authorization');
});
it('fails if no entry video link', function () {
Feature::activate(AllowPlaylistManagement::class);
Event::fakeExcept([PlaylistCreated::class, TrackCreated::class]);
$user = User::factory()
@@ -89,6 +118,8 @@ it('fails if no entry video link', function () {
});
it('creates', function () {
Feature::activate(AllowPlaylistManagement::class);
Event::fakeExcept([PlaylistCreated::class, TrackCreated::class]);
$user = User::factory()
@@ -5,10 +5,12 @@ declare(strict_types=1);
use App\Enums\Auth\CrudPermission;
use App\Events\List\Playlist\PlaylistCreated;
use App\Events\List\Playlist\Track\TrackCreated;
use App\Features\AllowPlaylistManagement;
use App\Models\Auth\User;
use App\Models\List\Playlist;
use App\Models\List\Playlist\PlaylistTrack;
use Illuminate\Support\Facades\Event;
use Laravel\Pennant\Feature;
use function Pest\Laravel\actingAs;
@@ -55,7 +57,36 @@ test('forbidden', function () {
$response->assertJsonPath('errors.0.extensions.category', 'authorization');
});
test('forbidden if feature flag is disabled', function () {
Feature::deactivate(AllowPlaylistManagement::class);
Event::fakeExcept([PlaylistCreated::class, TrackCreated::class]);
$user = User::factory()
->withPermissions(CrudPermission::DELETE->format(PlaylistTrack::class))
->createOne();
actingAs($user);
$track = PlaylistTrack::factory()
->for(Playlist::factory()->for($user))
->createOne();
$response = graphql([
'query' => $this->mutation,
'variables' => [
'playlist' => $track->playlist->hashid,
'id' => $track->hashid,
],
]);
$response->assertOk();
$response->assertJsonPath('errors.0.extensions.category', 'authorization');
});
test('forbidden if not owner', function () {
Feature::activate(AllowPlaylistManagement::class);
Event::fakeExcept([PlaylistCreated::class, TrackCreated::class]);
$user = User::factory()
@@ -81,6 +112,8 @@ test('forbidden if not owner', function () {
});
it('deletes', function () {
Feature::activate(AllowPlaylistManagement::class);
Event::fakeExcept([PlaylistCreated::class, TrackCreated::class]);
$user = User::factory()
@@ -5,12 +5,14 @@ declare(strict_types=1);
use App\Enums\Auth\CrudPermission;
use App\Events\List\Playlist\PlaylistCreated;
use App\Events\List\Playlist\Track\TrackCreated;
use App\Features\AllowPlaylistManagement;
use App\Models\Auth\User;
use App\Models\List\Playlist;
use App\Models\List\Playlist\PlaylistTrack;
use App\Models\Wiki\Anime\Theme\AnimeThemeEntry;
use App\Models\Wiki\Video;
use Illuminate\Support\Facades\Event;
use Laravel\Pennant\Feature;
use function Pest\Laravel\actingAs;
@@ -62,7 +64,36 @@ test('forbidden', function () {
$response->assertJsonPath('errors.0.extensions.category', 'authorization');
});
test('forbidden if feature flag is disabled', function () {
Feature::deactivate(AllowPlaylistManagement::class);
Event::fakeExcept([PlaylistCreated::class, TrackCreated::class]);
$user = User::factory()
->withPermissions(CrudPermission::UPDATE->format(PlaylistTrack::class))
->createOne();
actingAs($user);
$track = PlaylistTrack::factory()
->for(Playlist::factory()->for($user))
->createOne();
$response = graphql([
'query' => $this->mutation,
'variables' => [
'playlist' => $track->playlist->hashid,
'id' => $track->hashid,
],
]);
$response->assertOk();
$response->assertJsonPath('errors.0.extensions.category', 'authorization');
});
test('forbidden if not owner', function () {
Feature::activate(AllowPlaylistManagement::class);
Event::fakeExcept([PlaylistCreated::class, TrackCreated::class]);
$user = User::factory()
@@ -88,6 +119,8 @@ test('forbidden if not owner', function () {
});
it('updates', function () {
Feature::activate(AllowPlaylistManagement::class);
Event::fakeExcept([PlaylistCreated::class, TrackCreated::class]);
$user = User::factory()
@@ -4,9 +4,11 @@ declare(strict_types=1);
use App\Enums\Auth\CrudPermission;
use App\Events\List\Playlist\PlaylistCreated;
use App\Features\AllowPlaylistManagement;
use App\Models\Auth\User;
use App\Models\List\Playlist;
use Illuminate\Support\Facades\Event;
use Laravel\Pennant\Feature;
use function Pest\Laravel\actingAs;
@@ -51,7 +53,32 @@ test('forbidden', function () {
$response->assertJsonPath('errors.0.extensions.category', 'authorization');
});
test('forbidden if feature flag is disabled', function () {
Feature::deactivate(AllowPlaylistManagement::class);
Event::fakeExcept(PlaylistCreated::class);
$user = User::factory()
->withPermissions(CrudPermission::DELETE->format(Playlist::class))
->createOne();
actingAs($user);
$response = graphql([
'query' => $this->mutation,
'variables' => [
// Needed for the bind resolver.
'id' => Playlist::factory()->createOne()->hashid,
],
]);
$response->assertOk();
$response->assertJsonPath('errors.0.extensions.category', 'authorization');
});
test('forbidden if not owner', function () {
Feature::activate(AllowPlaylistManagement::class);
Event::fakeExcept(PlaylistCreated::class);
$user = User::factory()
@@ -72,6 +99,8 @@ test('forbidden if not owner', function () {
});
it('updates', function () {
Feature::activate(AllowPlaylistManagement::class);
Event::fakeExcept(PlaylistCreated::class);
$user = User::factory()
@@ -197,7 +197,7 @@ test('allowed include paths', function () {
$response = get(route('api.playlist.forward', ['playlist' => $playlist] + $parameters));
$tracks = PlaylistTrack::with($includedPaths->all())->get();
$tracks = $playlist->tracks()->with($includedPaths->all())->get();
$response->assertJson(
json_decode(