-@endforeach
\ No newline at end of file
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php
index 2b209d055..d16e390e9 100644
--- a/resources/views/layouts/app.blade.php
+++ b/resources/views/layouts/app.blade.php
@@ -1,17 +1,53 @@
-
-
+
+
- @include('layouts.analytics')
- @yield('content')
+
+
+
+
+ @livewire('navigation-menu')
+
+
+ @if (isset($header))
+
+ @endif
+
+
+
+ {{ $slot }}
+
+
+
+
- @yield('footer')
+
+ @stack('modals')
+
+ @livewireScripts
-
\ No newline at end of file
+
diff --git a/resources/views/layouts/footer.blade.php b/resources/views/layouts/footer.blade.php
index 2fc585d5d..17fa1901a 100644
--- a/resources/views/layouts/footer.blade.php
+++ b/resources/views/layouts/footer.blade.php
@@ -1,3 +1,30 @@
-
\ No newline at end of file
+
diff --git a/resources/views/layouts/grill.blade.php b/resources/views/layouts/grill.blade.php
deleted file mode 100644
index 79fd60471..000000000
--- a/resources/views/layouts/grill.blade.php
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
 }})
-
\ No newline at end of file
diff --git a/resources/views/layouts/guest.blade.php b/resources/views/layouts/guest.blade.php
new file mode 100644
index 000000000..32f45d929
--- /dev/null
+++ b/resources/views/layouts/guest.blade.php
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
{{ config('app.name', 'Laravel') }}
+
+
+
+
+
+
+
+
+
+ {{ $slot }}
+
+
+
+
+
+
diff --git a/resources/views/layouts/nav.blade.php b/resources/views/layouts/nav.blade.php
deleted file mode 100644
index 6e53168ac..000000000
--- a/resources/views/layouts/nav.blade.php
+++ /dev/null
@@ -1,12 +0,0 @@
-
diff --git a/resources/views/layouts/webm.blade.php b/resources/views/layouts/webm.blade.php
deleted file mode 100644
index a460ed935..000000000
--- a/resources/views/layouts/webm.blade.php
+++ /dev/null
@@ -1,3 +0,0 @@
-
- WebM support is required for inline playback - Install the latest Firefox or Botnet and come back <3
-
\ No newline at end of file
diff --git a/resources/views/mail/invitation.blade.php b/resources/views/mail/invitation.blade.php
new file mode 100644
index 000000000..a67a93868
--- /dev/null
+++ b/resources/views/mail/invitation.blade.php
@@ -0,0 +1,9 @@
+@component('mail::message')
+
+@lang('nova.invitation_message')
+
+@component('mail::button', ['url' => $url])
+@lang('nova.invitation_accept')
+@endcomponent
+
+@endcomponent
\ No newline at end of file
diff --git a/resources/views/navigation-menu.blade.php b/resources/views/navigation-menu.blade.php
new file mode 100644
index 000000000..a452bce07
--- /dev/null
+++ b/resources/views/navigation-menu.blade.php
@@ -0,0 +1,311 @@
+
diff --git a/resources/views/policy.blade.php b/resources/views/policy.blade.php
new file mode 100644
index 000000000..17e13593c
--- /dev/null
+++ b/resources/views/policy.blade.php
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+ {!! $policy !!}
+
+
+
+
diff --git a/resources/views/profile/delete-user-form.blade.php b/resources/views/profile/delete-user-form.blade.php
new file mode 100644
index 000000000..4c62ced11
--- /dev/null
+++ b/resources/views/profile/delete-user-form.blade.php
@@ -0,0 +1,52 @@
+
+
+ {{ __('Deactivate Account') }}
+
+
+
+ {{ __('Deactivate your account.') }}
+
+
+
+
+ {{ __('Once your account is deactivated, all of its resources and data will be deactivated. Before deactivating your account, please download any data or information that you wish to retain.') }}
+
+
+
+
+ {{ __('Deactivate Account') }}
+
+
+
+
+
+
+ {{ __('Deactivate Account') }}
+
+
+
+ {{ __('Are you sure you want to deactivate your account? Once your account is deactivated, all of its resources and data will be deactivated. Please enter your password to confirm you would like to deactivate your account.') }}
+
+
+
+
+
+
+
+
+
+
+ {{ __('Cancel') }}
+
+
+
+ {{ __('Deactivate Account') }}
+
+
+
+
+
diff --git a/resources/views/profile/logout-other-browser-sessions-form.blade.php b/resources/views/profile/logout-other-browser-sessions-form.blade.php
new file mode 100644
index 000000000..837b8d18e
--- /dev/null
+++ b/resources/views/profile/logout-other-browser-sessions-form.blade.php
@@ -0,0 +1,97 @@
+
+
+ {{ __('Browser Sessions') }}
+
+
+
+ {{ __('Manage and log out your active sessions on other browsers and devices.') }}
+
+
+
+
+ {{ __('If necessary, you may log out of all of your other browser sessions across all of your devices. Some of your recent sessions are listed below; however, this list may not be exhaustive. If you feel your account has been compromised, you should also update your password.') }}
+
+
+ @if (count($this->sessions) > 0)
+
+
+ @foreach ($this->sessions as $session)
+
+
+ @if ($session->agent->isDesktop())
+
+ @else
+
+ @endif
+
+
+
+
+ {{ $session->agent->platform() ? $session->agent->platform() : 'Unknown' }} - {{ $session->agent->browser() }}
+
+
+
+
+ {{ $session->ip_address }},
+
+ @if ($session->is_current_device)
+ {{ __('This device') }}
+ @else
+ {{ __('Last active') }} {{ $session->last_active }}
+ @endif
+
+
+
+
+ @endforeach
+
+ @endif
+
+
+
+ {{ __('Log Out Other Browser Sessions') }}
+
+
+
+ {{ __('Done.') }}
+
+
+
+
+
+
+ {{ __('Log Out Other Browser Sessions') }}
+
+
+
+ {{ __('Please enter your password to confirm you would like to log out of your other browser sessions across all of your devices.') }}
+
+
+
+
+
+
+
+
+
+
+ {{ __('Cancel') }}
+
+
+
+ {{ __('Log Out Other Browser Sessions') }}
+
+
+
+
+
diff --git a/resources/views/profile/show.blade.php b/resources/views/profile/show.blade.php
new file mode 100644
index 000000000..b8a79955c
--- /dev/null
+++ b/resources/views/profile/show.blade.php
@@ -0,0 +1,45 @@
+
+
+
+ {{ __('Profile') }}
+
+
+
+
+
+ @if (Laravel\Fortify\Features::canUpdateProfileInformation())
+ @livewire('profile.update-profile-information-form')
+
+
+ @endif
+
+ @if (Laravel\Fortify\Features::enabled(Laravel\Fortify\Features::updatePasswords()))
+
+ @livewire('profile.update-password-form')
+
+
+
+ @endif
+
+ @if (Laravel\Fortify\Features::canManageTwoFactorAuthentication())
+
+ @livewire('profile.two-factor-authentication-form')
+
+
+
+ @endif
+
+
+ @livewire('profile.logout-other-browser-sessions-form')
+
+
+ @if (Laravel\Jetstream\Jetstream::hasAccountDeletionFeatures())
+
+
+
+ @livewire('profile.delete-user-form')
+
+ @endif
+
+
+
diff --git a/resources/views/profile/two-factor-authentication-form.blade.php b/resources/views/profile/two-factor-authentication-form.blade.php
new file mode 100644
index 000000000..9e1e852a6
--- /dev/null
+++ b/resources/views/profile/two-factor-authentication-form.blade.php
@@ -0,0 +1,124 @@
+
+
+ {{ __('Two Factor Authentication') }}
+
+
+
+ {{ __('Add additional security to your account using two factor authentication.') }}
+
+
+
+
+ @if ($this->enabled)
+ @if ($showingConfirmation)
+ {{ __('Finish enabling two factor authentication.') }}
+ @else
+ {{ __('You have enabled two factor authentication.') }}
+ @endif
+ @else
+ {{ __('You have not enabled two factor authentication.') }}
+ @endif
+
+
+
+
+ {{ __('When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone\'s Google Authenticator application.') }}
+
+
+
+ @if ($this->enabled)
+ @if ($showingQrCode)
+
+
+ @if ($showingConfirmation)
+ {{ __('To finish enabling two factor authentication, scan the following QR code using your phone\'s authenticator application or enter the setup key and provide the generated OTP code.') }}
+ @else
+ {{ __('Two factor authentication is now enabled. Scan the following QR code using your phone\'s authenticator application or enter the setup key.') }}
+ @endif
+
+
+
+
+ {!! $this->user->twoFactorQrCodeSvg() !!}
+
+
+
+
+ {{ __('Setup Key') }}: {{ decrypt($this->user->two_factor_secret) }}
+
+
+
+ @if ($showingConfirmation)
+
+
+
+
+
+
+
+ @endif
+ @endif
+
+ @if ($showingRecoveryCodes)
+
+
+ {{ __('Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.') }}
+
+
+
+
+ @foreach (json_decode(decrypt($this->user->two_factor_recovery_codes), true) as $code)
+
{{ $code }}
+ @endforeach
+
+ @endif
+ @endif
+
+
+ @if (! $this->enabled)
+
+
+ {{ __('Enable') }}
+
+
+ @else
+ @if ($showingRecoveryCodes)
+
+
+ {{ __('Regenerate Recovery Codes') }}
+
+
+ @elseif ($showingConfirmation)
+
+
+ {{ __('Confirm') }}
+
+
+ @else
+
+
+ {{ __('Show Recovery Codes') }}
+
+
+ @endif
+
+ @if ($showingConfirmation)
+
+
+ {{ __('Cancel') }}
+
+
+ @else
+
+
+ {{ __('Disable') }}
+
+
+ @endif
+
+ @endif
+
+
+
diff --git a/resources/views/profile/update-password-form.blade.php b/resources/views/profile/update-password-form.blade.php
new file mode 100644
index 000000000..b5360056a
--- /dev/null
+++ b/resources/views/profile/update-password-form.blade.php
@@ -0,0 +1,39 @@
+
+
+ {{ __('Update Password') }}
+
+
+
+ {{ __('Ensure your account is using a long, random password to stay secure.') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __('Saved.') }}
+
+
+
+ {{ __('Save') }}
+
+
+
diff --git a/resources/views/profile/update-profile-information-form.blade.php b/resources/views/profile/update-profile-information-form.blade.php
new file mode 100644
index 000000000..5bededbcf
--- /dev/null
+++ b/resources/views/profile/update-profile-information-form.blade.php
@@ -0,0 +1,95 @@
+
+
+ {{ __('Profile Information') }}
+
+
+
+ {{ __('Update your account\'s profile information and email address.') }}
+
+
+
+
+ @if (Laravel\Jetstream\Jetstream::managesProfilePhotos())
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+ {{ __('Select A New Photo') }}
+
+
+ @if ($this->user->profile_photo_path)
+
+ {{ __('Remove Photo') }}
+
+ @endif
+
+
+
+ @endif
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @if (Laravel\Fortify\Features::enabled(Laravel\Fortify\Features::emailVerification()) && ! $this->user->hasVerifiedEmail())
+
+ {{ __('Your email address is unverified.') }}
+
+
+
+
+ @if ($this->verificationLinkSent)
+
+ {{ __('A new verification link has been sent to your email address.') }}
+
+ @endif
+ @endif
+
+
+
+
+
+ {{ __('Saved.') }}
+
+
+
+ {{ __('Save') }}
+
+
+
diff --git a/resources/views/sitemap/index.blade.php b/resources/views/sitemap/index.blade.php
deleted file mode 100644
index dc7ec08fe..000000000
--- a/resources/views/sitemap/index.blade.php
+++ /dev/null
@@ -1,8 +0,0 @@
-'; ?>
-
-
-
- {{ route('video_sitemap') }}
- {{ $video->updated_at->tz('UTC')->toAtomString() }}
-
-
\ No newline at end of file
diff --git a/resources/views/sitemap/videos.blade.php b/resources/views/sitemap/videos.blade.php
deleted file mode 100644
index 44460af57..000000000
--- a/resources/views/sitemap/videos.blade.php
+++ /dev/null
@@ -1,9 +0,0 @@
-'; ?>
-
-
- {{ route('video.index') }}
- {{ $video->updated_at->tz('UTC')->toAtomString() }}
- weekly
- 0.6
-
-
\ No newline at end of file
diff --git a/resources/views/terms.blade.php b/resources/views/terms.blade.php
new file mode 100644
index 000000000..bb8b1931d
--- /dev/null
+++ b/resources/views/terms.blade.php
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+ {!! $terms !!}
+
+
+
+
diff --git a/resources/views/vendor/jetstream/components/application-logo.blade.php b/resources/views/vendor/jetstream/components/application-logo.blade.php
new file mode 100644
index 000000000..66d024af1
--- /dev/null
+++ b/resources/views/vendor/jetstream/components/application-logo.blade.php
@@ -0,0 +1,4 @@
+
diff --git a/resources/views/vendor/jetstream/components/application-mark.blade.php b/resources/views/vendor/jetstream/components/application-mark.blade.php
new file mode 100644
index 000000000..66d024af1
--- /dev/null
+++ b/resources/views/vendor/jetstream/components/application-mark.blade.php
@@ -0,0 +1,4 @@
+
diff --git a/resources/views/vendor/jetstream/components/authentication-card-logo.blade.php b/resources/views/vendor/jetstream/components/authentication-card-logo.blade.php
new file mode 100644
index 000000000..ad5954e18
--- /dev/null
+++ b/resources/views/vendor/jetstream/components/authentication-card-logo.blade.php
@@ -0,0 +1,6 @@
+
+
+
diff --git a/resources/views/videos.blade.php b/resources/views/videos.blade.php
deleted file mode 100644
index b22e0c298..000000000
--- a/resources/views/videos.blade.php
+++ /dev/null
@@ -1,19 +0,0 @@
-@extends('layouts.app')
-
-@section('content')
-
-
AnimeThemes
-
A simple and consistent repository of anime opening and ending themes
-
- @include('layouts.announcements')
- @include('layouts.webm')
- @include('layouts.nav')
-
-
-
- @foreach ($videos as $video)
-
{{ $video->filename }}
- @endforeach
-
-
-@endsection
\ No newline at end of file
diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php
deleted file mode 100644
index 5d5187b1e..000000000
--- a/resources/views/welcome.blade.php
+++ /dev/null
@@ -1,20 +0,0 @@
-@extends('layouts.app')
-
-@section('content')
-
-
AnimeThemes
-
A simple and consistent repository of anime opening and ending themes
-
- @include('layouts.announcements')
- @include('layouts.webm')
- @include('layouts.nav')
- @include('layouts.grill')
-
-
-
-
-@endsection
-
-@section('footer')
- @include('layouts.footer')
-@endsection
\ No newline at end of file
diff --git a/routes/api.php b/routes/api.php
index 51535fb5f..ecfcb4da7 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -1,5 +1,10 @@
in(app_path('Http/Controllers/Api'));
+
+Route::fallback(function () {
+ abort(404);
+});
diff --git a/routes/channels.php b/routes/channels.php
index 480801e3e..14e3eac2b 100644
--- a/routes/channels.php
+++ b/routes/channels.php
@@ -1,5 +1,7 @@
in(app_path('Http/Controllers/Wiki'));
diff --git a/routes/web.php b/routes/web.php
index ba1b7c97a..c389d83b8 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -1,6 +1,9 @@
name('welcome');
-Route::resource('video', 'VideosController', ['only' => ['show']]);
-Route::get('/sitemap', 'SitemapController@index');
-Route::get('/sitemap/videos', 'SitemapController@videos')->name('video_sitemap');
+Discover::controllers()->in(app_path('Http/Controllers/Auth'));
+Discover::controllers()->in(app_path('Http/Controllers/Billing'));
+
+Route::redirect('/', url('/login'));
+
+Route::middleware(['auth', 'verified'])->get('/dashboard', function () {
+ return view('dashboard');
+})->name('dashboard');
diff --git a/server.php b/server.php
deleted file mode 100644
index 5fb6379e7..000000000
--- a/server.php
+++ /dev/null
@@ -1,21 +0,0 @@
-
- */
-
-$uri = urldecode(
- parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)
-);
-
-// This file allows us to emulate Apache's "mod_rewrite" functionality from the
-// built-in PHP web server. This provides a convenient way to test a Laravel
-// application without having installed a "real" web server software here.
-if ($uri !== '/' && file_exists(__DIR__.'/public'.$uri)) {
- return false;
-}
-
-require_once __DIR__.'/public/index.php';
diff --git a/storage/db-dumps/.gitignore b/storage/db-dumps/.gitignore
new file mode 100644
index 000000000..d6b7ef32c
--- /dev/null
+++ b/storage/db-dumps/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 000000000..2f59306ec
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,20 @@
+const defaultTheme = require('tailwindcss/defaultTheme');
+
+module.exports = {
+ content: [
+ './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',
+ './vendor/laravel/jetstream/**/*.blade.php',
+ './storage/framework/views/*.php',
+ './resources/views/**/*.blade.php',
+ ],
+
+ theme: {
+ extend: {
+ fontFamily: {
+ sans: ['Nunito', ...defaultTheme.fontFamily.sans],
+ },
+ },
+ },
+
+ plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')],
+};
diff --git a/tests/CreatesApplication.php b/tests/CreatesApplication.php
index 547152f6a..ac35c9d46 100644
--- a/tests/CreatesApplication.php
+++ b/tests/CreatesApplication.php
@@ -1,17 +1,23 @@
get('/login');
+
+ $response->assertStatus(200);
+ }
+
+ /**
+ * Users can authenticate using the login screen.
+ *
+ * @return void
+ */
+ public function testUsersCanAuthenticateUsingTheLoginScreen(): void
+ {
+ $user = User::factory()->createOne();
+
+ $response = $this->post('/login', [
+ 'email' => $user->email,
+ 'password' => 'password',
+ ]);
+
+ $this->assertAuthenticated();
+ $response->assertRedirect(RouteServiceProvider::HOME);
+ }
+
+ /**
+ * Users cannot authenticate with invalid password.
+ *
+ * @return void
+ */
+ public function testUsersCanNotAuthenticateWithInvalidPassword(): void
+ {
+ $user = User::factory()->createOne();
+
+ $this->post('/login', [
+ 'email' => $user->email,
+ 'password' => 'wrong-password',
+ ]);
+
+ $this->assertGuest();
+ }
+}
diff --git a/tests/Feature/Actions/Fortify/EmailVerificationTest.php b/tests/Feature/Actions/Fortify/EmailVerificationTest.php
new file mode 100644
index 000000000..8bc0000a5
--- /dev/null
+++ b/tests/Feature/Actions/Fortify/EmailVerificationTest.php
@@ -0,0 +1,96 @@
+createOne([
+ 'email_verified_at' => null,
+ ]);
+
+ $response = $this->actingAs($user)->get('/email/verify');
+
+ $response->assertStatus(200);
+ }
+
+ /**
+ * Email can be verified.
+ *
+ * @return void
+ */
+ public function testEmailCanBeVerified(): void
+ {
+ if (! Features::enabled(Features::emailVerification())) {
+ static::markTestSkipped('Email verification not enabled.');
+ }
+
+ Event::fake();
+
+ $user = User::factory()->createOne([
+ 'email_verified_at' => null,
+ ]);
+
+ $verificationUrl = URL::temporarySignedRoute(
+ 'verification.verify',
+ now()->addMinutes(60),
+ ['id' => $user->id, 'hash' => sha1($user->email)]
+ );
+
+ $response = $this->actingAs($user)->get($verificationUrl);
+
+ Event::assertDispatched(Verified::class);
+
+ static::assertTrue($user->fresh()->hasVerifiedEmail());
+ $response->assertRedirect(RouteServiceProvider::HOME.'?verified=1');
+ }
+
+ /**
+ * Email cannot be verified with invalid hash.
+ *
+ * @return void
+ */
+ public function testEmailCanNotBeVerifiedWithInvalidHash(): void
+ {
+ if (! Features::enabled(Features::emailVerification())) {
+ static::markTestSkipped('Email verification not enabled.');
+ }
+
+ $user = User::factory()->createOne([
+ 'email_verified_at' => null,
+ ]);
+
+ $verificationUrl = URL::temporarySignedRoute(
+ 'verification.verify',
+ now()->addMinutes(60),
+ ['id' => $user->id, 'hash' => sha1('wrong-email')]
+ );
+
+ $this->actingAs($user)->get($verificationUrl);
+
+ static::assertFalse($user->fresh()->hasVerifiedEmail());
+ }
+}
diff --git a/tests/Feature/Actions/Fortify/PasswordConfirmationTest.php b/tests/Feature/Actions/Fortify/PasswordConfirmationTest.php
new file mode 100644
index 000000000..29dd4de84
--- /dev/null
+++ b/tests/Feature/Actions/Fortify/PasswordConfirmationTest.php
@@ -0,0 +1,61 @@
+createOne();
+
+ $response = $this->actingAs($user)->get('/user/confirm-password');
+
+ $response->assertStatus(200);
+ }
+
+ /**
+ * Password can be confirmed.
+ *
+ * @return void
+ */
+ public function testPasswordCanBeConfirmed(): void
+ {
+ $user = User::factory()->createOne();
+
+ $response = $this->actingAs($user)->post('/user/confirm-password', [
+ 'password' => 'password',
+ ]);
+
+ $response->assertRedirect();
+ $response->assertSessionHasNoErrors();
+ }
+
+ /**
+ * Password is not confirmed with invalid password.
+ *
+ * @return void
+ */
+ public function testPasswordIsNotConfirmedWithInvalidPassword(): void
+ {
+ $user = User::factory()->createOne();
+
+ $response = $this->actingAs($user)->post('/user/confirm-password', [
+ 'password' => 'wrong-password',
+ ]);
+
+ $response->assertSessionHasErrors();
+ }
+}
diff --git a/tests/Feature/Actions/Fortify/PasswordResetTest.php b/tests/Feature/Actions/Fortify/PasswordResetTest.php
new file mode 100644
index 000000000..4cd7e9634
--- /dev/null
+++ b/tests/Feature/Actions/Fortify/PasswordResetTest.php
@@ -0,0 +1,121 @@
+get('/forgot-password');
+
+ $response->assertStatus(200);
+ }
+
+ /**
+ * Reset password link can be requested.
+ *
+ * @return void
+ */
+ public function testResetPasswordLinkCanBeRequested(): void
+ {
+ if (! Features::enabled(Features::resetPasswords())) {
+ static::markTestSkipped('Password resets are not enabled.');
+ }
+
+ Notification::fake();
+
+ $user = User::factory()->createOne();
+
+ $this->post('/forgot-password', [
+ 'email' => $user->email,
+ ]);
+
+ Notification::assertSentTo($user, ResetPassword::class);
+ }
+
+ /**
+ * Reset password screen can be rendered.
+ *
+ * @return void
+ */
+ public function testResetPasswordScreenCanBeRendered(): void
+ {
+ if (! Features::enabled(Features::resetPasswords())) {
+ static::markTestSkipped('Password resets are not enabled.');
+ }
+
+ Notification::fake();
+
+ $user = User::factory()->createOne();
+
+ $this->post('/forgot-password', [
+ 'email' => $user->email,
+ ]);
+
+ Notification::assertSentTo($user, ResetPassword::class, function (ResetPassword $notification) {
+ $response = $this->get('/reset-password/'.$notification->token);
+
+ $response->assertStatus(200);
+
+ return true;
+ });
+ }
+
+ /**
+ * Password can be reset with valid token.
+ *
+ * @return void
+ */
+ public function testPasswordCanBeResetWithValidToken(): void
+ {
+ if (! Features::enabled(Features::resetPasswords())) {
+ static::markTestSkipped('Password resets are not enabled.');
+ }
+
+ Notification::fake();
+
+ $user = User::factory()->createOne();
+
+ $this->post('/forgot-password', [
+ 'email' => $user->email,
+ ]);
+
+ $strongPassword = $this->faker->password(64, 128);
+
+ Notification::assertSentTo($user, ResetPassword::class, function (ResetPassword $notification) use ($user, $strongPassword) {
+ $response = $this->post('/reset-password', [
+ 'token' => $notification->token,
+ 'email' => $user->email,
+ 'password' => $strongPassword,
+ 'password_confirmation' => $strongPassword,
+ ]);
+
+ $response->assertSessionHasNoErrors();
+
+ return true;
+ });
+ }
+}
diff --git a/tests/Feature/Actions/Jetstream/BrowserSessionsTest.php b/tests/Feature/Actions/Jetstream/BrowserSessionsTest.php
new file mode 100644
index 000000000..9c135eaed
--- /dev/null
+++ b/tests/Feature/Actions/Jetstream/BrowserSessionsTest.php
@@ -0,0 +1,30 @@
+actingAs(User::factory()->createOne());
+
+ Livewire::test(LogoutOtherBrowserSessionsForm::class)
+ ->set('password', 'password')
+ ->call('logoutOtherBrowserSessions');
+ }
+}
diff --git a/tests/Feature/Actions/Jetstream/CreateApiTokenTest.php b/tests/Feature/Actions/Jetstream/CreateApiTokenTest.php
new file mode 100644
index 000000000..795597f07
--- /dev/null
+++ b/tests/Feature/Actions/Jetstream/CreateApiTokenTest.php
@@ -0,0 +1,41 @@
+actingAs($user = User::factory()->createOne());
+
+ Livewire::test(ApiTokenManager::class)
+ ->set(['createApiTokenForm' => [
+ 'name' => 'Test Token',
+ 'permissions' => [],
+ ]])
+ ->call('createApiToken');
+
+ static::assertCount(1, $user->fresh()->tokens);
+ static::assertEquals('Test Token', $user->fresh()->tokens->first()->name);
+ }
+}
diff --git a/tests/Feature/Actions/Jetstream/DeleteAccountTest.php b/tests/Feature/Actions/Jetstream/DeleteAccountTest.php
new file mode 100644
index 000000000..24262b0bd
--- /dev/null
+++ b/tests/Feature/Actions/Jetstream/DeleteAccountTest.php
@@ -0,0 +1,58 @@
+actingAs($user = User::factory()->createOne());
+
+ Livewire::test(DeleteUserForm::class)
+ ->set('password', 'password')
+ ->call('deleteUser');
+
+ static::assertSoftDeleted($user->fresh());
+ }
+
+ /**
+ * Correct password must be provided before account can be deleted.
+ *
+ * @return void
+ */
+ public function testCorrectPasswordMustBeProvidedBeforeAccountCanBeDeleted(): void
+ {
+ if (! Features::hasAccountDeletionFeatures()) {
+ static::markTestSkipped('Account deletion is not enabled.');
+ }
+
+ $this->actingAs($user = User::factory()->createOne());
+
+ Livewire::test(DeleteUserForm::class)
+ ->set('password', 'wrong-password')
+ ->call('deleteUser')
+ ->assertHasErrors(['password']);
+
+ static::assertNotSoftDeleted($user->fresh());
+ }
+}
diff --git a/tests/Feature/Actions/Jetstream/DeleteApiTokenTest.php b/tests/Feature/Actions/Jetstream/DeleteApiTokenTest.php
new file mode 100644
index 000000000..57c3c2ded
--- /dev/null
+++ b/tests/Feature/Actions/Jetstream/DeleteApiTokenTest.php
@@ -0,0 +1,44 @@
+actingAs($user = User::factory()->createOne());
+
+ $token = $user->tokens()->create([
+ 'name' => 'Test Token',
+ 'token' => Str::random(40),
+ 'abilities' => ['create anime', 'read anime'],
+ ]);
+
+ Livewire::test(ApiTokenManager::class)
+ ->set(['apiTokenIdBeingDeleted' => $token->getKey()])
+ ->call('deleteApiToken');
+
+ static::assertCount(0, $user->fresh()->tokens);
+ }
+}
diff --git a/tests/Feature/Actions/Jetstream/ProfileInformationTest.php b/tests/Feature/Actions/Jetstream/ProfileInformationTest.php
new file mode 100644
index 000000000..352c8005a
--- /dev/null
+++ b/tests/Feature/Actions/Jetstream/ProfileInformationTest.php
@@ -0,0 +1,50 @@
+actingAs($user = User::factory()->createOne());
+
+ $component = Livewire::test(UpdateProfileInformationForm::class);
+
+ static::assertEquals($user->name, $component->state['name']);
+ static::assertEquals($user->email, $component->state['email']);
+ }
+
+ /**
+ * Profile information can be updated.
+ *
+ * @return void
+ */
+ public function testProfileInformationCanBeUpdated(): void
+ {
+ $this->actingAs($user = User::factory()->createOne());
+
+ Livewire::test(UpdateProfileInformationForm::class)
+ ->set('state', ['name' => 'Test Name', 'email' => 'test@example.com'])
+ ->call('updateProfileInformation');
+
+ static::assertEquals('Test Name', $user->fresh()->name);
+ static::assertEquals('test@example.com', $user->fresh()->email);
+ }
+}
diff --git a/tests/Feature/Actions/Jetstream/TwoFactorAuthenticationSettingsTest.php b/tests/Feature/Actions/Jetstream/TwoFactorAuthenticationSettingsTest.php
new file mode 100644
index 000000000..beb191852
--- /dev/null
+++ b/tests/Feature/Actions/Jetstream/TwoFactorAuthenticationSettingsTest.php
@@ -0,0 +1,80 @@
+actingAs($user = User::factory()->createOne());
+
+ $this->withSession(['auth.password_confirmed_at' => time()]);
+
+ Livewire::test(TwoFactorAuthenticationForm::class)
+ ->call('enableTwoFactorAuthentication');
+
+ $user = $user->fresh();
+
+ static::assertNotNull($user->two_factor_secret);
+ static::assertCount(8, $user->recoveryCodes());
+ }
+
+ /**
+ * Recovery codes can be regenerated.
+ *
+ * @return void
+ */
+ public function testRecoveryCodesCanBeRegenerated(): void
+ {
+ $this->actingAs($user = User::factory()->createOne());
+
+ $this->withSession(['auth.password_confirmed_at' => time()]);
+
+ $component = Livewire::test(TwoFactorAuthenticationForm::class)
+ ->call('enableTwoFactorAuthentication')
+ ->call('regenerateRecoveryCodes');
+
+ $user = $user->fresh();
+
+ $component->call('regenerateRecoveryCodes');
+
+ static::assertCount(8, $user->recoveryCodes());
+ static::assertCount(8, array_diff($user->recoveryCodes(), $user->fresh()->recoveryCodes()));
+ }
+
+ /**
+ * Two-factor authentication can be disabled.
+ *
+ * @return void
+ */
+ public function testTwoFactorAuthenticationCanBeDisabled(): void
+ {
+ $this->actingAs($user = User::factory()->createOne());
+
+ $this->withSession(['auth.password_confirmed_at' => time()]);
+
+ $component = Livewire::test(TwoFactorAuthenticationForm::class)
+ ->call('enableTwoFactorAuthentication');
+
+ static::assertNotNull($user->fresh()->two_factor_secret);
+
+ $component->call('disableTwoFactorAuthentication');
+
+ static::assertNull($user->fresh()->two_factor_secret);
+ }
+}
diff --git a/tests/Feature/Actions/Jetstream/UpdatePasswordTest.php b/tests/Feature/Actions/Jetstream/UpdatePasswordTest.php
new file mode 100644
index 000000000..d41f611be
--- /dev/null
+++ b/tests/Feature/Actions/Jetstream/UpdatePasswordTest.php
@@ -0,0 +1,86 @@
+actingAs($user = User::factory()->createOne());
+
+ $newPassword = $this->faker->password(64, 128);
+
+ Livewire::test(UpdatePasswordForm::class)
+ ->set('state', [
+ 'current_password' => 'password',
+ 'password' => $newPassword,
+ 'password_confirmation' => $newPassword,
+ ])
+ ->call('updatePassword');
+
+ static::assertTrue(Hash::check($newPassword, $user->fresh()->password));
+ }
+
+ /**
+ * Password must be correct.
+ *
+ * @return void
+ */
+ public function testCurrentPasswordMustBeCorrect(): void
+ {
+ $this->actingAs($user = User::factory()->createOne());
+
+ $newPassword = $this->faker->password(64, 128);
+
+ Livewire::test(UpdatePasswordForm::class)
+ ->set('state', [
+ 'current_password' => 'wrong-password',
+ 'password' => $newPassword,
+ 'password_confirmation' => $newPassword,
+ ])
+ ->call('updatePassword')
+ ->assertHasErrors(['current_password']);
+
+ static::assertTrue(Hash::check('password', $user->fresh()->password));
+ }
+
+ /**
+ * New passwords must match.
+ *
+ * @return void
+ */
+ public function testNewPasswordsMustMatch(): void
+ {
+ $this->actingAs($user = User::factory()->createOne());
+
+ Livewire::test(UpdatePasswordForm::class)
+ ->set('state', [
+ 'current_password' => 'password',
+ 'password' => 'new-password',
+ 'password_confirmation' => 'wrong-password',
+ ])
+ ->call('updatePassword')
+ ->assertHasErrors(['password']);
+
+ static::assertTrue(Hash::check('password', $user->fresh()->password));
+ }
+}
diff --git a/tests/Feature/Console/Commands/Billing/BalanceReconcileTest.php b/tests/Feature/Console/Commands/Billing/BalanceReconcileTest.php
new file mode 100644
index 000000000..6fbe843ff
--- /dev/null
+++ b/tests/Feature/Console/Commands/Billing/BalanceReconcileTest.php
@@ -0,0 +1,140 @@
+expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Not enough arguments (missing: "service").');
+
+ $this->artisan(BalanceReconcileCommand::class)->run();
+ }
+
+ /**
+ * When service is Other, the Reconcile Balance Command shall output "No source repository implemented for Service 'other'".
+ *
+ * @return void
+ */
+ public function testOther(): void
+ {
+ $other = Service::OTHER()->key;
+
+ $this->artisan(BalanceReconcileCommand::class, ['service' => $other])->expectsOutput("No source repository implemented for Service '$other'");
+ }
+
+ /**
+ * If no changes are needed, the Reconcile Balance Command shall output 'No Balances created or deleted or updated'.
+ *
+ * @return void
+ */
+ public function testNoResults(): void
+ {
+ $this->mock(DigitalOceanBalanceRepository::class, function (MockInterface $mock) {
+ $mock->shouldReceive('get')->once()->andReturn(Collection::make());
+ });
+
+ $this->artisan(BalanceReconcileCommand::class, ['service' => Service::DIGITALOCEAN()->key])->expectsOutput('No Balances created or deleted or updated');
+ }
+
+ /**
+ * If balances are created, the Reconcile Balance Command shall output '{Created Count} Balances created, 0 Balances deleted, 0 Balances updated'.
+ *
+ * @return void
+ */
+ public function testCreated(): void
+ {
+ $this->baseRefreshDatabase(); // Cannot lazily refresh database within pending command
+
+ $createdBalanceCount = $this->faker->randomDigitNotNull();
+
+ $balances = Balance::factory()
+ ->count($createdBalanceCount)
+ ->make([
+ Balance::ATTRIBUTE_DATE => Date::now()->format(AllowedDateFormat::YMD),
+ Balance::ATTRIBUTE_SERVICE => Service::DIGITALOCEAN,
+ ]);
+
+ $this->mock(DigitalOceanBalanceRepository::class, function (MockInterface $mock) use ($balances) {
+ $mock->shouldReceive('get')->once()->andReturn($balances);
+ });
+
+ $this->artisan(BalanceReconcileCommand::class, ['service' => Service::DIGITALOCEAN()->key])->expectsOutput("$createdBalanceCount Balances created, 0 Balances deleted, 0 Balances updated");
+ }
+
+ /**
+ * If balances are deleted, the Reconcile Balance Command shall output '0 Balances created, {Deleted Count} Balances deleted, 0 Balances updated'.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $deletedBalanceCount = $this->faker->randomDigitNotNull();
+
+ Balance::factory()
+ ->count($deletedBalanceCount)
+ ->create([
+ Balance::ATTRIBUTE_DATE => Date::now()->format(AllowedDateFormat::YMD),
+ Balance::ATTRIBUTE_SERVICE => Service::DIGITALOCEAN,
+ ]);
+
+ $this->mock(DigitalOceanBalanceRepository::class, function (MockInterface $mock) {
+ $mock->shouldReceive('get')->once()->andReturn(Collection::make());
+ });
+
+ $this->artisan(BalanceReconcileCommand::class, ['service' => Service::DIGITALOCEAN()->key])->expectsOutput("0 Balances created, $deletedBalanceCount Balances deleted, 0 Balances updated");
+ }
+
+ /**
+ * If balances are updated, the Reconcile Balance Command shall output '0 Balances created, 0 Balances deleted, {Updated Count} Balances updated'.
+ *
+ * @return void
+ */
+ public function testUpdated(): void
+ {
+ Balance::factory()
+ ->create([
+ Balance::ATTRIBUTE_DATE => Date::now()->format(AllowedDateFormat::YMD),
+ Balance::ATTRIBUTE_SERVICE => Service::DIGITALOCEAN,
+ ]);
+
+ $sourceBalances = Balance::factory()
+ ->make([
+ Balance::ATTRIBUTE_DATE => Date::now()->format(AllowedDateFormat::YMD),
+ Balance::ATTRIBUTE_SERVICE => Service::DIGITALOCEAN,
+ ]);
+
+ $this->mock(DigitalOceanBalanceRepository::class, function (MockInterface $mock) use ($sourceBalances) {
+ $mock->shouldReceive('get')->once()->andReturn(collect([$sourceBalances]));
+ });
+
+ $this->artisan(BalanceReconcileCommand::class, ['service' => Service::DIGITALOCEAN()->key])->expectsOutput('0 Balances created, 0 Balances deleted, 1 Balances updated');
+ }
+}
diff --git a/tests/Feature/Console/Commands/Billing/TransactionReconcileTest.php b/tests/Feature/Console/Commands/Billing/TransactionReconcileTest.php
new file mode 100644
index 000000000..812ee26b9
--- /dev/null
+++ b/tests/Feature/Console/Commands/Billing/TransactionReconcileTest.php
@@ -0,0 +1,110 @@
+expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Not enough arguments (missing: "service").');
+
+ $this->artisan(TransactionReconcileCommand::class)->run();
+ }
+
+ /**
+ * When service is Other, the Reconcile Transaction Command shall output "No source repository implemented for Service 'other'".
+ *
+ * @return void
+ */
+ public function testOther(): void
+ {
+ $other = Service::OTHER()->key;
+
+ $this->artisan(TransactionReconcileCommand::class, ['service' => $other])->expectsOutput("No source repository implemented for Service '$other'");
+ }
+
+ /**
+ * If no changes are needed, the Reconcile Transaction Command shall output 'No Transactions created or deleted or updated'.
+ *
+ * @return void
+ */
+ public function testNoResults(): void
+ {
+ $this->mock(DigitalOceanTransactionRepository::class, function (MockInterface $mock) {
+ $mock->shouldReceive('get')->once()->andReturn(Collection::make());
+ });
+
+ $this->artisan(TransactionReconcileCommand::class, ['service' => Service::DIGITALOCEAN()->key])->expectsOutput('No Transactions created or deleted or updated');
+ }
+
+ /**
+ * If transactions are created, the Reconcile Transaction Command shall output '{Created Count} Transactions created, 0 Transactions deleted, 0 Transactions updated'.
+ *
+ * @return void
+ */
+ public function testCreated(): void
+ {
+ $this->baseRefreshDatabase(); // Cannot lazily refresh database within pending command
+
+ $createdTransactionCount = $this->faker->randomDigitNotNull();
+
+ $transactions = Transaction::factory()
+ ->count($createdTransactionCount)
+ ->make([
+ Transaction::ATTRIBUTE_SERVICE => Service::DIGITALOCEAN,
+ ]);
+
+ $this->mock(DigitalOceanTransactionRepository::class, function (MockInterface $mock) use ($transactions) {
+ $mock->shouldReceive('get')->once()->andReturn($transactions);
+ });
+
+ $this->artisan(TransactionReconcileCommand::class, ['service' => Service::DIGITALOCEAN()->key])->expectsOutput("$createdTransactionCount Transactions created, 0 Transactions deleted, 0 Transactions updated");
+ }
+
+ /**
+ * If transactions are deleted, the Reconcile Transaction Command shall output '0 Transactions created, {Deleted Count} Transactions deleted, 0 Transactions updated'.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $deletedTransactionCount = $this->faker->randomDigitNotNull();
+
+ Transaction::factory()
+ ->count($deletedTransactionCount)
+ ->create([
+ Transaction::ATTRIBUTE_SERVICE => Service::DIGITALOCEAN,
+ ]);
+
+ $this->mock(DigitalOceanTransactionRepository::class, function (MockInterface $mock) {
+ $mock->shouldReceive('get')->once()->andReturn(Collection::make());
+ });
+
+ $this->artisan(TransactionReconcileCommand::class, ['service' => Service::DIGITALOCEAN()->key])->expectsOutput("0 Transactions created, $deletedTransactionCount Transactions deleted, 0 Transactions updated");
+ }
+}
diff --git a/tests/Feature/Console/Commands/Document/DocumentDatabaseDumpTest.php b/tests/Feature/Console/Commands/Document/DocumentDatabaseDumpTest.php
new file mode 100644
index 000000000..bacf532f3
--- /dev/null
+++ b/tests/Feature/Console/Commands/Document/DocumentDatabaseDumpTest.php
@@ -0,0 +1,61 @@
+faker->iso8601());
+
+ $this->artisan(DocumentDatabaseDumpCommand::class, ['--create' => $this->faker->boolean()])
+ ->assertSuccessful()
+ ->expectsOutputToContain('has been created');
+ }
+
+ /**
+ * The Database Dump Command shall produce a file in the /path/to/project/storage/db-dumps directory.
+ *
+ * @return void
+ */
+ public function testDataBaseDumpFile(): void
+ {
+ if (! DatabaseDumpCommand::canIncludeTables(DB::connection())) {
+ static::markTestSkipped('DB connection does not support includeTables option');
+ }
+
+ Storage::fake('db-dumps');
+
+ Date::setTestNow($this->faker->iso8601());
+
+ $this->artisan(DocumentDatabaseDumpCommand::class, ['--create' => $this->faker->boolean()])->run();
+
+ static::assertCount(1, Storage::disk('db-dumps')->allFiles());
+ }
+}
diff --git a/tests/Feature/Console/Commands/Wiki/PruneDatabaseDumpsTest.php b/tests/Feature/Console/Commands/Wiki/PruneDatabaseDumpsTest.php
new file mode 100644
index 000000000..89bd5a572
--- /dev/null
+++ b/tests/Feature/Console/Commands/Wiki/PruneDatabaseDumpsTest.php
@@ -0,0 +1,61 @@
+artisan(PruneDatabaseDumpsCommand::class, ['--hours' => 0])->expectsOutput('No database dumps deleted');
+ }
+
+ /**
+ * If dumps are deleted, the Prune Database Dumps Command shall output '{Deleted Count} database dumps deleted'.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ if (! DatabaseDumpCommand::canIncludeTables(DB::connection())) {
+ static::markTestSkipped('DB connection does not support includeTables option');
+ }
+
+ Storage::fake('db-dumps');
+
+ $deletedCount = $this->faker->randomDigitNotNull();
+
+ Collection::times($deletedCount, function () {
+ Date::setTestNow($this->faker->iso8601());
+
+ $this->artisan(WikiDatabaseDumpCommand::class)->run();
+ });
+
+ Date::setTestNow();
+
+ $this->artisan(PruneDatabaseDumpsCommand::class, ['--hours' => -1])->expectsOutput("$deletedCount database dumps deleted");
+ }
+}
diff --git a/tests/Feature/Console/Commands/Wiki/VideoReconcileTest.php b/tests/Feature/Console/Commands/Wiki/VideoReconcileTest.php
new file mode 100644
index 000000000..162ab79bd
--- /dev/null
+++ b/tests/Feature/Console/Commands/Wiki/VideoReconcileTest.php
@@ -0,0 +1,103 @@
+mock(VideoRepository::class, function (MockInterface $mock) {
+ $mock->shouldReceive('get')->once()->andReturn(Collection::make());
+ });
+
+ $this->artisan(VideoReconcileCommand::class)->expectsOutput('No Videos created or deleted or updated');
+ }
+
+ /**
+ * If videos are created, the Reconcile Video Command shall output '{Created Count} Videos created, 0 Videos deleted, 0 Videos updated'.
+ *
+ * @return void
+ */
+ public function testCreated(): void
+ {
+ $this->baseRefreshDatabase(); // Cannot lazily refresh database within pending command
+
+ $createdVideoCount = $this->faker->randomDigitNotNull();
+
+ $videos = Video::factory()->count($createdVideoCount)->make();
+
+ $this->mock(VideoRepository::class, function (MockInterface $mock) use ($videos) {
+ $mock->shouldReceive('get')->once()->andReturn($videos);
+ });
+
+ $this->artisan(VideoReconcileCommand::class)->expectsOutput("$createdVideoCount Videos created, 0 Videos deleted, 0 Videos updated");
+ }
+
+ /**
+ * If videos are deleted, the Reconcile Video Command shall output '0 Videos created, {Deleted Count} Videos deleted, 0 Videos updated'.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $deletedVideoCount = $this->faker->randomDigitNotNull();
+
+ Video::factory()->count($deletedVideoCount)->create();
+
+ $this->mock(VideoRepository::class, function (MockInterface $mock) {
+ $mock->shouldReceive('get')->once()->andReturn(Collection::make());
+ });
+
+ $this->artisan(VideoReconcileCommand::class)->expectsOutput("0 Videos created, $deletedVideoCount Videos deleted, 0 Videos updated");
+ }
+
+ /**
+ * If videos are updated, the Reconcile Video Command shall output '0 Videos created, 0 Videos deleted, {Updated Count} Videos updated'.
+ *
+ * @return void
+ */
+ public function testUpdated(): void
+ {
+ $updatedVideoCount = $this->faker->randomDigitNotNull();
+
+ $basenames = collect($this->faker->words($updatedVideoCount));
+
+ Video::factory()
+ ->count($updatedVideoCount)
+ ->sequence(fn ($sequence) => [Video::ATTRIBUTE_BASENAME => $basenames->get($sequence->index)])
+ ->create();
+
+ $sourceVideos = Video::factory()
+ ->count($updatedVideoCount)
+ ->sequence(fn ($sequence) => [Video::ATTRIBUTE_BASENAME => $basenames->get($sequence->index)])
+ ->create();
+
+ $this->mock(VideoRepository::class, function (MockInterface $mock) use ($sourceVideos) {
+ $mock->shouldReceive('get')->once()->andReturn($sourceVideos);
+ });
+
+ $this->artisan(VideoReconcileCommand::class)->expectsOutput("0 Videos created, 0 Videos deleted, $updatedVideoCount Videos updated");
+ }
+}
diff --git a/tests/Feature/Console/Commands/Wiki/WikiDatabaseDumpTest.php b/tests/Feature/Console/Commands/Wiki/WikiDatabaseDumpTest.php
new file mode 100644
index 000000000..c41f27dbc
--- /dev/null
+++ b/tests/Feature/Console/Commands/Wiki/WikiDatabaseDumpTest.php
@@ -0,0 +1,61 @@
+faker->iso8601());
+
+ $this->artisan(WikiDatabaseDumpCommand::class, ['--create' => $this->faker->boolean()])
+ ->assertSuccessful()
+ ->expectsOutputToContain('has been created');
+ }
+
+ /**
+ * The Database Dump Command shall produce a file in the /path/to/project/storage/db-dumps directory.
+ *
+ * @return void
+ */
+ public function testDataBaseDumpFile(): void
+ {
+ if (! DatabaseDumpCommand::canIncludeTables(DB::connection())) {
+ static::markTestSkipped('DB connection does not support includeTables option');
+ }
+
+ Storage::fake('db-dumps');
+
+ Date::setTestNow($this->faker->iso8601());
+
+ $this->artisan(WikiDatabaseDumpCommand::class, ['--create' => $this->faker->boolean()])->run();
+
+ static::assertCount(1, Storage::disk('db-dumps')->allFiles());
+ }
+}
diff --git a/tests/Feature/Events/Admin/AnnouncementTest.php b/tests/Feature/Events/Admin/AnnouncementTest.php
new file mode 100644
index 000000000..7adf0d4e2
--- /dev/null
+++ b/tests/Feature/Events/Admin/AnnouncementTest.php
@@ -0,0 +1,101 @@
+create();
+
+ Event::assertDispatched(AnnouncementCreated::class);
+ }
+
+ /**
+ * When an Announcement is deleted, an AnnouncementDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnnouncementDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $announcement = Announcement::factory()->create();
+
+ $announcement->delete();
+
+ Event::assertDispatched(AnnouncementDeleted::class);
+ }
+
+ /**
+ * When an Announcement is restored, an AnnouncementRestored event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnnouncementRestoredEventDispatched(): void
+ {
+ Event::fake();
+
+ $announcement = Announcement::factory()->createOne();
+
+ $announcement->restore();
+
+ Event::assertDispatched(AnnouncementRestored::class);
+ }
+
+ /**
+ * When an Announcement is restored, an AnnouncementUpdated event shall not be dispatched.
+ * Note: This is a customization that overrides default framework behavior.
+ * An updated event is fired on restore.
+ *
+ * @return void
+ */
+ public function testAnnouncementRestoresQuietly(): void
+ {
+ Event::fake();
+
+ $announcement = Announcement::factory()->createOne();
+
+ $announcement->restore();
+
+ Event::assertNotDispatched(AnnouncementUpdated::class);
+ }
+
+ /**
+ * When an Announcement is updated, an AnnouncementUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnnouncementUpdatedEventDispatched(): void
+ {
+ Event::fake();
+
+ $announcement = Announcement::factory()->createOne();
+ $changes = Announcement::factory()->makeOne();
+
+ $announcement->fill($changes->getAttributes());
+ $announcement->save();
+
+ Event::assertDispatched(AnnouncementUpdated::class);
+ }
+}
diff --git a/tests/Feature/Events/Auth/InvitationTest.php b/tests/Feature/Events/Auth/InvitationTest.php
new file mode 100644
index 000000000..2acd0238a
--- /dev/null
+++ b/tests/Feature/Events/Auth/InvitationTest.php
@@ -0,0 +1,101 @@
+createOne();
+
+ Event::assertDispatched(InvitationCreated::class);
+ }
+
+ /**
+ * When an Invitation is deleted, an InvitationDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testInvitationDeletedEventDispatched(): void
+ {
+ Event::fake(InvitationDeleted::class);
+
+ $invitation = Invitation::factory()->createOne();
+
+ $invitation->delete();
+
+ Event::assertDispatched(InvitationDeleted::class);
+ }
+
+ /**
+ * When an Invitation is restored, an InvitationRestored event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testInvitationRestoredEventDispatched(): void
+ {
+ Event::fake(InvitationRestored::class);
+
+ $invitation = Invitation::factory()->createOne();
+
+ $invitation->restore();
+
+ Event::assertDispatched(InvitationRestored::class);
+ }
+
+ /**
+ * When an Invitation is restored, an InvitationUpdated event shall not be dispatched.
+ * Note: This is a customization that overrides default framework behavior.
+ * An updated event is fired on restore.
+ *
+ * @return void
+ */
+ public function testInvitationRestoresQuietly(): void
+ {
+ Event::fake();
+
+ $invitation = Invitation::factory()->createOne();
+
+ $invitation->restore();
+
+ Event::assertNotDispatched(InvitationUpdated::class);
+ }
+
+ /**
+ * When an Invitation is updated, an InvitationUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testInvitationUpdatedEventDispatched(): void
+ {
+ Event::fake(InvitationUpdated::class);
+
+ $invitation = Invitation::factory()->createOne();
+ $changes = Invitation::factory()->makeOne();
+
+ $invitation->fill($changes->getAttributes());
+ $invitation->save();
+
+ Event::assertDispatched(InvitationUpdated::class);
+ }
+}
diff --git a/tests/Feature/Events/Auth/UserTest.php b/tests/Feature/Events/Auth/UserTest.php
new file mode 100644
index 000000000..3d047e86f
--- /dev/null
+++ b/tests/Feature/Events/Auth/UserTest.php
@@ -0,0 +1,101 @@
+createOne();
+
+ Event::assertDispatched(UserCreated::class);
+ }
+
+ /**
+ * When a User is deleted, a UserDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testUserDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $user = User::factory()->createOne();
+
+ $user->delete();
+
+ Event::assertDispatched(UserDeleted::class);
+ }
+
+ /**
+ * When a User is restored, a UserRestored event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testUserRestoredEventDispatched(): void
+ {
+ Event::fake();
+
+ $user = User::factory()->createOne();
+
+ $user->restore();
+
+ Event::assertDispatched(UserRestored::class);
+ }
+
+ /**
+ * When a User is restored, a UserUpdated event shall not be dispatched.
+ * Note: This is a customization that overrides default framework behavior.
+ * An updated event is fired on restore.
+ *
+ * @return void
+ */
+ public function testUserRestoresQuietly(): void
+ {
+ Event::fake();
+
+ $user = User::factory()->createOne();
+
+ $user->restore();
+
+ Event::assertNotDispatched(UserUpdated::class);
+ }
+
+ /**
+ * When a User is updated, a UserUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testUserUpdatedEventDispatched(): void
+ {
+ Event::fake();
+
+ $user = User::factory()->createOne();
+ $changes = User::factory()->makeOne();
+
+ $user->fill($changes->getAttributes());
+ $user->save();
+
+ Event::assertDispatched(UserUpdated::class);
+ }
+}
diff --git a/tests/Feature/Events/Billing/BalanceTest.php b/tests/Feature/Events/Billing/BalanceTest.php
new file mode 100644
index 000000000..771aeb194
--- /dev/null
+++ b/tests/Feature/Events/Billing/BalanceTest.php
@@ -0,0 +1,101 @@
+createOne();
+
+ Event::assertDispatched(BalanceCreated::class);
+ }
+
+ /**
+ * When a Balance is deleted, an BalanceDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testBalanceDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $balance = Balance::factory()->createOne();
+
+ $balance->delete();
+
+ Event::assertDispatched(BalanceDeleted::class);
+ }
+
+ /**
+ * When a Balance is restored, an BalanceRestored event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testBalanceRestoredEventDispatched(): void
+ {
+ Event::fake();
+
+ $balance = Balance::factory()->createOne();
+
+ $balance->restore();
+
+ Event::assertDispatched(BalanceRestored::class);
+ }
+
+ /**
+ * When a Balance is restored, a BalanceUpdated event shall not be dispatched.
+ * Note: This is a customization that overrides default framework behavior.
+ * An updated event is fired on restore.
+ *
+ * @return void
+ */
+ public function testBalanceRestoresQuietly(): void
+ {
+ Event::fake();
+
+ $balance = Balance::factory()->createOne();
+
+ $balance->restore();
+
+ Event::assertNotDispatched(BalanceUpdated::class);
+ }
+
+ /**
+ * When a Balance is updated, an BalanceUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testBalanceUpdatedEventDispatched(): void
+ {
+ Event::fake();
+
+ $balance = Balance::factory()->createOne();
+ $changes = Balance::factory()->makeOne();
+
+ $balance->fill($changes->getAttributes());
+ $balance->save();
+
+ Event::assertDispatched(BalanceUpdated::class);
+ }
+}
diff --git a/tests/Feature/Events/Billing/TransactionTest.php b/tests/Feature/Events/Billing/TransactionTest.php
new file mode 100644
index 000000000..372e70f3d
--- /dev/null
+++ b/tests/Feature/Events/Billing/TransactionTest.php
@@ -0,0 +1,101 @@
+createOne();
+
+ Event::assertDispatched(TransactionCreated::class);
+ }
+
+ /**
+ * When a Transaction is deleted, an TransactionDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testTransactionDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $transaction = Transaction::factory()->createOne();
+
+ $transaction->delete();
+
+ Event::assertDispatched(TransactionDeleted::class);
+ }
+
+ /**
+ * When a Transaction is restored, an TransactionRestored event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testTransactionRestoredEventDispatched(): void
+ {
+ Event::fake();
+
+ $transaction = Transaction::factory()->createOne();
+
+ $transaction->restore();
+
+ Event::assertDispatched(TransactionRestored::class);
+ }
+
+ /**
+ * When a Transaction is restored, a TransactionUpdated event shall not be dispatched.
+ * Note: This is a customization that overrides default framework behavior.
+ * An updated event is fired on restore.
+ *
+ * @return void
+ */
+ public function testTransactionRestoresQuietly(): void
+ {
+ Event::fake();
+
+ $transaction = Transaction::factory()->createOne();
+
+ $transaction->restore();
+
+ Event::assertNotDispatched(TransactionUpdated::class);
+ }
+
+ /**
+ * When a Transaction is updated, an TransactionUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testTransactionUpdatedEventDispatched(): void
+ {
+ Event::fake();
+
+ $transaction = Transaction::factory()->createOne();
+ $changes = Transaction::factory()->makeOne();
+
+ $transaction->fill($changes->getAttributes());
+ $transaction->save();
+
+ Event::assertDispatched(TransactionUpdated::class);
+ }
+}
diff --git a/tests/Feature/Events/Document/PageTest.php b/tests/Feature/Events/Document/PageTest.php
new file mode 100644
index 000000000..5eaf55829
--- /dev/null
+++ b/tests/Feature/Events/Document/PageTest.php
@@ -0,0 +1,101 @@
+createOne();
+
+ Event::assertDispatched(PageCreated::class);
+ }
+
+ /**
+ * When a Page is deleted, an PageDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testPageDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $page = Page::factory()->createOne();
+
+ $page->delete();
+
+ Event::assertDispatched(PageDeleted::class);
+ }
+
+ /**
+ * When a Page is restored, an PageRestored event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testPageRestoredEventDispatched(): void
+ {
+ Event::fake();
+
+ $page = Page::factory()->createOne();
+
+ $page->restore();
+
+ Event::assertDispatched(PageRestored::class);
+ }
+
+ /**
+ * When a Page is restored, a PageUpdated event shall not be dispatched.
+ * Note: This is a customization that overrides default framework behavior.
+ * An updated event is fired on restore.
+ *
+ * @return void
+ */
+ public function testPageRestoresQuietly(): void
+ {
+ Event::fake();
+
+ $page = Page::factory()->createOne();
+
+ $page->restore();
+
+ Event::assertNotDispatched(PageUpdated::class);
+ }
+
+ /**
+ * When a Page is updated, an PageUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testPageUpdatedEventDispatched(): void
+ {
+ Event::fake();
+
+ $page = Page::factory()->createOne();
+ $changes = Page::factory()->makeOne();
+
+ $page->fill($changes->getAttributes());
+ $page->save();
+
+ Event::assertDispatched(PageUpdated::class);
+ }
+}
diff --git a/tests/Feature/Events/Pivot/AnimeImageTest.php b/tests/Feature/Events/Pivot/AnimeImageTest.php
new file mode 100644
index 000000000..1d86ca70b
--- /dev/null
+++ b/tests/Feature/Events/Pivot/AnimeImageTest.php
@@ -0,0 +1,53 @@
+createOne();
+ $image = Image::factory()->createOne();
+
+ $anime->images()->attach($image);
+
+ Event::assertDispatched(AnimeImageCreated::class);
+ }
+
+ /**
+ * When an Anime is detached from an Image or vice versa, an AnimeImageDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnimeImageDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $anime = Anime::factory()->createOne();
+ $image = Image::factory()->createOne();
+
+ $anime->images()->attach($image);
+ $anime->images()->detach($image);
+
+ Event::assertDispatched(AnimeImageDeleted::class);
+ }
+}
diff --git a/tests/Feature/Events/Pivot/AnimeResourceTest.php b/tests/Feature/Events/Pivot/AnimeResourceTest.php
new file mode 100644
index 000000000..dc62a7a19
--- /dev/null
+++ b/tests/Feature/Events/Pivot/AnimeResourceTest.php
@@ -0,0 +1,83 @@
+createOne();
+ $resource = ExternalResource::factory()->createOne();
+
+ $anime->resources()->attach($resource);
+
+ Event::assertDispatched(AnimeResourceCreated::class);
+ }
+
+ /**
+ * When an Anime is detached from a Resource or vice versa, an AnimeResourceDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnimeResourceDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $anime = Anime::factory()->createOne();
+ $resource = ExternalResource::factory()->createOne();
+
+ $anime->resources()->attach($resource);
+ $anime->resources()->detach($resource);
+
+ Event::assertDispatched(AnimeResourceDeleted::class);
+ }
+
+ /**
+ * When an Anime Resource pivot is updated, an AnimeResourceUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnimeResourceUpdatedEventDispatched(): void
+ {
+ Event::fake();
+
+ $anime = Anime::factory()->createOne();
+ $resource = ExternalResource::factory()->createOne();
+
+ $animeResource = AnimeResource::factory()
+ ->for($anime, 'anime')
+ ->for($resource, 'resource')
+ ->createOne();
+
+ $changes = AnimeResource::factory()
+ ->for($anime, 'anime')
+ ->for($resource, 'resource')
+ ->makeOne();
+
+ $animeResource->fill($changes->getAttributes());
+ $animeResource->save();
+
+ Event::assertDispatched(AnimeResourceUpdated::class);
+ }
+}
diff --git a/tests/Feature/Events/Pivot/AnimeSeriesTest.php b/tests/Feature/Events/Pivot/AnimeSeriesTest.php
new file mode 100644
index 000000000..b42c912a1
--- /dev/null
+++ b/tests/Feature/Events/Pivot/AnimeSeriesTest.php
@@ -0,0 +1,53 @@
+createOne();
+ $series = Series::factory()->createOne();
+
+ $anime->series()->attach($series);
+
+ Event::assertDispatched(AnimeSeriesCreated::class);
+ }
+
+ /**
+ * When an Anime is detached from a Series or vice versa, an AnimeSeriesDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnimeSeriesDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $anime = Anime::factory()->createOne();
+ $series = Series::factory()->createOne();
+
+ $anime->series()->attach($series);
+ $anime->series()->detach($series);
+
+ Event::assertDispatched(AnimeSeriesDeleted::class);
+ }
+}
diff --git a/tests/Feature/Events/Pivot/AnimeStudioTest.php b/tests/Feature/Events/Pivot/AnimeStudioTest.php
new file mode 100644
index 000000000..1f7df1629
--- /dev/null
+++ b/tests/Feature/Events/Pivot/AnimeStudioTest.php
@@ -0,0 +1,53 @@
+createOne();
+ $studio = Studio::factory()->createOne();
+
+ $anime->studios()->attach($studio);
+
+ Event::assertDispatched(AnimeStudioCreated::class);
+ }
+
+ /**
+ * When an Anime is detached from a Studio or vice versa, an AnimeStudioDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnimeStudioDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $anime = Anime::factory()->createOne();
+ $studio = Studio::factory()->createOne();
+
+ $anime->studios()->attach($studio);
+ $anime->studios()->detach($studio);
+
+ Event::assertDispatched(AnimeStudioDeleted::class);
+ }
+}
diff --git a/tests/Feature/Events/Pivot/AnimeThemeEntryVideoTest.php b/tests/Feature/Events/Pivot/AnimeThemeEntryVideoTest.php
new file mode 100644
index 000000000..6af7b716b
--- /dev/null
+++ b/tests/Feature/Events/Pivot/AnimeThemeEntryVideoTest.php
@@ -0,0 +1,59 @@
+createOne();
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ $video->animethemeentries()->attach($entry);
+
+ Event::assertDispatched(AnimeThemeEntryAnimeThemeCreatedVideo::class);
+ }
+
+ /**
+ * When a Video is detached from an AnimeThemeEntry or vice versa, an AnimeThemeEntryVideoDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnimeThemeEntryVideoDeletedEventDispatched(): void
+ {
+ Event::fake(AnimeThemeEntryAnimeThemeDeletedVideo::class);
+
+ $video = Video::factory()->createOne();
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ $video->animethemeentries()->attach($entry);
+ $video->animethemeentries()->detach($entry);
+
+ Event::assertDispatched(AnimeThemeEntryAnimeThemeDeletedVideo::class);
+ }
+}
diff --git a/tests/Feature/Events/Pivot/ArtistImageTest.php b/tests/Feature/Events/Pivot/ArtistImageTest.php
new file mode 100644
index 000000000..c980fbfe9
--- /dev/null
+++ b/tests/Feature/Events/Pivot/ArtistImageTest.php
@@ -0,0 +1,53 @@
+createOne();
+ $image = Image::factory()->createOne();
+
+ $artist->images()->attach($image);
+
+ Event::assertDispatched(ArtistImageCreated::class);
+ }
+
+ /**
+ * When an Artist is detached from an Image or vice versa, an ArtistImageDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testArtistImageDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $artist = Artist::factory()->createOne();
+ $image = Image::factory()->createOne();
+
+ $artist->images()->attach($image);
+ $artist->images()->detach($image);
+
+ Event::assertDispatched(ArtistImageDeleted::class);
+ }
+}
diff --git a/tests/Feature/Events/Pivot/ArtistMemberTest.php b/tests/Feature/Events/Pivot/ArtistMemberTest.php
new file mode 100644
index 000000000..b34322f8a
--- /dev/null
+++ b/tests/Feature/Events/Pivot/ArtistMemberTest.php
@@ -0,0 +1,82 @@
+createOne();
+ $member = Artist::factory()->createOne();
+
+ $artist->members()->attach($member);
+
+ Event::assertDispatched(ArtistMemberCreated::class);
+ }
+
+ /**
+ * When an Artist is detached from a Member or vice versa, an ArtistMemberDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testArtistMemberDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $artist = Artist::factory()->createOne();
+ $member = Artist::factory()->createOne();
+
+ $artist->members()->attach($member);
+ $artist->members()->detach($member);
+
+ Event::assertDispatched(ArtistMemberDeleted::class);
+ }
+
+ /**
+ * When an Artist Member pivot is updated, an ArtistMemberUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testArtistMemberUpdatedEventDispatched(): void
+ {
+ Event::fake();
+
+ $artist = Artist::factory()->createOne();
+ $member = Artist::factory()->createOne();
+
+ $artistMember = ArtistMember::factory()
+ ->for($artist, 'artist')
+ ->for($member, 'member')
+ ->createOne();
+
+ $changes = ArtistMember::factory()
+ ->for($artist, 'artist')
+ ->for($member, 'member')
+ ->makeOne();
+
+ $artistMember->fill($changes->getAttributes());
+ $artistMember->save();
+
+ Event::assertDispatched(ArtistMemberUpdated::class);
+ }
+}
diff --git a/tests/Feature/Events/Pivot/ArtistResourceTest.php b/tests/Feature/Events/Pivot/ArtistResourceTest.php
new file mode 100644
index 000000000..5d27c7a2b
--- /dev/null
+++ b/tests/Feature/Events/Pivot/ArtistResourceTest.php
@@ -0,0 +1,83 @@
+createOne();
+ $resource = ExternalResource::factory()->createOne();
+
+ $artist->resources()->attach($resource);
+
+ Event::assertDispatched(ArtistResourceCreated::class);
+ }
+
+ /**
+ * When an Artist is detached from a Resource or vice versa, an ArtistResourceDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testArtistResourceDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $artist = Artist::factory()->createOne();
+ $resource = ExternalResource::factory()->createOne();
+
+ $artist->resources()->attach($resource);
+ $artist->resources()->detach($resource);
+
+ Event::assertDispatched(ArtistResourceDeleted::class);
+ }
+
+ /**
+ * When an Artist Resource pivot is updated, an ArtistResourceUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testArtistResourceUpdatedEventDispatched(): void
+ {
+ Event::fake();
+
+ $artist = Artist::factory()->createOne();
+ $resource = ExternalResource::factory()->createOne();
+
+ $artistResource = ArtistResource::factory()
+ ->for($artist, 'artist')
+ ->for($resource, 'resource')
+ ->createOne();
+
+ $changes = ArtistResource::factory()
+ ->for($artist, 'artist')
+ ->for($resource, 'resource')
+ ->makeOne();
+
+ $artistResource->fill($changes->getAttributes());
+ $artistResource->save();
+
+ Event::assertDispatched(ArtistResourceUpdated::class);
+ }
+}
diff --git a/tests/Feature/Events/Pivot/ArtistSongTest.php b/tests/Feature/Events/Pivot/ArtistSongTest.php
new file mode 100644
index 000000000..e543066b3
--- /dev/null
+++ b/tests/Feature/Events/Pivot/ArtistSongTest.php
@@ -0,0 +1,83 @@
+createOne();
+ $song = Song::factory()->createOne();
+
+ $artist->songs()->attach($song);
+
+ Event::assertDispatched(ArtistSongCreated::class);
+ }
+
+ /**
+ * When an Artist is detached from a Song or vice versa, an ArtistSongDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testArtistSongDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $artist = Artist::factory()->createOne();
+ $song = Song::factory()->createOne();
+
+ $artist->songs()->attach($song);
+ $artist->songs()->detach($song);
+
+ Event::assertDispatched(ArtistSongDeleted::class);
+ }
+
+ /**
+ * When an Artist Song pivot is updated, an ArtistSongUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testArtistSongUpdatedEventDispatched(): void
+ {
+ Event::fake();
+
+ $artist = Artist::factory()->createOne();
+ $song = Song::factory()->createOne();
+
+ $artistSong = ArtistSong::factory()
+ ->for($artist, 'artist')
+ ->for($song, 'song')
+ ->createOne();
+
+ $changes = ArtistSong::factory()
+ ->for($artist, 'artist')
+ ->for($song, 'song')
+ ->makeOne();
+
+ $artistSong->fill($changes->getAttributes());
+ $artistSong->save();
+
+ Event::assertDispatched(ArtistSongUpdated::class);
+ }
+}
diff --git a/tests/Feature/Events/Pivot/StudioResourceTest.php b/tests/Feature/Events/Pivot/StudioResourceTest.php
new file mode 100644
index 000000000..06a7d59ef
--- /dev/null
+++ b/tests/Feature/Events/Pivot/StudioResourceTest.php
@@ -0,0 +1,83 @@
+createOne();
+ $resource = ExternalResource::factory()->createOne();
+
+ $studio->resources()->attach($resource);
+
+ Event::assertDispatched(StudioResourceCreated::class);
+ }
+
+ /**
+ * When a Studio is detached to a Resource or vice versa, a StudioResourceDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testStudioResourceDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $studio = Studio::factory()->createOne();
+ $resource = ExternalResource::factory()->createOne();
+
+ $studio->resources()->attach($resource);
+ $studio->resources()->detach($resource);
+
+ Event::assertDispatched(StudioResourceDeleted::class);
+ }
+
+ /**
+ * When a Studio Resource pivot is updated, a StudioResourceUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testStudioResourceUpdatedEventDispatched(): void
+ {
+ Event::fake();
+
+ $studio = Studio::factory()->createOne();
+ $resource = ExternalResource::factory()->createOne();
+
+ $studioResource = StudioResource::factory()
+ ->for($studio, 'studio')
+ ->for($resource, 'resource')
+ ->createOne();
+
+ $changes = StudioResource::factory()
+ ->for($studio, 'studio')
+ ->for($resource, 'resource')
+ ->makeOne();
+
+ $studioResource->fill($changes->getAttributes());
+ $studioResource->save();
+
+ Event::assertDispatched(StudioResourceUpdated::class);
+ }
+}
diff --git a/tests/Feature/Events/Wiki/Anime/SynonymTest.php b/tests/Feature/Events/Wiki/Anime/SynonymTest.php
new file mode 100644
index 000000000..c87864e73
--- /dev/null
+++ b/tests/Feature/Events/Wiki/Anime/SynonymTest.php
@@ -0,0 +1,115 @@
+for(Anime::factory())
+ ->createOne();
+
+ Event::assertDispatched(SynonymCreated::class);
+ }
+
+ /**
+ * When a Synonym is deleted, a SynonymDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testSynonymDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $synonym = AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ $synonym->delete();
+
+ Event::assertDispatched(SynonymDeleted::class);
+ }
+
+ /**
+ * When a Synonym is restored, a SynonymRestored event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testSynonymRestoredEventDispatched(): void
+ {
+ Event::fake();
+
+ $synonym = AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ $synonym->restore();
+
+ Event::assertDispatched(SynonymRestored::class);
+ }
+
+ /**
+ * When a Synonym is restored, a SynonymUpdated event shall not be dispatched.
+ * Note: This is a customization that overrides default framework behavior.
+ * An updated event is fired on restore.
+ *
+ * @return void
+ */
+ public function testSynonymRestoresQuietly(): void
+ {
+ Event::fake();
+
+ $synonym = AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ $synonym->restore();
+
+ Event::assertNotDispatched(SynonymUpdated::class);
+ }
+
+ /**
+ * When a Synonym is updated, a SynonymUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testSynonymUpdatedEventDispatched(): void
+ {
+ Event::fake();
+
+ $synonym = AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ $changes = AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->makeOne();
+
+ $synonym->fill($changes->getAttributes());
+ $synonym->save();
+
+ Event::assertDispatched(SynonymUpdated::class);
+ }
+}
diff --git a/tests/Feature/Events/Wiki/Anime/Theme/EntryTest.php b/tests/Feature/Events/Wiki/Anime/Theme/EntryTest.php
new file mode 100644
index 000000000..a660f4cca
--- /dev/null
+++ b/tests/Feature/Events/Wiki/Anime/Theme/EntryTest.php
@@ -0,0 +1,117 @@
+for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ Event::assertDispatched(EntryCreated::class);
+ }
+
+ /**
+ * When an Entry is deleted, an EntryDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testEntryDeletedEventDispatched(): void
+ {
+ Event::fakeExcept(ThemeCreating::class);
+
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ $entry->delete();
+
+ Event::assertDispatched(EntryDeleted::class);
+ }
+
+ /**
+ * When an Entry is restored, an EntryRestored event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testEntryRestoredEventDispatched(): void
+ {
+ Event::fakeExcept(ThemeCreating::class);
+
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ $entry->restore();
+
+ Event::assertDispatched(EntryRestored::class);
+ }
+
+ /**
+ * When an Entry is restored, an EntryUpdated event shall not be dispatched.
+ * Note: This is a customization that overrides default framework behavior.
+ * An updated event is fired on restore.
+ *
+ * @return void
+ */
+ public function testEntryRestoresQuietly(): void
+ {
+ Event::fakeExcept(ThemeCreating::class);
+
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ $entry->restore();
+
+ Event::assertNotDispatched(EntryUpdated::class);
+ }
+
+ /**
+ * When an Entry is updated, an EntryUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testEntryUpdatedEventDispatched(): void
+ {
+ Event::fakeExcept(ThemeCreating::class);
+
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ $changes = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->makeOne();
+
+ $entry->fill($changes->getAttributes());
+ $entry->save();
+
+ Event::assertDispatched(EntryUpdated::class);
+ }
+}
diff --git a/tests/Feature/Events/Wiki/Anime/ThemeTest.php b/tests/Feature/Events/Wiki/Anime/ThemeTest.php
new file mode 100644
index 000000000..d57f7f823
--- /dev/null
+++ b/tests/Feature/Events/Wiki/Anime/ThemeTest.php
@@ -0,0 +1,116 @@
+for(Anime::factory())
+ ->createOne();
+
+ Event::assertDispatched(ThemeCreated::class);
+ }
+
+ /**
+ * When a Theme is deleted, a ThemeDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testThemeDeletedEventDispatched(): void
+ {
+ Event::fakeExcept(ThemeCreating::class);
+
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ $theme->delete();
+
+ Event::assertDispatched(ThemeDeleted::class);
+ }
+
+ /**
+ * When a Theme is restored, a ThemeRestored event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testThemeRestoredEventDispatched(): void
+ {
+ Event::fakeExcept(ThemeCreating::class);
+
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ $theme->restore();
+
+ Event::assertDispatched(ThemeRestored::class);
+ }
+
+ /**
+ * When a Theme is restored, a ThemeUpdated event shall not be dispatched.
+ * Note: This is a customization that overrides default framework behavior.
+ * An updated event is fired on restore.
+ *
+ * @return void
+ */
+ public function testThemeRestoresQuietly(): void
+ {
+ Event::fakeExcept(ThemeCreating::class);
+
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ $theme->restore();
+
+ Event::assertNotDispatched(ThemeUpdated::class);
+ }
+
+ /**
+ * When a Theme is updated, a ThemeUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testThemeUpdatedEventDispatched(): void
+ {
+ Event::fakeExcept(ThemeCreating::class);
+
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ $changes = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->makeOne();
+
+ $theme->fill($changes->getAttributes());
+ $theme->save();
+
+ Event::assertDispatched(ThemeUpdated::class);
+ }
+}
diff --git a/tests/Feature/Events/Wiki/AnimeTest.php b/tests/Feature/Events/Wiki/AnimeTest.php
new file mode 100644
index 000000000..8442ce7c3
--- /dev/null
+++ b/tests/Feature/Events/Wiki/AnimeTest.php
@@ -0,0 +1,101 @@
+createOne();
+
+ Event::assertDispatched(AnimeCreated::class);
+ }
+
+ /**
+ * When an Anime is deleted, an AnimeDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnimeDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $anime = Anime::factory()->createOne();
+
+ $anime->delete();
+
+ Event::assertDispatched(AnimeDeleted::class);
+ }
+
+ /**
+ * When an Anime is restored, an AnimeRestored event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnimeRestoredEventDispatched(): void
+ {
+ Event::fake();
+
+ $anime = Anime::factory()->createOne();
+
+ $anime->restore();
+
+ Event::assertDispatched(AnimeRestored::class);
+ }
+
+ /**
+ * When an Anime is restored, an AnimeUpdated event shall not be dispatched.
+ * Note: This is a customization that overrides default framework behavior.
+ * An updated event is fired on restore.
+ *
+ * @return void
+ */
+ public function testAnimeRestoresQuietly(): void
+ {
+ Event::fake();
+
+ $anime = Anime::factory()->createOne();
+
+ $anime->restore();
+
+ Event::assertNotDispatched(AnimeUpdated::class);
+ }
+
+ /**
+ * When an Anime is updated, an AnimeUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnimeUpdatedEventDispatched(): void
+ {
+ Event::fake();
+
+ $anime = Anime::factory()->createOne();
+ $changes = Anime::factory()->makeOne();
+
+ $anime->fill($changes->getAttributes());
+ $anime->save();
+
+ Event::assertDispatched(AnimeUpdated::class);
+ }
+}
diff --git a/tests/Feature/Events/Wiki/ArtistTest.php b/tests/Feature/Events/Wiki/ArtistTest.php
new file mode 100644
index 000000000..3fa7ffe64
--- /dev/null
+++ b/tests/Feature/Events/Wiki/ArtistTest.php
@@ -0,0 +1,101 @@
+createOne();
+
+ Event::assertDispatched(ArtistCreated::class);
+ }
+
+ /**
+ * When an Artist is deleted, an ArtistDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testArtistDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $artist = Artist::factory()->createOne();
+
+ $artist->delete();
+
+ Event::assertDispatched(ArtistDeleted::class);
+ }
+
+ /**
+ * When an Artist is restored, an ArtistRestored event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testArtistRestoredEventDispatched(): void
+ {
+ Event::fake();
+
+ $artist = Artist::factory()->createOne();
+
+ $artist->restore();
+
+ Event::assertDispatched(ArtistRestored::class);
+ }
+
+ /**
+ * When an Artist is restored, an ArtistUpdated event shall not be dispatched.
+ * Note: This is a customization that overrides default framework behavior.
+ * An updated event is fired on restore.
+ *
+ * @return void
+ */
+ public function testArtistRestoresQuietly(): void
+ {
+ Event::fake();
+
+ $artist = Artist::factory()->createOne();
+
+ $artist->restore();
+
+ Event::assertNotDispatched(ArtistUpdated::class);
+ }
+
+ /**
+ * When an Artist is updated, an ArtistUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testArtistUpdatedEventDispatched(): void
+ {
+ Event::fake();
+
+ $artist = Artist::factory()->createOne();
+ $changes = Artist::factory()->makeOne();
+
+ $artist->fill($changes->getAttributes());
+ $artist->save();
+
+ Event::assertDispatched(ArtistUpdated::class);
+ }
+}
diff --git a/tests/Feature/Events/Wiki/ExternalResourceTest.php b/tests/Feature/Events/Wiki/ExternalResourceTest.php
new file mode 100644
index 000000000..d8484325e
--- /dev/null
+++ b/tests/Feature/Events/Wiki/ExternalResourceTest.php
@@ -0,0 +1,101 @@
+createOne();
+
+ Event::assertDispatched(ExternalResourceCreated::class);
+ }
+
+ /**
+ * When a Resource is deleted, an ExternalResourceDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testExternalResourceDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $resource = ExternalResource::factory()->createOne();
+
+ $resource->delete();
+
+ Event::assertDispatched(ExternalResourceDeleted::class);
+ }
+
+ /**
+ * When a Resource is restored, an ExternalResourceRestored event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testExternalResourceRestoredEventDispatched(): void
+ {
+ Event::fake();
+
+ $resource = ExternalResource::factory()->createOne();
+
+ $resource->restore();
+
+ Event::assertDispatched(ExternalResourceRestored::class);
+ }
+
+ /**
+ * When a Resource is restored, an ExternalResourceUpdated event shall not be dispatched.
+ * Note: This is a customization that overrides default framework behavior.
+ * An updated event is fired on restore.
+ *
+ * @return void
+ */
+ public function testExternalResourceRestoresQuietly(): void
+ {
+ Event::fake();
+
+ $resource = ExternalResource::factory()->createOne();
+
+ $resource->restore();
+
+ Event::assertNotDispatched(ExternalResourceUpdated::class);
+ }
+
+ /**
+ * When an ExternalResource is updated, an ExternalResourceUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testExternalResourceUpdatedEventDispatched(): void
+ {
+ Event::fake();
+
+ $resource = ExternalResource::factory()->createOne();
+ $changes = ExternalResource::factory()->makeOne();
+
+ $resource->fill($changes->getAttributes());
+ $resource->save();
+
+ Event::assertDispatched(ExternalResourceUpdated::class);
+ }
+}
diff --git a/tests/Feature/Events/Wiki/ImageTest.php b/tests/Feature/Events/Wiki/ImageTest.php
new file mode 100644
index 000000000..7f632d9f3
--- /dev/null
+++ b/tests/Feature/Events/Wiki/ImageTest.php
@@ -0,0 +1,101 @@
+createOne();
+
+ Event::assertDispatched(ImageCreated::class);
+ }
+
+ /**
+ * When an Image is deleted, an ImageDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testImageDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $image = Image::factory()->createOne();
+
+ $image->delete();
+
+ Event::assertDispatched(ImageDeleted::class);
+ }
+
+ /**
+ * When an Image is restored, an ImageRestored event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testImageRestoredEventDispatched(): void
+ {
+ Event::fake();
+
+ $image = Image::factory()->createOne();
+
+ $image->restore();
+
+ Event::assertDispatched(ImageRestored::class);
+ }
+
+ /**
+ * When an Image is restored, an ImageUpdated event shall not be dispatched.
+ * Note: This is a customization that overrides default framework behavior.
+ * An updated event is fired on restore.
+ *
+ * @return void
+ */
+ public function testImageRestoresQuietly(): void
+ {
+ Event::fake();
+
+ $image = Image::factory()->createOne();
+
+ $image->restore();
+
+ Event::assertNotDispatched(ImageUpdated::class);
+ }
+
+ /**
+ * When an Image is updated, an ImageUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testImageUpdatedEventDispatched(): void
+ {
+ Event::fake();
+
+ $image = Image::factory()->createOne();
+ $changes = Image::factory()->makeOne();
+
+ $image->fill($changes->getAttributes());
+ $image->save();
+
+ Event::assertDispatched(ImageUpdated::class);
+ }
+}
diff --git a/tests/Feature/Events/Wiki/SeriesTest.php b/tests/Feature/Events/Wiki/SeriesTest.php
new file mode 100644
index 000000000..5c77e8fdc
--- /dev/null
+++ b/tests/Feature/Events/Wiki/SeriesTest.php
@@ -0,0 +1,101 @@
+createOne();
+
+ Event::assertDispatched(SeriesCreated::class);
+ }
+
+ /**
+ * When a Series is deleted, a SeriesDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testSeriesDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $series = Series::factory()->createOne();
+
+ $series->delete();
+
+ Event::assertDispatched(SeriesDeleted::class);
+ }
+
+ /**
+ * When a Series is restored, a SeriesRestored event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testSeriesRestoredEventDispatched(): void
+ {
+ Event::fake();
+
+ $series = Series::factory()->createOne();
+
+ $series->restore();
+
+ Event::assertDispatched(SeriesRestored::class);
+ }
+
+ /**
+ * When a Series is restored, a SeriesUpdated event shall not be dispatched.
+ * Note: This is a customization that overrides default framework behavior.
+ * An updated event is fired on restore.
+ *
+ * @return void
+ */
+ public function testSeriesRestoresQuietly(): void
+ {
+ Event::fake();
+
+ $series = Series::factory()->createOne();
+
+ $series->restore();
+
+ Event::assertNotDispatched(SeriesUpdated::class);
+ }
+
+ /**
+ * When a Series is updated, a SeriesUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testSeriesUpdatedEventDispatched(): void
+ {
+ Event::fake();
+
+ $series = Series::factory()->createOne();
+ $changes = Series::factory()->makeOne();
+
+ $series->fill($changes->getAttributes());
+ $series->save();
+
+ Event::assertDispatched(SeriesUpdated::class);
+ }
+}
diff --git a/tests/Feature/Events/Wiki/SongTest.php b/tests/Feature/Events/Wiki/SongTest.php
new file mode 100644
index 000000000..c5db8adf9
--- /dev/null
+++ b/tests/Feature/Events/Wiki/SongTest.php
@@ -0,0 +1,101 @@
+createOne();
+
+ Event::assertDispatched(SongCreated::class);
+ }
+
+ /**
+ * When a Song is deleted, a SongDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testSongDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $song = Song::factory()->createOne();
+
+ $song->delete();
+
+ Event::assertDispatched(SongDeleted::class);
+ }
+
+ /**
+ * When a Song is restored, a SongRestored event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testSongRestoredEventDispatched(): void
+ {
+ Event::fake();
+
+ $song = Song::factory()->createOne();
+
+ $song->restore();
+
+ Event::assertDispatched(SongRestored::class);
+ }
+
+ /**
+ * When a Song is restored, a SongUpdated event shall not be dispatched.
+ * Note: This is a customization that overrides default framework behavior.
+ * An updated event is fired on restore.
+ *
+ * @return void
+ */
+ public function testSongRestoresQuietly(): void
+ {
+ Event::fake();
+
+ $song = Song::factory()->createOne();
+
+ $song->restore();
+
+ Event::assertNotDispatched(SongUpdated::class);
+ }
+
+ /**
+ * When a Song is updated, a SongUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testSongUpdatedEventDispatched(): void
+ {
+ Event::fake();
+
+ $song = Song::factory()->createOne();
+ $changes = Song::factory()->makeOne();
+
+ $song->fill($changes->getAttributes());
+ $song->save();
+
+ Event::assertDispatched(SongUpdated::class);
+ }
+}
diff --git a/tests/Feature/Events/Wiki/StudioTest.php b/tests/Feature/Events/Wiki/StudioTest.php
new file mode 100644
index 000000000..e738c3982
--- /dev/null
+++ b/tests/Feature/Events/Wiki/StudioTest.php
@@ -0,0 +1,101 @@
+createOne();
+
+ Event::assertDispatched(StudioCreated::class);
+ }
+
+ /**
+ * When a Studio is deleted, a StudioDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testStudioDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $studio = Studio::factory()->createOne();
+
+ $studio->delete();
+
+ Event::assertDispatched(StudioDeleted::class);
+ }
+
+ /**
+ * When a Studio is restored, a StudioRestored event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testStudioRestoredEventDispatched(): void
+ {
+ Event::fake();
+
+ $studio = Studio::factory()->createOne();
+
+ $studio->restore();
+
+ Event::assertDispatched(StudioRestored::class);
+ }
+
+ /**
+ * When a Studio is restored, a StudioUpdated event shall not be dispatched.
+ * Note: This is a customization that overrides default framework behavior.
+ * An updated event is fired on restore.
+ *
+ * @return void
+ */
+ public function testStudioRestoresQuietly(): void
+ {
+ Event::fake();
+
+ $studio = Studio::factory()->createOne();
+
+ $studio->restore();
+
+ Event::assertNotDispatched(StudioUpdated::class);
+ }
+
+ /**
+ * When a Studio is updated, a StudioUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testStudioUpdatedEventDispatched(): void
+ {
+ Event::fake();
+
+ $studio = Studio::factory()->createOne();
+ $changes = Studio::factory()->makeOne();
+
+ $studio->fill($changes->getAttributes());
+ $studio->save();
+
+ Event::assertDispatched(StudioUpdated::class);
+ }
+}
diff --git a/tests/Feature/Events/Wiki/VideoTest.php b/tests/Feature/Events/Wiki/VideoTest.php
new file mode 100644
index 000000000..6b304a600
--- /dev/null
+++ b/tests/Feature/Events/Wiki/VideoTest.php
@@ -0,0 +1,101 @@
+createOne();
+
+ Event::assertDispatched(VideoCreated::class);
+ }
+
+ /**
+ * When a Video is deleted, a VideoDeleted event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testVideoDeletedEventDispatched(): void
+ {
+ Event::fake();
+
+ $video = Video::factory()->createOne();
+
+ $video->delete();
+
+ Event::assertDispatched(VideoDeleted::class);
+ }
+
+ /**
+ * When a Video is restored, a VideoRestored event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testVideoRestoredEventDispatched(): void
+ {
+ Event::fake();
+
+ $video = Video::factory()->createOne();
+
+ $video->restore();
+
+ Event::assertDispatched(VideoRestored::class);
+ }
+
+ /**
+ * When a Video is restored, a VideoUpdated event shall not be dispatched.
+ * Note: This is a customization that overrides default framework behavior.
+ * An updated event is fired on restore.
+ *
+ * @return void
+ */
+ public function testVideoRestoresQuietly(): void
+ {
+ Event::fake();
+
+ $video = Video::factory()->createOne();
+
+ $video->restore();
+
+ Event::assertNotDispatched(VideoUpdated::class);
+ }
+
+ /**
+ * When a Video is updated, a VideoUpdated event shall be dispatched.
+ *
+ * @return void
+ */
+ public function testVideoUpdatedEventDispatched(): void
+ {
+ Event::fake();
+
+ $video = Video::factory()->createOne();
+ $changes = Video::factory()->makeOne();
+
+ $video->fill($changes->getAttributes());
+ $video->save();
+
+ Event::assertDispatched(VideoUpdated::class);
+ }
+}
diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php
deleted file mode 100644
index cdb511193..000000000
--- a/tests/Feature/ExampleTest.php
+++ /dev/null
@@ -1,21 +0,0 @@
-get('/');
-
- $response->assertStatus(200);
- }
-}
diff --git a/tests/Feature/Http/Api/Admin/Announcement/AnnouncementDestroyTest.php b/tests/Feature/Http/Api/Admin/Announcement/AnnouncementDestroyTest.php
new file mode 100644
index 000000000..b0592d67f
--- /dev/null
+++ b/tests/Feature/Http/Api/Admin/Announcement/AnnouncementDestroyTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.announcement.destroy', ['announcement' => $announcement]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Announcement Destroy Endpoint shall delete the announcement.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $announcement = Announcement::factory()->createOne();
+
+ $user = User::factory()->withPermission('delete announcement')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.announcement.destroy', ['announcement' => $announcement]));
+
+ $response->assertOk();
+ static::assertSoftDeleted($announcement);
+ }
+}
diff --git a/tests/Feature/Http/Api/Admin/Announcement/AnnouncementForceDeleteTest.php b/tests/Feature/Http/Api/Admin/Announcement/AnnouncementForceDeleteTest.php
new file mode 100644
index 000000000..bc1453e4b
--- /dev/null
+++ b/tests/Feature/Http/Api/Admin/Announcement/AnnouncementForceDeleteTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.announcement.forceDelete', ['announcement' => $announcement]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Announcement Force Destroy Endpoint shall force delete the announcement.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $announcement = Announcement::factory()->createOne();
+
+ $user = User::factory()->withPermission('force delete announcement')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.announcement.forceDelete', ['announcement' => $announcement]));
+
+ $response->assertOk();
+ static::assertModelMissing($announcement);
+ }
+}
diff --git a/tests/Feature/Http/Api/Admin/Announcement/AnnouncementIndexTest.php b/tests/Feature/Http/Api/Admin/Announcement/AnnouncementIndexTest.php
new file mode 100644
index 000000000..551410308
--- /dev/null
+++ b/tests/Feature/Http/Api/Admin/Announcement/AnnouncementIndexTest.php
@@ -0,0 +1,401 @@
+count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.announcement.index'));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnnouncementCollection($announcements, new AnnouncementReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Announcement Index Endpoint shall be paginated.
+ *
+ * @return void
+ */
+ public function testPaginated(): void
+ {
+ Announcement::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.announcement.index'));
+
+ $response->assertJsonStructure([
+ AnnouncementCollection::$wrap,
+ 'links',
+ 'meta',
+ ]);
+ }
+
+ /**
+ * The Announcement Index Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $schema = new AnnouncementSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ AnnouncementResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $announcements = Announcement::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.announcement.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnnouncementCollection($announcements, new AnnouncementReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Announcement Index Endpoint shall support sorting resources.
+ *
+ * @return void
+ */
+ public function testSorts(): void
+ {
+ $schema = new AnnouncementSchema();
+
+ $sort = collect($schema->fields())
+ ->filter(fn (Field $field) => $field instanceof SortableField)
+ ->map(fn (SortableField $field) => $field->getSort())
+ ->random();
+
+ $parameters = [
+ SortParser::param() => $sort->format(Direction::getRandomInstance()),
+ ];
+
+ $query = new AnnouncementReadQuery($parameters);
+
+ Announcement::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.announcement.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ $query->collection($query->index())
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Announcement Index Endpoint shall support filtering by created_at.
+ *
+ * @return void
+ */
+ public function testCreatedAtFilter(): void
+ {
+ $createdFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_CREATED_AT => $createdFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($createdFilter, function () {
+ Announcement::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Announcement::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $announcement = Announcement::query()->where(BaseModel::ATTRIBUTE_CREATED_AT, $createdFilter)->get();
+
+ $response = $this->get(route('api.announcement.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnnouncementCollection($announcement, new AnnouncementReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Announcement Index Endpoint shall support filtering by updated_at.
+ *
+ * @return void
+ */
+ public function testUpdatedAtFilter(): void
+ {
+ $updatedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_UPDATED_AT => $updatedFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($updatedFilter, function () {
+ Announcement::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Announcement::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $announcement = Announcement::query()->where(BaseModel::ATTRIBUTE_UPDATED_AT, $updatedFilter)->get();
+
+ $response = $this->get(route('api.announcement.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnnouncementCollection($announcement, new AnnouncementReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Announcement Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithoutTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITHOUT,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Announcement::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteAnnouncement = Announcement::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteAnnouncement->each(function (Announcement $announcement) {
+ $announcement->delete();
+ });
+
+ $announcement = Announcement::withoutTrashed()->get();
+
+ $response = $this->get(route('api.announcement.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnnouncementCollection($announcement, new AnnouncementReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Announcement Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Announcement::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteAnnouncement = Announcement::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteAnnouncement->each(function (Announcement $announcement) {
+ $announcement->delete();
+ });
+
+ $announcement = Announcement::withTrashed()->get();
+
+ $response = $this->get(route('api.announcement.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnnouncementCollection($announcement, new AnnouncementReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Announcement Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testOnlyTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::ONLY,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Announcement::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteAnnouncement = Announcement::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteAnnouncement->each(function (Announcement $announcement) {
+ $announcement->delete();
+ });
+
+ $announcement = Announcement::onlyTrashed()->get();
+
+ $response = $this->get(route('api.announcement.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnnouncementCollection($announcement, new AnnouncementReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Announcement Index Endpoint shall support filtering by deleted_at.
+ *
+ * @return void
+ */
+ public function testDeletedAtFilter(): void
+ {
+ $deletedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_DELETED_AT => $deletedFilter,
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($deletedFilter, function () {
+ $announcements = Announcement::factory()->count($this->faker->randomDigitNotNull())->create();
+ $announcements->each(function (Announcement $announcement) {
+ $announcement->delete();
+ });
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ $announcements = Announcement::factory()->count($this->faker->randomDigitNotNull())->create();
+ $announcements->each(function (Announcement $announcement) {
+ $announcement->delete();
+ });
+ });
+
+ $announcement = Announcement::withTrashed()->where(BaseModel::ATTRIBUTE_DELETED_AT, $deletedFilter)->get();
+
+ $response = $this->get(route('api.announcement.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnnouncementCollection($announcement, new AnnouncementReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Admin/Announcement/AnnouncementRestoreTest.php b/tests/Feature/Http/Api/Admin/Announcement/AnnouncementRestoreTest.php
new file mode 100644
index 000000000..8262c620e
--- /dev/null
+++ b/tests/Feature/Http/Api/Admin/Announcement/AnnouncementRestoreTest.php
@@ -0,0 +1,56 @@
+createOne();
+
+ $announcement->delete();
+
+ $response = $this->patch(route('api.announcement.restore', ['announcement' => $announcement]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Announcement Restore Endpoint shall restore the announcement.
+ *
+ * @return void
+ */
+ public function testRestored(): void
+ {
+ $announcement = Announcement::factory()->createOne();
+
+ $announcement->delete();
+
+ $user = User::factory()->withPermission('restore announcement')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->patch(route('api.announcement.restore', ['announcement' => $announcement]));
+
+ $response->assertOk();
+ static::assertNotSoftDeleted($announcement);
+ }
+}
diff --git a/tests/Feature/Http/Api/Admin/Announcement/AnnouncementShowTest.php b/tests/Feature/Http/Api/Admin/Announcement/AnnouncementShowTest.php
new file mode 100644
index 000000000..a24b6a22a
--- /dev/null
+++ b/tests/Feature/Http/Api/Admin/Announcement/AnnouncementShowTest.php
@@ -0,0 +1,109 @@
+create();
+
+ $response = $this->get(route('api.announcement.show', ['announcement' => $announcement]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnnouncementResource($announcement, new AnnouncementReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Announcement Show Endpoint shall return an Announcement Resource for soft deleted images.
+ *
+ * @return void
+ */
+ public function testSoftDelete(): void
+ {
+ $announcement = Announcement::factory()->createOne();
+
+ $announcement->delete();
+
+ $announcement->unsetRelations();
+
+ $response = $this->get(route('api.announcement.show', ['announcement' => $announcement]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnnouncementResource($announcement, new AnnouncementReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Announcement Show Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $schema = new AnnouncementSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ AnnouncementResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $announcement = Announcement::factory()->create();
+
+ $response = $this->get(route('api.announcement.show', ['announcement' => $announcement]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnnouncementResource($announcement, new AnnouncementReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Admin/Announcement/AnnouncementStoreTest.php b/tests/Feature/Http/Api/Admin/Announcement/AnnouncementStoreTest.php
new file mode 100644
index 000000000..d9b1b98af
--- /dev/null
+++ b/tests/Feature/Http/Api/Admin/Announcement/AnnouncementStoreTest.php
@@ -0,0 +1,70 @@
+makeOne();
+
+ $response = $this->post(route('api.announcement.store', $announcement->toArray()));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Announcement Store Endpoint shall require the content field.
+ *
+ * @return void
+ */
+ public function testRequiredFields(): void
+ {
+ $user = User::factory()->withPermission('create announcement')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.announcement.store'));
+
+ $response->assertJsonValidationErrors([
+ Announcement::ATTRIBUTE_CONTENT,
+ ]);
+ }
+
+ /**
+ * The Announcement Store Endpoint shall create an announcement.
+ *
+ * @return void
+ */
+ public function testCreate(): void
+ {
+ $parameters = Announcement::factory()->raw();
+
+ $user = User::factory()->withPermission('create announcement')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.announcement.store', $parameters));
+
+ $response->assertCreated();
+ static::assertDatabaseCount(Announcement::TABLE, 1);
+ }
+}
diff --git a/tests/Feature/Http/Api/Admin/Announcement/AnnouncementUpdateTest.php b/tests/Feature/Http/Api/Admin/Announcement/AnnouncementUpdateTest.php
new file mode 100644
index 000000000..1d48fe28c
--- /dev/null
+++ b/tests/Feature/Http/Api/Admin/Announcement/AnnouncementUpdateTest.php
@@ -0,0 +1,55 @@
+createOne();
+
+ $parameters = Announcement::factory()->raw();
+
+ $response = $this->put(route('api.announcement.update', ['announcement' => $announcement] + $parameters));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Announcement Update Endpoint shall update an announcement.
+ *
+ * @return void
+ */
+ public function testUpdate(): void
+ {
+ $announcement = Announcement::factory()->createOne();
+
+ $parameters = Announcement::factory()->raw();
+
+ $user = User::factory()->withPermission('update announcement')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->put(route('api.announcement.update', ['announcement' => $announcement] + $parameters));
+
+ $response->assertOk();
+ }
+}
diff --git a/tests/Feature/Http/Api/Billing/Balance/BalanceDestroyTest.php b/tests/Feature/Http/Api/Billing/Balance/BalanceDestroyTest.php
new file mode 100644
index 000000000..5f5508098
--- /dev/null
+++ b/tests/Feature/Http/Api/Billing/Balance/BalanceDestroyTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.balance.destroy', ['balance' => $balance]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Balance Destroy Endpoint shall delete the balance.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $balance = Balance::factory()->createOne();
+
+ $user = User::factory()->withPermission('delete balance')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.balance.destroy', ['balance' => $balance]));
+
+ $response->assertOk();
+ static::assertSoftDeleted($balance);
+ }
+}
diff --git a/tests/Feature/Http/Api/Billing/Balance/BalanceForceDeleteTest.php b/tests/Feature/Http/Api/Billing/Balance/BalanceForceDeleteTest.php
new file mode 100644
index 000000000..75c82dacd
--- /dev/null
+++ b/tests/Feature/Http/Api/Billing/Balance/BalanceForceDeleteTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.balance.forceDelete', ['balance' => $balance]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Balance Force Destroy Endpoint shall force delete the balance.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $balance = Balance::factory()->createOne();
+
+ $user = User::factory()->withPermission('force delete balance')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.balance.forceDelete', ['balance' => $balance]));
+
+ $response->assertOk();
+ static::assertModelMissing($balance);
+ }
+}
diff --git a/tests/Feature/Http/Api/Billing/Balance/BalanceIndexTest.php b/tests/Feature/Http/Api/Billing/Balance/BalanceIndexTest.php
new file mode 100644
index 000000000..280b0c792
--- /dev/null
+++ b/tests/Feature/Http/Api/Billing/Balance/BalanceIndexTest.php
@@ -0,0 +1,401 @@
+count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.balance.index'));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new BalanceCollection($balances, new BalanceReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Balance Index Endpoint shall be paginated.
+ *
+ * @return void
+ */
+ public function testPaginated(): void
+ {
+ Balance::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.balance.index'));
+
+ $response->assertJsonStructure([
+ BalanceCollection::$wrap,
+ 'links',
+ 'meta',
+ ]);
+ }
+
+ /**
+ * The Balance Index Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $schema = new BalanceSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ BalanceResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $balances = Balance::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.balance.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new BalanceCollection($balances, new BalanceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Balance Index Endpoint shall support sorting resources.
+ *
+ * @return void
+ */
+ public function testSorts(): void
+ {
+ $schema = new BalanceSchema();
+
+ $sort = collect($schema->fields())
+ ->filter(fn (Field $field) => $field instanceof SortableField)
+ ->map(fn (SortableField $field) => $field->getSort())
+ ->random();
+
+ $parameters = [
+ SortParser::param() => $sort->format(Direction::getRandomInstance()),
+ ];
+
+ $query = new BalanceReadQuery($parameters);
+
+ Balance::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.balance.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ $query->collection($query->index())
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Balance Index Endpoint shall support filtering by created_at.
+ *
+ * @return void
+ */
+ public function testCreatedAtFilter(): void
+ {
+ $createdFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_CREATED_AT => $createdFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($createdFilter, function () {
+ Balance::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Balance::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $balance = Balance::query()->where(BaseModel::ATTRIBUTE_CREATED_AT, $createdFilter)->get();
+
+ $response = $this->get(route('api.balance.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new BalanceCollection($balance, new BalanceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Balance Index Endpoint shall support filtering by updated_at.
+ *
+ * @return void
+ */
+ public function testUpdatedAtFilter(): void
+ {
+ $updatedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_UPDATED_AT => $updatedFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($updatedFilter, function () {
+ Balance::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Balance::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $balance = Balance::query()->where(BaseModel::ATTRIBUTE_UPDATED_AT, $updatedFilter)->get();
+
+ $response = $this->get(route('api.balance.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new BalanceCollection($balance, new BalanceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Balance Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithoutTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITHOUT,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Balance::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteBalance = Balance::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteBalance->each(function (Balance $balance) {
+ $balance->delete();
+ });
+
+ $balance = Balance::withoutTrashed()->get();
+
+ $response = $this->get(route('api.balance.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new BalanceCollection($balance, new BalanceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Balance Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Balance::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteBalance = Balance::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteBalance->each(function (Balance $balance) {
+ $balance->delete();
+ });
+
+ $balance = Balance::withTrashed()->get();
+
+ $response = $this->get(route('api.balance.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new BalanceCollection($balance, new BalanceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Balance Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testOnlyTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::ONLY,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Balance::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteBalance = Balance::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteBalance->each(function (Balance $balance) {
+ $balance->delete();
+ });
+
+ $balance = Balance::onlyTrashed()->get();
+
+ $response = $this->get(route('api.balance.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new BalanceCollection($balance, new BalanceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Balance Index Endpoint shall support filtering by deleted_at.
+ *
+ * @return void
+ */
+ public function testDeletedAtFilter(): void
+ {
+ $deletedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_DELETED_AT => $deletedFilter,
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($deletedFilter, function () {
+ $balances = Balance::factory()->count($this->faker->randomDigitNotNull())->create();
+ $balances->each(function (Balance $balance) {
+ $balance->delete();
+ });
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ $balances = Balance::factory()->count($this->faker->randomDigitNotNull())->create();
+ $balances->each(function (Balance $balance) {
+ $balance->delete();
+ });
+ });
+
+ $balance = Balance::withTrashed()->where(BaseModel::ATTRIBUTE_DELETED_AT, $deletedFilter)->get();
+
+ $response = $this->get(route('api.balance.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new BalanceCollection($balance, new BalanceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Billing/Balance/BalanceRestoreTest.php b/tests/Feature/Http/Api/Billing/Balance/BalanceRestoreTest.php
new file mode 100644
index 000000000..160174c87
--- /dev/null
+++ b/tests/Feature/Http/Api/Billing/Balance/BalanceRestoreTest.php
@@ -0,0 +1,56 @@
+createOne();
+
+ $balance->delete();
+
+ $response = $this->patch(route('api.balance.restore', ['balance' => $balance]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Balance Restore Endpoint shall restore the balance.
+ *
+ * @return void
+ */
+ public function testRestored(): void
+ {
+ $balance = Balance::factory()->createOne();
+
+ $balance->delete();
+
+ $user = User::factory()->withPermission('restore balance')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->patch(route('api.balance.restore', ['balance' => $balance]));
+
+ $response->assertOk();
+ static::assertNotSoftDeleted($balance);
+ }
+}
diff --git a/tests/Feature/Http/Api/Billing/Balance/BalanceShowTest.php b/tests/Feature/Http/Api/Billing/Balance/BalanceShowTest.php
new file mode 100644
index 000000000..64c6932df
--- /dev/null
+++ b/tests/Feature/Http/Api/Billing/Balance/BalanceShowTest.php
@@ -0,0 +1,109 @@
+create();
+
+ $response = $this->get(route('api.balance.show', ['balance' => $balance]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new BalanceResource($balance, new BalanceReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Balance Show Endpoint shall return a Balance Resource for soft deleted images.
+ *
+ * @return void
+ */
+ public function testSoftDelete(): void
+ {
+ $balance = Balance::factory()->createOne();
+
+ $balance->delete();
+
+ $balance->unsetRelations();
+
+ $response = $this->get(route('api.balance.show', ['balance' => $balance]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new BalanceResource($balance, new BalanceReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Balance Show Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $schema = new BalanceSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ BalanceResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $balance = Balance::factory()->create();
+
+ $response = $this->get(route('api.balance.show', ['balance' => $balance]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new BalanceResource($balance, new BalanceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Billing/Balance/BalanceStoreTest.php b/tests/Feature/Http/Api/Billing/Balance/BalanceStoreTest.php
new file mode 100644
index 000000000..64ab897d8
--- /dev/null
+++ b/tests/Feature/Http/Api/Billing/Balance/BalanceStoreTest.php
@@ -0,0 +1,82 @@
+makeOne();
+
+ $response = $this->post(route('api.balance.store', $balance->toArray()));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Balance Store Endpoint shall require date, service, frequency, usage & balance fields.
+ *
+ * @return void
+ */
+ public function testRequiredFields(): void
+ {
+ $user = User::factory()->withPermission('create balance')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.balance.store'));
+
+ $response->assertJsonValidationErrors([
+ Balance::ATTRIBUTE_DATE,
+ Balance::ATTRIBUTE_SERVICE,
+ Balance::ATTRIBUTE_FREQUENCY,
+ Balance::ATTRIBUTE_USAGE,
+ Balance::ATTRIBUTE_BALANCE,
+ ]);
+ }
+
+ /**
+ * The Balance Store Endpoint shall create a balance.
+ *
+ * @return void
+ */
+ public function testCreate(): void
+ {
+ $parameters = array_merge(
+ Balance::factory()->raw(),
+ [
+ Balance::ATTRIBUTE_FREQUENCY => BalanceFrequency::getRandomInstance()->description,
+ Balance::ATTRIBUTE_SERVICE => Service::getRandomInstance()->description,
+ ]
+ );
+
+ $user = User::factory()->withPermission('create balance')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.balance.store', $parameters));
+
+ $response->assertCreated();
+ static::assertDatabaseCount(Balance::TABLE, 1);
+ }
+}
diff --git a/tests/Feature/Http/Api/Billing/Balance/BalanceUpdateTest.php b/tests/Feature/Http/Api/Billing/Balance/BalanceUpdateTest.php
new file mode 100644
index 000000000..8922ac39c
--- /dev/null
+++ b/tests/Feature/Http/Api/Billing/Balance/BalanceUpdateTest.php
@@ -0,0 +1,69 @@
+createOne();
+
+ $parameters = array_merge(
+ Balance::factory()->raw(),
+ [
+ Balance::ATTRIBUTE_FREQUENCY => BalanceFrequency::getRandomInstance()->description,
+ Balance::ATTRIBUTE_SERVICE => Service::getRandomInstance()->description,
+ ]
+ );
+
+ $response = $this->put(route('api.balance.update', ['balance' => $balance] + $parameters));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Balance Update Endpoint shall update a balance.
+ *
+ * @return void
+ */
+ public function testUpdate(): void
+ {
+ $balance = Balance::factory()->createOne();
+
+ $parameters = array_merge(
+ Balance::factory()->raw(),
+ [
+ Balance::ATTRIBUTE_FREQUENCY => BalanceFrequency::getRandomInstance()->description,
+ Balance::ATTRIBUTE_SERVICE => Service::getRandomInstance()->description,
+ ]
+ );
+
+ $user = User::factory()->withPermission('update balance')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->put(route('api.balance.update', ['balance' => $balance] + $parameters));
+
+ $response->assertOk();
+ }
+}
diff --git a/tests/Feature/Http/Api/Billing/Transaction/TransactionDestroyTest.php b/tests/Feature/Http/Api/Billing/Transaction/TransactionDestroyTest.php
new file mode 100644
index 000000000..83ca2c83e
--- /dev/null
+++ b/tests/Feature/Http/Api/Billing/Transaction/TransactionDestroyTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.transaction.destroy', ['transaction' => $transaction]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Transaction Destroy Endpoint shall delete the transaction.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $transaction = Transaction::factory()->createOne();
+
+ $user = User::factory()->withPermission('delete transaction')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.transaction.destroy', ['transaction' => $transaction]));
+
+ $response->assertOk();
+ static::assertSoftDeleted($transaction);
+ }
+}
diff --git a/tests/Feature/Http/Api/Billing/Transaction/TransactionForceDeleteTest.php b/tests/Feature/Http/Api/Billing/Transaction/TransactionForceDeleteTest.php
new file mode 100644
index 000000000..a5b2c74c1
--- /dev/null
+++ b/tests/Feature/Http/Api/Billing/Transaction/TransactionForceDeleteTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.transaction.forceDelete', ['transaction' => $transaction]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Transaction Force Destroy Endpoint shall force delete the transaction.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $transaction = Transaction::factory()->createOne();
+
+ $user = User::factory()->withPermission('force delete transaction')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.transaction.forceDelete', ['transaction' => $transaction]));
+
+ $response->assertOk();
+ static::assertModelMissing($transaction);
+ }
+}
diff --git a/tests/Feature/Http/Api/Billing/Transaction/TransactionIndexTest.php b/tests/Feature/Http/Api/Billing/Transaction/TransactionIndexTest.php
new file mode 100644
index 000000000..f476a05b6
--- /dev/null
+++ b/tests/Feature/Http/Api/Billing/Transaction/TransactionIndexTest.php
@@ -0,0 +1,401 @@
+count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.transaction.index'));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new TransactionCollection($transactions, new TransactionReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Transaction Index Endpoint shall be paginated.
+ *
+ * @return void
+ */
+ public function testPaginated(): void
+ {
+ Transaction::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.transaction.index'));
+
+ $response->assertJsonStructure([
+ TransactionCollection::$wrap,
+ 'links',
+ 'meta',
+ ]);
+ }
+
+ /**
+ * The Transaction Index Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $schema = new TransactionSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ TransactionResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $transactions = Transaction::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.transaction.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new TransactionCollection($transactions, new TransactionReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Transaction Index Endpoint shall support sorting resources.
+ *
+ * @return void
+ */
+ public function testSorts(): void
+ {
+ $schema = new TransactionSchema();
+
+ $sort = collect($schema->fields())
+ ->filter(fn (Field $field) => $field instanceof SortableField)
+ ->map(fn (SortableField $field) => $field->getSort())
+ ->random();
+
+ $parameters = [
+ SortParser::param() => $sort->format(Direction::getRandomInstance()),
+ ];
+
+ $query = new TransactionReadQuery($parameters);
+
+ Transaction::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.transaction.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ $query->collection($query->index())
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Transaction Index Endpoint shall support filtering by created_at.
+ *
+ * @return void
+ */
+ public function testCreatedAtFilter(): void
+ {
+ $createdFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_CREATED_AT => $createdFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($createdFilter, function () {
+ Transaction::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Transaction::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $transaction = Transaction::query()->where(BaseModel::ATTRIBUTE_CREATED_AT, $createdFilter)->get();
+
+ $response = $this->get(route('api.transaction.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new TransactionCollection($transaction, new TransactionReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Transaction Index Endpoint shall support filtering by updated_at.
+ *
+ * @return void
+ */
+ public function testUpdatedAtFilter(): void
+ {
+ $updatedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_UPDATED_AT => $updatedFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($updatedFilter, function () {
+ Transaction::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Transaction::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $transaction = Transaction::query()->where(BaseModel::ATTRIBUTE_UPDATED_AT, $updatedFilter)->get();
+
+ $response = $this->get(route('api.transaction.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new TransactionCollection($transaction, new TransactionReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Transaction Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithoutTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITHOUT,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Transaction::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteTransaction = Transaction::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteTransaction->each(function (Transaction $transaction) {
+ $transaction->delete();
+ });
+
+ $transaction = Transaction::withoutTrashed()->get();
+
+ $response = $this->get(route('api.transaction.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new TransactionCollection($transaction, new TransactionReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Transaction Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Transaction::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteTransaction = Transaction::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteTransaction->each(function (Transaction $transaction) {
+ $transaction->delete();
+ });
+
+ $transaction = Transaction::withTrashed()->get();
+
+ $response = $this->get(route('api.transaction.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new TransactionCollection($transaction, new TransactionReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Transaction Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testOnlyTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::ONLY,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Transaction::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteTransaction = Transaction::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteTransaction->each(function (Transaction $transaction) {
+ $transaction->delete();
+ });
+
+ $transaction = Transaction::onlyTrashed()->get();
+
+ $response = $this->get(route('api.transaction.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new TransactionCollection($transaction, new TransactionReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Transaction Index Endpoint shall support filtering by deleted_at.
+ *
+ * @return void
+ */
+ public function testDeletedAtFilter(): void
+ {
+ $deletedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_DELETED_AT => $deletedFilter,
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($deletedFilter, function () {
+ $transactions = Transaction::factory()->count($this->faker->randomDigitNotNull())->create();
+ $transactions->each(function (Transaction $transaction) {
+ $transaction->delete();
+ });
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ $transactions = Transaction::factory()->count($this->faker->randomDigitNotNull())->create();
+ $transactions->each(function (Transaction $transaction) {
+ $transaction->delete();
+ });
+ });
+
+ $transaction = Transaction::withTrashed()->where(BaseModel::ATTRIBUTE_DELETED_AT, $deletedFilter)->get();
+
+ $response = $this->get(route('api.transaction.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new TransactionCollection($transaction, new TransactionReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Billing/Transaction/TransactionRestoreTest.php b/tests/Feature/Http/Api/Billing/Transaction/TransactionRestoreTest.php
new file mode 100644
index 000000000..8ece849a8
--- /dev/null
+++ b/tests/Feature/Http/Api/Billing/Transaction/TransactionRestoreTest.php
@@ -0,0 +1,56 @@
+createOne();
+
+ $transaction->delete();
+
+ $response = $this->patch(route('api.transaction.restore', ['transaction' => $transaction]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Transaction Restore Endpoint shall restore the transaction.
+ *
+ * @return void
+ */
+ public function testRestored(): void
+ {
+ $transaction = Transaction::factory()->createOne();
+
+ $transaction->delete();
+
+ $user = User::factory()->withPermission('restore transaction')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->patch(route('api.transaction.restore', ['transaction' => $transaction]));
+
+ $response->assertOk();
+ static::assertNotSoftDeleted($transaction);
+ }
+}
diff --git a/tests/Feature/Http/Api/Billing/Transaction/TransactionShowTest.php b/tests/Feature/Http/Api/Billing/Transaction/TransactionShowTest.php
new file mode 100644
index 000000000..3e291e571
--- /dev/null
+++ b/tests/Feature/Http/Api/Billing/Transaction/TransactionShowTest.php
@@ -0,0 +1,109 @@
+create();
+
+ $response = $this->get(route('api.transaction.show', ['transaction' => $transaction]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new TransactionResource($transaction, new TransactionReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Transaction Show Endpoint shall return a Transaction Resource for soft deleted images.
+ *
+ * @return void
+ */
+ public function testSoftDelete(): void
+ {
+ $transaction = Transaction::factory()->createOne();
+
+ $transaction->delete();
+
+ $transaction->unsetRelations();
+
+ $response = $this->get(route('api.transaction.show', ['transaction' => $transaction]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new TransactionResource($transaction, new TransactionReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Transaction Show Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $schema = new TransactionSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ TransactionResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $transaction = Transaction::factory()->create();
+
+ $response = $this->get(route('api.transaction.show', ['transaction' => $transaction]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new TransactionResource($transaction, new TransactionReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Billing/Transaction/TransactionStoreTest.php b/tests/Feature/Http/Api/Billing/Transaction/TransactionStoreTest.php
new file mode 100644
index 000000000..3e351f641
--- /dev/null
+++ b/tests/Feature/Http/Api/Billing/Transaction/TransactionStoreTest.php
@@ -0,0 +1,77 @@
+makeOne();
+
+ $response = $this->post(route('api.transaction.store', $transaction->toArray()));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Transaction Store Endpoint shall require amount, date, description & service fields.
+ *
+ * @return void
+ */
+ public function testRequiredFields(): void
+ {
+ $user = User::factory()->withPermission('create transaction')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.transaction.store'));
+
+ $response->assertJsonValidationErrors([
+ Transaction::ATTRIBUTE_AMOUNT,
+ Transaction::ATTRIBUTE_DATE,
+ Transaction::ATTRIBUTE_DESCRIPTION,
+ Transaction::ATTRIBUTE_SERVICE,
+ ]);
+ }
+
+ /**
+ * The Transaction Store Endpoint shall create a transaction.
+ *
+ * @return void
+ */
+ public function testCreate(): void
+ {
+ $parameters = array_merge(
+ Transaction::factory()->raw(),
+ [Transaction::ATTRIBUTE_SERVICE => Service::getRandomInstance()->description]
+ );
+
+ $user = User::factory()->withPermission('create transaction')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.transaction.store', $parameters));
+
+ $response->assertCreated();
+ static::assertDatabaseCount(Transaction::TABLE, 1);
+ }
+}
diff --git a/tests/Feature/Http/Api/Billing/Transaction/TransactionUpdateTest.php b/tests/Feature/Http/Api/Billing/Transaction/TransactionUpdateTest.php
new file mode 100644
index 000000000..bc47f0daf
--- /dev/null
+++ b/tests/Feature/Http/Api/Billing/Transaction/TransactionUpdateTest.php
@@ -0,0 +1,62 @@
+createOne();
+
+ $parameters = array_merge(
+ Transaction::factory()->raw(),
+ [Transaction::ATTRIBUTE_SERVICE => Service::getRandomInstance()->description]
+ );
+
+ $response = $this->put(route('api.transaction.update', ['transaction' => $transaction] + $parameters));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Transaction Update Endpoint shall update a transaction.
+ *
+ * @return void
+ */
+ public function testUpdate(): void
+ {
+ $transaction = Transaction::factory()->createOne();
+
+ $parameters = array_merge(
+ Transaction::factory()->raw(),
+ [Transaction::ATTRIBUTE_SERVICE => Service::getRandomInstance()->description]
+ );
+
+ $user = User::factory()->withPermission('update transaction')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->put(route('api.transaction.update', ['transaction' => $transaction] + $parameters));
+
+ $response->assertOk();
+ }
+}
diff --git a/tests/Feature/Http/Api/Config/FlagsShowTest.php b/tests/Feature/Http/Api/Config/FlagsShowTest.php
new file mode 100644
index 000000000..b86511438
--- /dev/null
+++ b/tests/Feature/Http/Api/Config/FlagsShowTest.php
@@ -0,0 +1,87 @@
+faker->boolean());
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, $this->faker->boolean());
+ Config::set(FlagConstants::ALLOW_VIEW_RECORDING_FLAG_QUALIFIED, $this->faker->boolean());
+
+ $response = $this->get(route('api.config.flags.show'));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new FlagsResource(new FlagsReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Flags Show Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, $this->faker->boolean());
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, $this->faker->boolean());
+ Config::set(FlagConstants::ALLOW_VIEW_RECORDING_FLAG_QUALIFIED, $this->faker->boolean());
+
+ $schema = new FlagsSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ FlagsResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $response = $this->get(route('api.config.flags.show'));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new FlagsResource(new FlagsReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Config/WikiShowTest.php b/tests/Feature/Http/Api/Config/WikiShowTest.php
new file mode 100644
index 000000000..b2c7f1daa
--- /dev/null
+++ b/tests/Feature/Http/Api/Config/WikiShowTest.php
@@ -0,0 +1,104 @@
+for(Video::factory())
+ ->for(AnimeThemeEntry::factory()->for(AnimeTheme::factory()->for(Anime::factory())))
+ ->createOne();
+
+ Config::set(WikiConstants::FEATURED_ENTRY_SETTING_QUALIFIED, $pivot->entry_id);
+ Config::set(WikiConstants::FEATURED_VIDEO_SETTING_QUALIFIED, $pivot->video_id);
+
+ $response = $this->get(route('api.config.wiki.show'));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new WikiResource(new WikiReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Wiki Show Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ Event::fakeExcept(ThemeCreating::class);
+
+ $pivot = AnimeThemeEntryVideo::factory()
+ ->for(Video::factory())
+ ->for(AnimeThemeEntry::factory()->for(AnimeTheme::factory()->for(Anime::factory())))
+ ->createOne();
+
+ Config::set(WikiConstants::FEATURED_ENTRY_SETTING_QUALIFIED, $pivot->entry_id);
+ Config::set(WikiConstants::FEATURED_VIDEO_SETTING_QUALIFIED, $pivot->video_id);
+
+ $schema = new WikiSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ WikiResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $response = $this->get(route('api.config.wiki.show'));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new WikiResource(new WikiReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Document/Page/PageDestroyTest.php b/tests/Feature/Http/Api/Document/Page/PageDestroyTest.php
new file mode 100644
index 000000000..245aefb95
--- /dev/null
+++ b/tests/Feature/Http/Api/Document/Page/PageDestroyTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.page.destroy', ['page' => $page]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Page Destroy Endpoint shall delete the page.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $page = Page::factory()->createOne();
+
+ $user = User::factory()->withPermission('delete page')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.page.destroy', ['page' => $page]));
+
+ $response->assertOk();
+ static::assertSoftDeleted($page);
+ }
+}
diff --git a/tests/Feature/Http/Api/Document/Page/PageForceDeleteTest.php b/tests/Feature/Http/Api/Document/Page/PageForceDeleteTest.php
new file mode 100644
index 000000000..0c648cb91
--- /dev/null
+++ b/tests/Feature/Http/Api/Document/Page/PageForceDeleteTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.page.forceDelete', ['page' => $page]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Page Force Destroy Endpoint shall force delete the page.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $page = Page::factory()->createOne();
+
+ $user = User::factory()->withPermission('force delete page')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.page.forceDelete', ['page' => $page]));
+
+ $response->assertOk();
+ static::assertModelMissing($page);
+ }
+}
diff --git a/tests/Feature/Http/Api/Document/Page/PageIndexTest.php b/tests/Feature/Http/Api/Document/Page/PageIndexTest.php
new file mode 100644
index 000000000..7c01cb17f
--- /dev/null
+++ b/tests/Feature/Http/Api/Document/Page/PageIndexTest.php
@@ -0,0 +1,401 @@
+count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.page.index'));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new PageCollection($pages, new PageReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Page Index Endpoint shall be paginated.
+ *
+ * @return void
+ */
+ public function testPaginated(): void
+ {
+ Page::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.page.index'));
+
+ $response->assertJsonStructure([
+ PageCollection::$wrap,
+ 'links',
+ 'meta',
+ ]);
+ }
+
+ /**
+ * The Page Index Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $schema = new PageSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ PageResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $pages = Page::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.page.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new PageCollection($pages, new PageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Page Index Endpoint shall support sorting resources.
+ *
+ * @return void
+ */
+ public function testSorts(): void
+ {
+ $schema = new PageSchema();
+
+ $sort = collect($schema->fields())
+ ->filter(fn (Field $field) => $field instanceof SortableField)
+ ->map(fn (SortableField $field) => $field->getSort())
+ ->random();
+
+ $parameters = [
+ SortParser::param() => $sort->format(Direction::getRandomInstance()),
+ ];
+
+ $query = new PageReadQuery($parameters);
+
+ Page::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.page.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ $query->collection($query->index())
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Page Index Endpoint shall support filtering by created_at.
+ *
+ * @return void
+ */
+ public function testCreatedAtFilter(): void
+ {
+ $createdFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_CREATED_AT => $createdFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($createdFilter, function () {
+ Page::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Page::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $page = Page::query()->where(BaseModel::ATTRIBUTE_CREATED_AT, $createdFilter)->get();
+
+ $response = $this->get(route('api.page.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new PageCollection($page, new PageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Page Index Endpoint shall support filtering by updated_at.
+ *
+ * @return void
+ */
+ public function testUpdatedAtFilter(): void
+ {
+ $updatedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_UPDATED_AT => $updatedFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($updatedFilter, function () {
+ Page::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Page::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $page = Page::query()->where(BaseModel::ATTRIBUTE_UPDATED_AT, $updatedFilter)->get();
+
+ $response = $this->get(route('api.page.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new PageCollection($page, new PageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Page Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithoutTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITHOUT,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Page::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deletePage = Page::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deletePage->each(function (Page $page) {
+ $page->delete();
+ });
+
+ $page = Page::withoutTrashed()->get();
+
+ $response = $this->get(route('api.page.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new PageCollection($page, new PageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Page Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Page::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deletePage = Page::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deletePage->each(function (Page $page) {
+ $page->delete();
+ });
+
+ $page = Page::withTrashed()->get();
+
+ $response = $this->get(route('api.page.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new PageCollection($page, new PageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Page Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testOnlyTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::ONLY,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Page::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deletePage = Page::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deletePage->each(function (Page $page) {
+ $page->delete();
+ });
+
+ $page = Page::onlyTrashed()->get();
+
+ $response = $this->get(route('api.page.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new PageCollection($page, new PageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Page Index Endpoint shall support filtering by deleted_at.
+ *
+ * @return void
+ */
+ public function testDeletedAtFilter(): void
+ {
+ $deletedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_DELETED_AT => $deletedFilter,
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($deletedFilter, function () {
+ $pages = Page::factory()->count($this->faker->randomDigitNotNull())->create();
+ $pages->each(function (Page $page) {
+ $page->delete();
+ });
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ $pages = Page::factory()->count($this->faker->randomDigitNotNull())->create();
+ $pages->each(function (Page $page) {
+ $page->delete();
+ });
+ });
+
+ $page = Page::withTrashed()->where(BaseModel::ATTRIBUTE_DELETED_AT, $deletedFilter)->get();
+
+ $response = $this->get(route('api.page.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new PageCollection($page, new PageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Document/Page/PageRestoreTest.php b/tests/Feature/Http/Api/Document/Page/PageRestoreTest.php
new file mode 100644
index 000000000..693893cb0
--- /dev/null
+++ b/tests/Feature/Http/Api/Document/Page/PageRestoreTest.php
@@ -0,0 +1,56 @@
+createOne();
+
+ $page->delete();
+
+ $response = $this->patch(route('api.page.restore', ['page' => $page]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Page Restore Endpoint shall restore the page.
+ *
+ * @return void
+ */
+ public function testRestored(): void
+ {
+ $page = Page::factory()->createOne();
+
+ $page->delete();
+
+ $user = User::factory()->withPermission('restore page')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->patch(route('api.page.restore', ['page' => $page]));
+
+ $response->assertOk();
+ static::assertNotSoftDeleted($page);
+ }
+}
diff --git a/tests/Feature/Http/Api/Document/Page/PageShowTest.php b/tests/Feature/Http/Api/Document/Page/PageShowTest.php
new file mode 100644
index 000000000..229368794
--- /dev/null
+++ b/tests/Feature/Http/Api/Document/Page/PageShowTest.php
@@ -0,0 +1,109 @@
+create();
+
+ $response = $this->get(route('api.page.show', ['page' => $page]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new PageResource($page, new PageReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Page Show Endpoint shall return a Page Resource for soft deleted images.
+ *
+ * @return void
+ */
+ public function testSoftDelete(): void
+ {
+ $page = Page::factory()->createOne();
+
+ $page->delete();
+
+ $page->unsetRelations();
+
+ $response = $this->get(route('api.page.show', ['page' => $page]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new PageResource($page, new PageReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Page Show Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $schema = new PageSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ PageResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $page = Page::factory()->create();
+
+ $response = $this->get(route('api.page.show', ['page' => $page]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new PageResource($page, new PageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Document/Page/PageStoreTest.php b/tests/Feature/Http/Api/Document/Page/PageStoreTest.php
new file mode 100644
index 000000000..02a6e1f93
--- /dev/null
+++ b/tests/Feature/Http/Api/Document/Page/PageStoreTest.php
@@ -0,0 +1,72 @@
+makeOne();
+
+ $response = $this->post(route('api.page.store', $page->toArray()));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Page Store Endpoint shall require body, name & slug fields.
+ *
+ * @return void
+ */
+ public function testRequiredFields(): void
+ {
+ $user = User::factory()->withPermission('create page')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.page.store'));
+
+ $response->assertJsonValidationErrors([
+ Page::ATTRIBUTE_BODY,
+ Page::ATTRIBUTE_NAME,
+ Page::ATTRIBUTE_SLUG,
+ ]);
+ }
+
+ /**
+ * The Page Store Endpoint shall create a page.
+ *
+ * @return void
+ */
+ public function testCreate(): void
+ {
+ $parameters = Page::factory()->raw();
+
+ $user = User::factory()->withPermission('create page')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.page.store', $parameters));
+
+ $response->assertCreated();
+ static::assertDatabaseCount(Page::TABLE, 1);
+ }
+}
diff --git a/tests/Feature/Http/Api/Document/Page/PageUpdateTest.php b/tests/Feature/Http/Api/Document/Page/PageUpdateTest.php
new file mode 100644
index 000000000..9baa94ccf
--- /dev/null
+++ b/tests/Feature/Http/Api/Document/Page/PageUpdateTest.php
@@ -0,0 +1,55 @@
+createOne();
+
+ $parameters = Page::factory()->raw();
+
+ $response = $this->put(route('api.page.update', ['page' => $page] + $parameters));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Page Update Endpoint shall update an page.
+ *
+ * @return void
+ */
+ public function testUpdate(): void
+ {
+ $page = Page::factory()->createOne();
+
+ $parameters = Page::factory()->raw();
+
+ $user = User::factory()->withPermission('update page')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->put(route('api.page.update', ['page' => $page] + $parameters));
+
+ $response->assertOk();
+ }
+}
diff --git a/tests/Feature/Http/Api/FallbackTest.php b/tests/Feature/Http/Api/FallbackTest.php
new file mode 100644
index 000000000..6b20e4afc
--- /dev/null
+++ b/tests/Feature/Http/Api/FallbackTest.php
@@ -0,0 +1,29 @@
+get(route('api.anime.index').Str::random());
+
+ $response->assertJsonStructure([
+ 'message',
+ ]);
+ }
+}
diff --git a/tests/Feature/Http/Api/ThrottleTest.php b/tests/Feature/Http/Api/ThrottleTest.php
new file mode 100644
index 000000000..41b937e0b
--- /dev/null
+++ b/tests/Feature/Http/Api/ThrottleTest.php
@@ -0,0 +1,45 @@
+get(route('api.anime.index'));
+
+ $response->assertHeader('X-RateLimit-Limit');
+ $response->assertHeader('X-RateLimit-Remaining');
+ }
+
+ /**
+ * Users with the 'bypass api rate limiter' permission shall not be rate limited.
+ *
+ * @return void
+ */
+ public function testClientNotRateLimited(): void
+ {
+ $user = User::factory()->withPermission('bypass api rate limiter')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->get(route('api.anime.index'));
+
+ $response->assertHeaderMissing('X-RateLimit-Limit');
+ $response->assertHeaderMissing('X-RateLimit-Remaining');
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/AnimeDestroyTest.php b/tests/Feature/Http/Api/Wiki/Anime/AnimeDestroyTest.php
new file mode 100644
index 000000000..7d6d22379
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/AnimeDestroyTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.anime.destroy', ['anime' => $anime]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Anime Destroy Endpoint shall delete the anime.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $anime = Anime::factory()->createOne();
+
+ $user = User::factory()->withPermission('delete anime')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.anime.destroy', ['anime' => $anime]));
+
+ $response->assertOk();
+ static::assertSoftDeleted($anime);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/AnimeForceDeleteTest.php b/tests/Feature/Http/Api/Wiki/Anime/AnimeForceDeleteTest.php
new file mode 100644
index 000000000..856c3bb11
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/AnimeForceDeleteTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.anime.forceDelete', ['anime' => $anime]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Anime Force Destroy Endpoint shall force delete the anime.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $anime = Anime::factory()->createOne();
+
+ $user = User::factory()->withPermission('force delete anime')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.anime.forceDelete', ['anime' => $anime]));
+
+ $response->assertOk();
+ static::assertModelMissing($anime);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/AnimeIndexTest.php b/tests/Feature/Http/Api/Wiki/Anime/AnimeIndexTest.php
new file mode 100644
index 000000000..c8903fb91
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/AnimeIndexTest.php
@@ -0,0 +1,1211 @@
+withoutEvents();
+
+ $anime = Anime::factory()->count($this->faker->numberBetween(1, 3))->create();
+
+ $response = $this->get(route('api.anime.index'));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall be paginated.
+ *
+ * @return void
+ */
+ public function testPaginated(): void
+ {
+ $this->withoutEvents();
+
+ Anime::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.anime.index'));
+
+ $response->assertJsonStructure([
+ AnimeCollection::$wrap,
+ 'links',
+ 'meta',
+ ]);
+ }
+
+ /**
+ * The Anime Index Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new AnimeSchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ Anime::factory()->jsonApiResource()->count($this->faker->numberBetween(1, 3))->create();
+ $anime = Anime::with($includedPaths->all())->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $this->withoutEvents();
+
+ $schema = new AnimeSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ AnimeResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $anime = Anime::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support sorting resources.
+ *
+ * @return void
+ */
+ public function testSorts(): void
+ {
+ $this->withoutEvents();
+
+ $schema = new AnimeSchema();
+
+ $sort = collect($schema->fields())
+ ->filter(fn (Field $field) => $field instanceof SortableField)
+ ->map(fn (SortableField $field) => $field->getSort())
+ ->random();
+
+ $parameters = [
+ SortParser::param() => $sort->format(Direction::getRandomInstance()),
+ ];
+
+ $query = new AnimeReadQuery($parameters);
+
+ Anime::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ $query->collection($query->index())
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support filtering by season.
+ *
+ * @return void
+ */
+ public function testSeasonFilter(): void
+ {
+ $this->withoutEvents();
+
+ $seasonFilter = AnimeSeason::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_SEASON => $seasonFilter->description,
+ ],
+ ];
+
+ Anime::factory()->count($this->faker->randomDigitNotNull())->create();
+ $anime = Anime::query()->where(Anime::ATTRIBUTE_SEASON, $seasonFilter->value)->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support filtering by season.
+ *
+ * @return void
+ */
+ public function testYearFilter(): void
+ {
+ $this->withoutEvents();
+
+ $yearFilter = $this->faker->numberBetween(2000, 2002);
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_YEAR => $yearFilter,
+ ],
+ ];
+
+ Anime::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->state(new Sequence(
+ [Anime::ATTRIBUTE_YEAR => 2000],
+ [Anime::ATTRIBUTE_YEAR => 2001],
+ [Anime::ATTRIBUTE_YEAR => 2002],
+ ))
+ ->create();
+
+ $anime = Anime::query()->where(Anime::ATTRIBUTE_YEAR, $yearFilter)->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support filtering by created_at.
+ *
+ * @return void
+ */
+ public function testCreatedAtFilter(): void
+ {
+ $this->withoutEvents();
+
+ $createdFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_CREATED_AT => $createdFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($createdFilter, function () {
+ Anime::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Anime::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $anime = Anime::query()->where(BaseModel::ATTRIBUTE_CREATED_AT, $createdFilter)->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support filtering by updated_at.
+ *
+ * @return void
+ */
+ public function testUpdatedAtFilter(): void
+ {
+ $this->withoutEvents();
+
+ $updatedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_UPDATED_AT => $updatedFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($updatedFilter, function () {
+ Anime::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Anime::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $anime = Anime::query()->where(BaseModel::ATTRIBUTE_UPDATED_AT, $updatedFilter)->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithoutTrashedFilter(): void
+ {
+ $this->withoutEvents();
+
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITHOUT,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Anime::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteAnime = Anime::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteAnime->each(function (Anime $anime) {
+ $anime->delete();
+ });
+
+ $anime = Anime::withoutTrashed()->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithTrashedFilter(): void
+ {
+ $this->withoutEvents();
+
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Anime::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteAnime = Anime::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteAnime->each(function (Anime $anime) {
+ $anime->delete();
+ });
+
+ $anime = Anime::withTrashed()->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testOnlyTrashedFilter(): void
+ {
+ $this->withoutEvents();
+
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::ONLY,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Anime::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteAnime = Anime::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteAnime->each(function (Anime $anime) {
+ $anime->delete();
+ });
+
+ $anime = Anime::onlyTrashed()->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support filtering by deleted_at.
+ *
+ * @return void
+ */
+ public function testDeletedAtFilter(): void
+ {
+ $this->withoutEvents();
+
+ $deletedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_DELETED_AT => $deletedFilter,
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($deletedFilter, function () {
+ $anime = Anime::factory()->count($this->faker->randomDigitNotNull())->create();
+ $anime->each(function (Anime $item) {
+ $item->delete();
+ });
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ $anime = Anime::factory()->count($this->faker->randomDigitNotNull())->create();
+ $anime->each(function (Anime $item) {
+ $item->delete();
+ });
+ });
+
+ $anime = Anime::withTrashed()->where(BaseModel::ATTRIBUTE_DELETED_AT, $deletedFilter)->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support constrained eager loading of themes by group.
+ *
+ * @return void
+ */
+ public function testThemesByGroup(): void
+ {
+ $groupFilter = $this->faker->word();
+ $excludedGroup = $this->faker->word();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_GROUP => $groupFilter,
+ ],
+ IncludeParser::param() => Anime::RELATION_THEMES,
+ ];
+
+ Anime::factory()
+ ->has(
+ AnimeTheme::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->state(new Sequence(
+ [AnimeTheme::ATTRIBUTE_GROUP => $groupFilter],
+ [AnimeTheme::ATTRIBUTE_GROUP => $excludedGroup],
+ ))
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $anime = Anime::with([
+ Anime::RELATION_THEMES => function (HasMany $query) use ($groupFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_GROUP, $groupFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support constrained eager loading of themes by sequence.
+ *
+ * @return void
+ */
+ public function testThemesBySequence(): void
+ {
+ $sequenceFilter = $this->faker->randomDigitNotNull();
+ $excludedSequence = $sequenceFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_SEQUENCE => $sequenceFilter,
+ ],
+ IncludeParser::param() => Anime::RELATION_THEMES,
+ ];
+
+ Anime::factory()
+ ->has(
+ AnimeTheme::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->state(new Sequence(
+ [AnimeTheme::ATTRIBUTE_SEQUENCE => $sequenceFilter],
+ [AnimeTheme::ATTRIBUTE_SEQUENCE => $excludedSequence],
+ ))
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $anime = Anime::with([
+ Anime::RELATION_THEMES => function (HasMany $query) use ($sequenceFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_SEQUENCE, $sequenceFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support constrained eager loading of themes by type.
+ *
+ * @return void
+ */
+ public function testThemesByType(): void
+ {
+ $typeFilter = ThemeType::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_TYPE => $typeFilter->description,
+ ],
+ IncludeParser::param() => Anime::RELATION_THEMES,
+ ];
+
+ Anime::factory()
+ ->has(AnimeTheme::factory()->count($this->faker->randomDigitNotNull()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $anime = Anime::with([
+ Anime::RELATION_THEMES => function (HasMany $query) use ($typeFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_TYPE, $typeFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support constrained eager loading of entries by nsfw.
+ *
+ * @return void
+ */
+ public function testEntriesByNsfw(): void
+ {
+ $nsfwFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeThemeEntry::ATTRIBUTE_NSFW => $nsfwFilter,
+ ],
+ IncludeParser::param() => Anime::RELATION_ENTRIES,
+ ];
+
+ Anime::factory()
+ ->has(
+ AnimeTheme::factory()
+ ->has(AnimeThemeEntry::factory()->count($this->faker->numberBetween(1, 3)))
+ ->count($this->faker->numberBetween(1, 3))
+ )
+ ->count($this->faker->numberBetween(1, 3))
+ ->create();
+
+ $anime = Anime::with([
+ Anime::RELATION_ENTRIES => function (HasMany $query) use ($nsfwFilter) {
+ $query->where(AnimeThemeEntry::ATTRIBUTE_NSFW, $nsfwFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support constrained eager loading of entries by spoiler.
+ *
+ * @return void
+ */
+ public function testEntriesBySpoiler(): void
+ {
+ $spoilerFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeThemeEntry::ATTRIBUTE_SPOILER => $spoilerFilter,
+ ],
+ IncludeParser::param() => Anime::RELATION_ENTRIES,
+ ];
+
+ Anime::factory()
+ ->has(
+ AnimeTheme::factory()
+ ->has(AnimeThemeEntry::factory()->count($this->faker->numberBetween(1, 3)))
+ ->count($this->faker->numberBetween(1, 3))
+ )
+ ->count($this->faker->numberBetween(1, 3))
+ ->create();
+
+ $anime = Anime::with([
+ Anime::RELATION_ENTRIES => function (HasMany $query) use ($spoilerFilter) {
+ $query->where(AnimeThemeEntry::ATTRIBUTE_SPOILER, $spoilerFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support constrained eager loading of entries by version.
+ *
+ * @return void
+ */
+ public function testEntriesByVersion(): void
+ {
+ $versionFilter = $this->faker->numberBetween(1, 3);
+ $excludedVersion = $versionFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeThemeEntry::ATTRIBUTE_VERSION => $versionFilter,
+ ],
+ IncludeParser::param() => Anime::RELATION_ENTRIES,
+ ];
+
+ Anime::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->has(
+ AnimeTheme::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->state(new Sequence(
+ [AnimeThemeEntry::ATTRIBUTE_VERSION => $versionFilter],
+ [AnimeThemeEntry::ATTRIBUTE_VERSION => $excludedVersion],
+ ))
+ )
+ )
+ ->create();
+
+ $anime = Anime::with([
+ Anime::RELATION_ENTRIES => function (HasMany $query) use ($versionFilter) {
+ $query->where(AnimeThemeEntry::ATTRIBUTE_VERSION, $versionFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support constrained eager loading of resources by site.
+ *
+ * @return void
+ */
+ public function testResourcesBySite(): void
+ {
+ $siteFilter = ResourceSite::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ ExternalResource::ATTRIBUTE_SITE => $siteFilter->description,
+ ],
+ IncludeParser::param() => Anime::RELATION_RESOURCES,
+ ];
+
+ Anime::factory()
+ ->has(ExternalResource::factory()->count($this->faker->randomDigitNotNull()), Anime::RELATION_RESOURCES)
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $anime = Anime::with([
+ Anime::RELATION_RESOURCES => function (BelongsToMany $query) use ($siteFilter) {
+ $query->where(ExternalResource::ATTRIBUTE_SITE, $siteFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support constrained eager loading of images by facet.
+ *
+ * @return void
+ */
+ public function testImagesByFacet(): void
+ {
+ $facetFilter = ImageFacet::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Image::ATTRIBUTE_FACET => $facetFilter->description,
+ ],
+ IncludeParser::param() => Anime::RELATION_IMAGES,
+ ];
+
+ Anime::factory()
+ ->has(Image::factory()->count($this->faker->randomDigitNotNull()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $anime = Anime::with([
+ Anime::RELATION_IMAGES => function (BelongsToMany $query) use ($facetFilter) {
+ $query->where(Image::ATTRIBUTE_FACET, $facetFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support constrained eager loading of videos by lyrics.
+ *
+ * @return void
+ */
+ public function testVideosByLyrics(): void
+ {
+ $lyricsFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_LYRICS => $lyricsFilter,
+ ],
+ IncludeParser::param() => Anime::RELATION_VIDEOS,
+ ];
+
+ Anime::factory()->jsonApiResource()->count($this->faker->numberBetween(1, 3))->create();
+
+ $anime = Anime::with([
+ Anime::RELATION_VIDEOS => function (BelongsToMany $query) use ($lyricsFilter) {
+ $query->where(Video::ATTRIBUTE_LYRICS, $lyricsFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support constrained eager loading of videos by nc.
+ *
+ * @return void
+ */
+ public function testVideosByNc(): void
+ {
+ $ncFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_NC => $ncFilter,
+ ],
+ IncludeParser::param() => Anime::RELATION_VIDEOS,
+ ];
+
+ Anime::factory()->jsonApiResource()->count($this->faker->numberBetween(1, 3))->create();
+
+ $anime = Anime::with([
+ Anime::RELATION_VIDEOS => function (BelongsToMany $query) use ($ncFilter) {
+ $query->where(Video::ATTRIBUTE_NC, $ncFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support constrained eager loading of videos by overlap.
+ *
+ * @return void
+ */
+ public function testVideosByOverlap(): void
+ {
+ $overlapFilter = VideoOverlap::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_OVERLAP => $overlapFilter->description,
+ ],
+ IncludeParser::param() => Anime::RELATION_VIDEOS,
+ ];
+
+ Anime::factory()->jsonApiResource()->count($this->faker->numberBetween(1, 3))->create();
+
+ $anime = Anime::with([
+ Anime::RELATION_VIDEOS => function (BelongsToMany $query) use ($overlapFilter) {
+ $query->where(Video::ATTRIBUTE_OVERLAP, $overlapFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support constrained eager loading of videos by resolution.
+ *
+ * @return void
+ */
+ public function testVideosByResolution(): void
+ {
+ $resolutionFilter = $this->faker->randomNumber();
+ $excludedResolution = $resolutionFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_RESOLUTION => $resolutionFilter,
+ ],
+ IncludeParser::param() => Anime::RELATION_VIDEOS,
+ ];
+
+ Anime::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->has(
+ AnimeTheme::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->has(
+ Video::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->state(new Sequence(
+ [Video::ATTRIBUTE_RESOLUTION => $resolutionFilter],
+ [Video::ATTRIBUTE_RESOLUTION => $excludedResolution],
+ ))
+ )
+ )
+ )
+ ->create();
+
+ $anime = Anime::with([
+ Anime::RELATION_VIDEOS => function (BelongsToMany $query) use ($resolutionFilter) {
+ $query->where(Video::ATTRIBUTE_RESOLUTION, $resolutionFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support constrained eager loading of videos by source.
+ *
+ * @return void
+ */
+ public function testVideosBySource(): void
+ {
+ $sourceFilter = VideoSource::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_SOURCE => $sourceFilter->description,
+ ],
+ IncludeParser::param() => Anime::RELATION_VIDEOS,
+ ];
+
+ Anime::factory()->jsonApiResource()->count($this->faker->numberBetween(1, 3))->create();
+
+ $anime = Anime::with([
+ Anime::RELATION_VIDEOS => function (BelongsToMany $query) use ($sourceFilter) {
+ $query->where(Video::ATTRIBUTE_SOURCE, $sourceFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support constrained eager loading of videos by subbed.
+ *
+ * @return void
+ */
+ public function testVideosBySubbed(): void
+ {
+ $subbedFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_SUBBED => $subbedFilter,
+ ],
+ IncludeParser::param() => Anime::RELATION_VIDEOS,
+ ];
+
+ Anime::factory()->jsonApiResource()->count($this->faker->numberBetween(1, 3))->create();
+
+ $anime = Anime::with([
+ Anime::RELATION_VIDEOS => function (BelongsToMany $query) use ($subbedFilter) {
+ $query->where(Video::ATTRIBUTE_SUBBED, $subbedFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support constrained eager loading of videos by uncen.
+ *
+ * @return void
+ */
+ public function testVideosByUncen(): void
+ {
+ $uncenFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_UNCEN => $uncenFilter,
+ ],
+ IncludeParser::param() => Anime::RELATION_VIDEOS,
+ ];
+
+ Anime::factory()->jsonApiResource()->count($this->faker->numberBetween(1, 3))->create();
+
+ $anime = Anime::with([
+ Anime::RELATION_VIDEOS => function (BelongsToMany $query) use ($uncenFilter) {
+ $query->where(Video::ATTRIBUTE_UNCEN, $uncenFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.anime.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeCollection($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/AnimeRestoreTest.php b/tests/Feature/Http/Api/Wiki/Anime/AnimeRestoreTest.php
new file mode 100644
index 000000000..8a594b010
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/AnimeRestoreTest.php
@@ -0,0 +1,56 @@
+createOne();
+
+ $anime->delete();
+
+ $response = $this->patch(route('api.anime.restore', ['anime' => $anime]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Anime Restore Endpoint shall restore the anime.
+ *
+ * @return void
+ */
+ public function testRestored(): void
+ {
+ $anime = Anime::factory()->createOne();
+
+ $anime->delete();
+
+ $user = User::factory()->withPermission('restore anime')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->patch(route('api.anime.restore', ['anime' => $anime]));
+
+ $response->assertOk();
+ static::assertNotSoftDeleted($anime);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/AnimeShowTest.php b/tests/Feature/Http/Api/Wiki/Anime/AnimeShowTest.php
new file mode 100644
index 000000000..fabdb60fc
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/AnimeShowTest.php
@@ -0,0 +1,808 @@
+withoutEvents();
+
+ $anime = Anime::factory()->create();
+
+ $response = $this->get(route('api.anime.show', ['anime' => $anime]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeResource($anime, new AnimeReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Show Endpoint shall return an Anime Resource for soft deleted anime.
+ *
+ * @return void
+ */
+ public function testSoftDelete(): void
+ {
+ $this->withoutEvents();
+
+ $anime = Anime::factory()->createOne();
+
+ $anime->delete();
+
+ $anime->unsetRelations();
+
+ $response = $this->get(route('api.anime.show', ['anime' => $anime]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeResource($anime, new AnimeReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Show Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new AnimeSchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ $anime = Anime::factory()->jsonApiResource()->createOne();
+
+ $response = $this->get(route('api.anime.show', ['anime' => $anime] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeResource($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Show Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $this->withoutEvents();
+
+ $schema = new AnimeSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ AnimeResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $anime = Anime::factory()->create();
+
+ $response = $this->get(route('api.anime.show', ['anime' => $anime] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeResource($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Show Endpoint shall support constrained eager loading of themes by group.
+ *
+ * @return void
+ */
+ public function testThemesByGroup(): void
+ {
+ $groupFilter = $this->faker->word();
+ $excludedGroup = $this->faker->word();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_GROUP => $groupFilter,
+ ],
+ IncludeParser::param() => Anime::RELATION_THEMES,
+ ];
+
+ $anime = Anime::factory()
+ ->has(
+ AnimeTheme::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->state(new Sequence(
+ [AnimeTheme::ATTRIBUTE_GROUP => $groupFilter],
+ [AnimeTheme::ATTRIBUTE_GROUP => $excludedGroup],
+ ))
+ )
+ ->createOne();
+
+ $anime->unsetRelations()->load([
+ Anime::RELATION_THEMES => function (HasMany $query) use ($groupFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_GROUP, $groupFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.anime.show', ['anime' => $anime] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeResource($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Show Endpoint shall support constrained eager loading of themes by sequence.
+ *
+ * @return void
+ */
+ public function testThemesBySequence(): void
+ {
+ $sequenceFilter = $this->faker->randomDigitNotNull();
+ $excludedSequence = $sequenceFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_SEQUENCE => $sequenceFilter,
+ ],
+ IncludeParser::param() => Anime::RELATION_THEMES,
+ ];
+
+ $anime = Anime::factory()
+ ->has(
+ AnimeTheme::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->state(new Sequence(
+ [AnimeTheme::ATTRIBUTE_SEQUENCE => $sequenceFilter],
+ [AnimeTheme::ATTRIBUTE_SEQUENCE => $excludedSequence],
+ ))
+ )
+ ->createOne();
+
+ $anime->unsetRelations()->load([
+ Anime::RELATION_THEMES => function (HasMany $query) use ($sequenceFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_SEQUENCE, $sequenceFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.anime.show', ['anime' => $anime] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeResource($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Show Endpoint shall support constrained eager loading of themes by type.
+ *
+ * @return void
+ */
+ public function testThemesByType(): void
+ {
+ $typeFilter = ThemeType::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_TYPE => $typeFilter->description,
+ ],
+ IncludeParser::param() => Anime::RELATION_THEMES,
+ ];
+
+ $anime = Anime::factory()
+ ->has(AnimeTheme::factory()->count($this->faker->randomDigitNotNull()))
+ ->createOne();
+
+ $anime->unsetRelations()->load([
+ Anime::RELATION_THEMES => function (HasMany $query) use ($typeFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_TYPE, $typeFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.anime.show', ['anime' => $anime] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeResource($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Show Endpoint shall support constrained eager loading of entries by nsfw.
+ *
+ * @return void
+ */
+ public function testEntriesByNsfw(): void
+ {
+ $nsfwFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeThemeEntry::ATTRIBUTE_NSFW => $nsfwFilter,
+ ],
+ IncludeParser::param() => Anime::RELATION_ENTRIES,
+ ];
+
+ $anime = Anime::factory()
+ ->has(
+ AnimeTheme::factory()
+ ->has(AnimeThemeEntry::factory()->count($this->faker->numberBetween(1, 3)))
+ ->count($this->faker->numberBetween(1, 3))
+ )
+ ->createOne();
+
+ $anime->unsetRelations()->load([
+ Anime::RELATION_ENTRIES => function (HasMany $query) use ($nsfwFilter) {
+ $query->where(AnimeThemeEntry::ATTRIBUTE_NSFW, $nsfwFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.anime.show', ['anime' => $anime] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeResource($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Show Endpoint shall support constrained eager loading of entries by spoiler.
+ *
+ * @return void
+ */
+ public function testEntriesBySpoiler(): void
+ {
+ $spoilerFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeThemeEntry::ATTRIBUTE_SPOILER => $spoilerFilter,
+ ],
+ IncludeParser::param() => Anime::RELATION_ENTRIES,
+ ];
+
+ $anime = Anime::factory()
+ ->has(
+ AnimeTheme::factory()
+ ->has(AnimeThemeEntry::factory()->count($this->faker->numberBetween(1, 3)))
+ ->count($this->faker->numberBetween(1, 3))
+ )
+ ->createOne();
+
+ $anime->unsetRelations()->load([
+ Anime::RELATION_ENTRIES => function (HasMany $query) use ($spoilerFilter) {
+ $query->where(AnimeThemeEntry::ATTRIBUTE_SPOILER, $spoilerFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.anime.show', ['anime' => $anime] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeResource($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Show Endpoint shall support constrained eager loading of entries by version.
+ *
+ * @return void
+ */
+ public function testEntriesByVersion(): void
+ {
+ $versionFilter = $this->faker->numberBetween(1, 3);
+ $excludedVersion = $versionFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeThemeEntry::ATTRIBUTE_VERSION => $versionFilter,
+ ],
+ IncludeParser::param() => Anime::RELATION_ENTRIES,
+ ];
+
+ $anime = Anime::factory()
+ ->has(
+ AnimeTheme::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->state(new Sequence(
+ [AnimeThemeEntry::ATTRIBUTE_VERSION => $versionFilter],
+ [AnimeThemeEntry::ATTRIBUTE_VERSION => $excludedVersion],
+ ))
+ )
+ )
+ ->createOne();
+
+ $anime->unsetRelations()->load([
+ Anime::RELATION_ENTRIES => function (HasMany $query) use ($versionFilter) {
+ $query->where(AnimeThemeEntry::ATTRIBUTE_VERSION, $versionFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.anime.show', ['anime' => $anime] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeResource($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Show Endpoint shall support constrained eager loading of resources by site.
+ *
+ * @return void
+ */
+ public function testResourcesBySite(): void
+ {
+ $this->withoutEvents();
+
+ $siteFilter = ResourceSite::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ ExternalResource::ATTRIBUTE_SITE => $siteFilter->description,
+ ],
+ IncludeParser::param() => Anime::RELATION_RESOURCES,
+ ];
+
+ $anime = Anime::factory()
+ ->has(ExternalResource::factory()->count($this->faker->randomDigitNotNull()), Anime::RELATION_RESOURCES)
+ ->createOne();
+
+ $anime->unsetRelations()->load([
+ Anime::RELATION_RESOURCES => function (BelongsToMany $query) use ($siteFilter) {
+ $query->where(ExternalResource::ATTRIBUTE_SITE, $siteFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.anime.show', ['anime' => $anime] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeResource($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Show Endpoint shall support constrained eager loading of images by facet.
+ *
+ * @return void
+ */
+ public function testImagesByFacet(): void
+ {
+ $this->withoutEvents();
+
+ $facetFilter = ImageFacet::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Image::ATTRIBUTE_FACET => $facetFilter->description,
+ ],
+ IncludeParser::param() => Anime::RELATION_IMAGES,
+ ];
+
+ $anime = Anime::factory()
+ ->has(Image::factory()->count($this->faker->randomDigitNotNull()))
+ ->createOne();
+
+ $anime->unsetRelations()->load([
+ Anime::RELATION_IMAGES => function (BelongsToMany $query) use ($facetFilter) {
+ $query->where(Image::ATTRIBUTE_FACET, $facetFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.anime.show', ['anime' => $anime] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeResource($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Show Endpoint shall support constrained eager loading of videos by lyrics.
+ *
+ * @return void
+ */
+ public function testVideosByLyrics(): void
+ {
+ $lyricsFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_LYRICS => $lyricsFilter,
+ ],
+ IncludeParser::param() => Anime::RELATION_VIDEOS,
+ ];
+
+ $anime = Anime::factory()->jsonApiResource()->createOne();
+
+ $anime->unsetRelations()->load([
+ Anime::RELATION_VIDEOS => function (BelongsToMany $query) use ($lyricsFilter) {
+ $query->where(Video::ATTRIBUTE_LYRICS, $lyricsFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.anime.show', ['anime' => $anime] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeResource($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Show Endpoint shall support constrained eager loading of videos by nc.
+ *
+ * @return void
+ */
+ public function testVideosByNc(): void
+ {
+ $ncFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_NC => $ncFilter,
+ ],
+ IncludeParser::param() => Anime::RELATION_VIDEOS,
+ ];
+
+ $anime = Anime::factory()->jsonApiResource()->createOne();
+
+ $anime->unsetRelations()->load([
+ Anime::RELATION_VIDEOS => function (BelongsToMany $query) use ($ncFilter) {
+ $query->where(Video::ATTRIBUTE_NC, $ncFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.anime.show', ['anime' => $anime] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeResource($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Show Endpoint shall support constrained eager loading of videos by overlap.
+ *
+ * @return void
+ */
+ public function testVideosByOverlap(): void
+ {
+ $overlapFilter = VideoOverlap::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_OVERLAP => $overlapFilter->description,
+ ],
+ IncludeParser::param() => Anime::RELATION_VIDEOS,
+ ];
+
+ $anime = Anime::factory()->jsonApiResource()->createOne();
+
+ $anime->unsetRelations()->load([
+ Anime::RELATION_VIDEOS => function (BelongsToMany $query) use ($overlapFilter) {
+ $query->where(Video::ATTRIBUTE_OVERLAP, $overlapFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.anime.show', ['anime' => $anime] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeResource($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Show Endpoint shall support constrained eager loading of videos by resolution.
+ *
+ * @return void
+ */
+ public function testVideosByResolution(): void
+ {
+ $resolutionFilter = $this->faker->randomNumber();
+ $excludedResolution = $resolutionFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_RESOLUTION => $resolutionFilter,
+ ],
+ IncludeParser::param() => Anime::RELATION_VIDEOS,
+ ];
+
+ $anime = Anime::factory()
+ ->has(
+ AnimeTheme::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->has(
+ Video::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->state(new Sequence(
+ [Video::ATTRIBUTE_RESOLUTION => $resolutionFilter],
+ [Video::ATTRIBUTE_RESOLUTION => $excludedResolution],
+ ))
+ )
+ )
+ )
+ ->create();
+
+ $anime->unsetRelations()->load([
+ Anime::RELATION_VIDEOS => function (BelongsToMany $query) use ($resolutionFilter) {
+ $query->where(Video::ATTRIBUTE_RESOLUTION, $resolutionFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.anime.show', ['anime' => $anime] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeResource($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Show Endpoint shall support constrained eager loading of videos by source.
+ *
+ * @return void
+ */
+ public function testVideosBySource(): void
+ {
+ $sourceFilter = VideoSource::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_SOURCE => $sourceFilter->description,
+ ],
+ IncludeParser::param() => Anime::RELATION_VIDEOS,
+ ];
+
+ $anime = Anime::factory()->jsonApiResource()->createOne();
+
+ $anime->unsetRelations()->load([
+ Anime::RELATION_VIDEOS => function (BelongsToMany $query) use ($sourceFilter) {
+ $query->where(Video::ATTRIBUTE_SOURCE, $sourceFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.anime.show', ['anime' => $anime] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeResource($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Show Endpoint shall support constrained eager loading of videos by subbed.
+ *
+ * @return void
+ */
+ public function testVideosBySubbed(): void
+ {
+ $subbedFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_SUBBED => $subbedFilter,
+ ],
+ IncludeParser::param() => Anime::RELATION_VIDEOS,
+ ];
+
+ $anime = Anime::factory()->jsonApiResource()->createOne();
+
+ $anime->unsetRelations()->load([
+ Anime::RELATION_VIDEOS => function (BelongsToMany $query) use ($subbedFilter) {
+ $query->where(Video::ATTRIBUTE_SUBBED, $subbedFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.anime.show', ['anime' => $anime] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeResource($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Show Endpoint shall support constrained eager loading of videos by uncen.
+ *
+ * @return void
+ */
+ public function testVideosByUncen(): void
+ {
+ $uncenFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_UNCEN => $uncenFilter,
+ ],
+ IncludeParser::param() => Anime::RELATION_VIDEOS,
+ ];
+
+ $anime = Anime::factory()->jsonApiResource()->createOne();
+
+ $anime->unsetRelations()->load([
+ Anime::RELATION_VIDEOS => function (BelongsToMany $query) use ($uncenFilter) {
+ $query->where(Video::ATTRIBUTE_UNCEN, $uncenFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.anime.show', ['anime' => $anime] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new AnimeResource($anime, new AnimeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/AnimeStoreTest.php b/tests/Feature/Http/Api/Wiki/Anime/AnimeStoreTest.php
new file mode 100644
index 000000000..0ed15ed98
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/AnimeStoreTest.php
@@ -0,0 +1,77 @@
+makeOne();
+
+ $response = $this->post(route('api.anime.store', $anime->toArray()));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Anime Store Endpoint shall require name, season, slug & year fields.
+ *
+ * @return void
+ */
+ public function testRequiredFields(): void
+ {
+ $user = User::factory()->withPermission('create anime')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.anime.store'));
+
+ $response->assertJsonValidationErrors([
+ Anime::ATTRIBUTE_NAME,
+ Anime::ATTRIBUTE_SEASON,
+ Anime::ATTRIBUTE_SLUG,
+ Anime::ATTRIBUTE_YEAR,
+ ]);
+ }
+
+ /**
+ * The Anime Store Endpoint shall create an anime.
+ *
+ * @return void
+ */
+ public function testCreate(): void
+ {
+ $parameters = array_merge(
+ Anime::factory()->raw(),
+ [Anime::ATTRIBUTE_SEASON => AnimeSeason::getRandomInstance()->description],
+ );
+
+ $user = User::factory()->withPermission('create anime')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.anime.store', $parameters));
+
+ $response->assertCreated();
+ static::assertDatabaseCount(Anime::TABLE, 1);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/AnimeUpdateTest.php b/tests/Feature/Http/Api/Wiki/Anime/AnimeUpdateTest.php
new file mode 100644
index 000000000..d60f55917
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/AnimeUpdateTest.php
@@ -0,0 +1,62 @@
+createOne();
+
+ $parameters = array_merge(
+ Anime::factory()->raw(),
+ [Anime::ATTRIBUTE_SEASON => AnimeSeason::getRandomInstance()->description],
+ );
+
+ $response = $this->put(route('api.anime.update', ['anime' => $anime] + $parameters));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Anime Update Endpoint shall update an anime.
+ *
+ * @return void
+ */
+ public function testUpdate(): void
+ {
+ $anime = Anime::factory()->createOne();
+
+ $parameters = array_merge(
+ Anime::factory()->raw(),
+ [Anime::ATTRIBUTE_SEASON => AnimeSeason::getRandomInstance()->description],
+ );
+
+ $user = User::factory()->withPermission('update anime')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->put(route('api.anime.update', ['anime' => $anime] + $parameters));
+
+ $response->assertOk();
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/Synonym/SynonymDestroyTest.php b/tests/Feature/Http/Api/Wiki/Anime/Synonym/SynonymDestroyTest.php
new file mode 100644
index 000000000..574aec148
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/Synonym/SynonymDestroyTest.php
@@ -0,0 +1,53 @@
+for(Anime::factory())->createOne();
+
+ $response = $this->delete(route('api.animesynonym.destroy', ['animesynonym' => $synonym]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Synonym Destroy Endpoint shall delete the synonym.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $synonym = AnimeSynonym::factory()->for(Anime::factory())->createOne();
+
+ $user = User::factory()->withPermission('delete anime synonym')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.animesynonym.destroy', ['animesynonym' => $synonym]));
+
+ $response->assertOk();
+ static::assertSoftDeleted($synonym);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/Synonym/SynonymForceDeleteTest.php b/tests/Feature/Http/Api/Wiki/Anime/Synonym/SynonymForceDeleteTest.php
new file mode 100644
index 000000000..08776c26b
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/Synonym/SynonymForceDeleteTest.php
@@ -0,0 +1,53 @@
+for(Anime::factory())->createOne();
+
+ $response = $this->delete(route('api.animesynonym.forceDelete', ['animesynonym' => $synonym]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Synonym Force Destroy Endpoint shall force delete the synonym.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $synonym = AnimeSynonym::factory()->for(Anime::factory())->createOne();
+
+ $user = User::factory()->withPermission('force delete anime synonym')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.animesynonym.forceDelete', ['animesynonym' => $synonym]));
+
+ $response->assertOk();
+ static::assertModelMissing($synonym);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/Synonym/SynonymIndexTest.php b/tests/Feature/Http/Api/Wiki/Anime/Synonym/SynonymIndexTest.php
new file mode 100644
index 000000000..ca8a1ea5a
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/Synonym/SynonymIndexTest.php
@@ -0,0 +1,585 @@
+for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $synonyms = AnimeSynonym::all();
+
+ $response = $this->get(route('api.animesynonym.index'));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SynonymCollection($synonyms, new SynonymReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Synonym Index Endpoint shall be paginated.
+ *
+ * @return void
+ */
+ public function testPaginated(): void
+ {
+ AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $response = $this->get(route('api.animesynonym.index'));
+
+ $response->assertJsonStructure([
+ SynonymCollection::$wrap,
+ 'links',
+ 'meta',
+ ]);
+ }
+
+ /**
+ * The Synonym Index Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new SynonymSchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $synonyms = AnimeSynonym::with($includedPaths->all())->get();
+
+ $response = $this->get(route('api.animesynonym.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SynonymCollection($synonyms, new SynonymReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Synonym Index Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $schema = new SynonymSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ SynonymResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $synonyms = AnimeSynonym::all();
+
+ $response = $this->get(route('api.animesynonym.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SynonymCollection($synonyms, new SynonymReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Synonym Index Endpoint shall support sorting resources.
+ *
+ * @return void
+ */
+ public function testSorts(): void
+ {
+ $schema = new SynonymSchema();
+
+ $sort = collect($schema->fields())
+ ->filter(fn (Field $field) => $field instanceof SortableField)
+ ->map(fn (SortableField $field) => $field->getSort())
+ ->random();
+
+ $parameters = [
+ SortParser::param() => $sort->format(Direction::getRandomInstance()),
+ ];
+
+ $query = new SynonymReadQuery($parameters);
+
+ AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $response = $this->get(route('api.animesynonym.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ $query->collection($query->index())
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Synonym Index Endpoint shall support filtering by created_at.
+ *
+ * @return void
+ */
+ public function testCreatedAtFilter(): void
+ {
+ $createdFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_CREATED_AT => $createdFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($createdFilter, function () {
+ AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+ });
+
+ $synonym = AnimeSynonym::query()->where(BaseModel::ATTRIBUTE_CREATED_AT, $createdFilter)->get();
+
+ $response = $this->get(route('api.animesynonym.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SynonymCollection($synonym, new SynonymReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Synonym Index Endpoint shall support filtering by updated_at.
+ *
+ * @return void
+ */
+ public function testUpdatedAtFilter(): void
+ {
+ $updatedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_UPDATED_AT => $updatedFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($updatedFilter, function () {
+ AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+ });
+
+ $synonym = AnimeSynonym::query()->where(BaseModel::ATTRIBUTE_UPDATED_AT, $updatedFilter)->get();
+
+ $response = $this->get(route('api.animesynonym.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SynonymCollection($synonym, new SynonymReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Synonym Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithoutTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITHOUT,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $deleteSynonym = AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $deleteSynonym->each(function (AnimeSynonym $synonym) {
+ $synonym->delete();
+ });
+
+ $synonym = AnimeSynonym::withoutTrashed()->get();
+
+ $response = $this->get(route('api.animesynonym.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SynonymCollection($synonym, new SynonymReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Synonym Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $deleteSynonym = AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $deleteSynonym->each(function (AnimeSynonym $synonym) {
+ $synonym->delete();
+ });
+
+ $synonym = AnimeSynonym::withTrashed()->get();
+
+ $response = $this->get(route('api.animesynonym.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SynonymCollection($synonym, new SynonymReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Synonym Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testOnlyTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::ONLY,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $deleteSynonym = AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $deleteSynonym->each(function (AnimeSynonym $synonym) {
+ $synonym->delete();
+ });
+
+ $synonym = AnimeSynonym::onlyTrashed()->get();
+
+ $response = $this->get(route('api.animesynonym.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SynonymCollection($synonym, new SynonymReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Synonym Index Endpoint shall support filtering by deleted_at.
+ *
+ * @return void
+ */
+ public function testDeletedAtFilter(): void
+ {
+ $deletedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_DELETED_AT => $deletedFilter,
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($deletedFilter, function () {
+ AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+ });
+
+ $synonym = AnimeSynonym::withTrashed()->where(BaseModel::ATTRIBUTE_DELETED_AT, $deletedFilter)->get();
+
+ $response = $this->get(route('api.animesynonym.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SynonymCollection($synonym, new SynonymReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Synonym Index Endpoint shall support constrained eager loading of anime by season.
+ *
+ * @return void
+ */
+ public function testAnimeBySeason(): void
+ {
+ $seasonFilter = AnimeSeason::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_SEASON => $seasonFilter->description,
+ ],
+ IncludeParser::param() => AnimeSynonym::RELATION_ANIME,
+ ];
+
+ AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $synonyms = AnimeSynonym::with([
+ AnimeSynonym::RELATION_ANIME => function (BelongsTo $query) use ($seasonFilter) {
+ $query->where(Anime::ATTRIBUTE_SEASON, $seasonFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.animesynonym.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SynonymCollection($synonyms, new SynonymReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Synonym Index Endpoint shall support constrained eager loading of anime by year.
+ *
+ * @return void
+ */
+ public function testAnimeByYear(): void
+ {
+ $yearFilter = intval($this->faker->year());
+ $excludedYear = $yearFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_YEAR => $yearFilter,
+ ],
+ IncludeParser::param() => AnimeSynonym::RELATION_ANIME,
+ ];
+
+ AnimeSynonym::factory()
+ ->for(
+ Anime::factory()
+ ->state([
+ Anime::ATTRIBUTE_YEAR => $this->faker->boolean() ? $yearFilter : $excludedYear,
+ ])
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $synonyms = AnimeSynonym::with([
+ AnimeSynonym::RELATION_ANIME => function (BelongsTo $query) use ($yearFilter) {
+ $query->where(Anime::ATTRIBUTE_YEAR, $yearFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.animesynonym.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SynonymCollection($synonyms, new SynonymReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/Synonym/SynonymRestoreTest.php b/tests/Feature/Http/Api/Wiki/Anime/Synonym/SynonymRestoreTest.php
new file mode 100644
index 000000000..b1a36a2b4
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/Synonym/SynonymRestoreTest.php
@@ -0,0 +1,57 @@
+for(Anime::factory())->createOne();
+
+ $synonym->delete();
+
+ $response = $this->patch(route('api.animesynonym.restore', ['animesynonym' => $synonym]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Synonym Restore Endpoint shall restore the synonym.
+ *
+ * @return void
+ */
+ public function testRestored(): void
+ {
+ $synonym = AnimeSynonym::factory()->for(Anime::factory())->createOne();
+
+ $synonym->delete();
+
+ $user = User::factory()->withPermission('restore anime synonym')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->patch(route('api.animesynonym.restore', ['animesynonym' => $synonym]));
+
+ $response->assertOk();
+ static::assertNotSoftDeleted($synonym);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/Synonym/SynonymShowTest.php b/tests/Feature/Http/Api/Wiki/Anime/Synonym/SynonymShowTest.php
new file mode 100644
index 000000000..660cbb38d
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/Synonym/SynonymShowTest.php
@@ -0,0 +1,238 @@
+for(Anime::factory())->createOne();
+
+ $synonym->unsetRelations();
+
+ $response = $this->get(route('api.animesynonym.show', ['animesynonym' => $synonym]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SynonymResource($synonym, new SynonymReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Synonym Show Endpoint shall return a Synonym Resource for soft deleted synonyms.
+ *
+ * @return void
+ */
+ public function testSoftDelete(): void
+ {
+ $synonym = AnimeSynonym::factory()->for(Anime::factory())->createOne();
+
+ $synonym->delete();
+
+ $synonym->unsetRelations();
+
+ $response = $this->get(route('api.animesynonym.show', ['animesynonym' => $synonym]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SynonymResource($synonym, new SynonymReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Synonym Show Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new SynonymSchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ $synonym = AnimeSynonym::factory()->for(Anime::factory())->createOne();
+
+ $response = $this->get(route('api.animesynonym.show', ['animesynonym' => $synonym] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SynonymResource($synonym, new SynonymReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Synonym Index Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $schema = new SynonymSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ SynonymResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $synonym = AnimeSynonym::factory()->for(Anime::factory())->createOne();
+
+ $synonym->unsetRelations();
+
+ $response = $this->get(route('api.animesynonym.show', ['animesynonym' => $synonym] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SynonymResource($synonym, new SynonymReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Synonym Show Endpoint shall support constrained eager loading of anime by season.
+ *
+ * @return void
+ */
+ public function testAnimeBySeason(): void
+ {
+ $seasonFilter = AnimeSeason::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_SEASON => $seasonFilter->description,
+ ],
+ IncludeParser::param() => AnimeSynonym::RELATION_ANIME,
+ ];
+
+ $synonym = AnimeSynonym::factory()->for(Anime::factory())->createOne();
+
+ $synonym->unsetRelations()->load([
+ AnimeSynonym::RELATION_ANIME => function (BelongsTo $query) use ($seasonFilter) {
+ $query->where(Anime::ATTRIBUTE_SEASON, $seasonFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.animesynonym.show', ['animesynonym' => $synonym] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SynonymResource($synonym, new SynonymReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Synonym Show Endpoint shall support constrained eager loading of anime by year.
+ *
+ * @return void
+ */
+ public function testAnimeByYear(): void
+ {
+ $yearFilter = intval($this->faker->year());
+ $excludedYear = $yearFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_YEAR => $yearFilter,
+ ],
+ IncludeParser::param() => AnimeSynonym::RELATION_ANIME,
+ ];
+
+ $synonym = AnimeSynonym::factory()
+ ->for(
+ Anime::factory()
+ ->state([
+ Anime::ATTRIBUTE_YEAR => $this->faker->boolean() ? $yearFilter : $excludedYear,
+ ])
+ )
+ ->createOne();
+
+ $synonym->unsetRelations()->load([
+ AnimeSynonym::RELATION_ANIME => function (BelongsTo $query) use ($yearFilter) {
+ $query->where(Anime::ATTRIBUTE_YEAR, $yearFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.animesynonym.show', ['animesynonym' => $synonym] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SynonymResource($synonym, new SynonymReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/Synonym/SynonymStoreTest.php b/tests/Feature/Http/Api/Wiki/Anime/Synonym/SynonymStoreTest.php
new file mode 100644
index 000000000..c0b539a84
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/Synonym/SynonymStoreTest.php
@@ -0,0 +1,76 @@
+for(Anime::factory())->makeOne();
+
+ $response = $this->post(route('api.animesynonym.store', $synonym->toArray()));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Synonym Store Endpoint shall require the text field.
+ *
+ * @return void
+ */
+ public function testRequiredFields(): void
+ {
+ $user = User::factory()->withPermission('create anime synonym')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.animesynonym.store'));
+
+ $response->assertJsonValidationErrors([
+ AnimeSynonym::ATTRIBUTE_TEXT,
+ ]);
+ }
+
+ /**
+ * The Synonym Store Endpoint shall create a synonym.
+ *
+ * @return void
+ */
+ public function testCreate(): void
+ {
+ $anime = Anime::factory()->createOne();
+
+ $parameters = array_merge(
+ AnimeSynonym::factory()->raw(),
+ [AnimeSynonym::ATTRIBUTE_ANIME => $anime->getKey()],
+ );
+
+ $user = User::factory()->withPermission('create anime synonym')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.animesynonym.store', $parameters));
+
+ $response->assertCreated();
+ static::assertDatabaseCount(AnimeSynonym::TABLE, 1);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/Synonym/SynonymUpdateTest.php b/tests/Feature/Http/Api/Wiki/Anime/Synonym/SynonymUpdateTest.php
new file mode 100644
index 000000000..2f2073c39
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/Synonym/SynonymUpdateTest.php
@@ -0,0 +1,56 @@
+for(Anime::factory())->createOne();
+
+ $parameters = AnimeSynonym::factory()->raw();
+
+ $response = $this->put(route('api.animesynonym.update', ['animesynonym' => $synonym] + $parameters));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Synonym Update Endpoint shall update a synonym.
+ *
+ * @return void
+ */
+ public function testUpdate(): void
+ {
+ $synonym = AnimeSynonym::factory()->for(Anime::factory())->createOne();
+
+ $parameters = AnimeSynonym::factory()->raw();
+
+ $user = User::factory()->withPermission('update anime synonym')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->put(route('api.animesynonym.update', ['animesynonym' => $synonym] + $parameters));
+
+ $response->assertOk();
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/Theme/Entry/EntryDestroyTest.php b/tests/Feature/Http/Api/Wiki/Anime/Theme/Entry/EntryDestroyTest.php
new file mode 100644
index 000000000..53e1d4995
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/Theme/Entry/EntryDestroyTest.php
@@ -0,0 +1,61 @@
+for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ $response = $this->delete(route('api.animethemeentry.destroy', ['animethemeentry' => $entry]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Entry Destroy Endpoint shall delete the entry.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ Event::fakeExcept(ThemeCreating::class);
+
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ $user = User::factory()->withPermission('delete anime theme entry')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.animethemeentry.destroy', ['animethemeentry' => $entry]));
+
+ $response->assertOk();
+ static::assertSoftDeleted($entry);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/Theme/Entry/EntryForceDeleteTest.php b/tests/Feature/Http/Api/Wiki/Anime/Theme/Entry/EntryForceDeleteTest.php
new file mode 100644
index 000000000..18555e11c
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/Theme/Entry/EntryForceDeleteTest.php
@@ -0,0 +1,61 @@
+for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ $response = $this->delete(route('api.animethemeentry.forceDelete', ['animethemeentry' => $entry]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Entry Force Destroy Endpoint shall force delete the entry.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ Event::fakeExcept(ThemeCreating::class);
+
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ $user = User::factory()->withPermission('force delete anime theme entry')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.animethemeentry.forceDelete', ['animethemeentry' => $entry]));
+
+ $response->assertOk();
+ static::assertModelMissing($entry);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/Theme/Entry/EntryIndexTest.php b/tests/Feature/Http/Api/Wiki/Anime/Theme/Entry/EntryIndexTest.php
new file mode 100644
index 000000000..7d55e051d
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/Theme/Entry/EntryIndexTest.php
@@ -0,0 +1,847 @@
+for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $response = $this->get(route('api.animethemeentry.index'));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryCollection($entries, new EntryReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Index Endpoint shall be paginated.
+ *
+ * @return void
+ */
+ public function testPaginated(): void
+ {
+ AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $response = $this->get(route('api.animethemeentry.index'));
+
+ $response->assertJsonStructure([
+ EntryCollection::$wrap,
+ 'links',
+ 'meta',
+ ]);
+ }
+
+ /**
+ * The Entry Index Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new EntrySchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->has(Video::factory()->count($this->faker->randomDigitNotNull()))
+ ->create();
+
+ $entries = AnimeThemeEntry::with($includedPaths->all())->get();
+
+ $response = $this->get(route('api.animethemeentry.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryCollection($entries, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Index Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $schema = new EntrySchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ EntryResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $entries = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $response = $this->get(route('api.animethemeentry.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryCollection($entries, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Index Endpoint shall support sorting resources.
+ *
+ * @return void
+ */
+ public function testSorts(): void
+ {
+ $schema = new EntrySchema();
+
+ $sort = collect($schema->fields())
+ ->filter(fn (Field $field) => $field instanceof SortableField)
+ ->map(fn (SortableField $field) => $field->getSort())
+ ->random();
+
+ $parameters = [
+ SortParser::param() => $sort->format(Direction::getRandomInstance()),
+ ];
+
+ $query = new EntryReadQuery($parameters);
+
+ AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $response = $this->get(route('api.animethemeentry.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ $query->collection($query->index())
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Index Endpoint shall support filtering by created_at.
+ *
+ * @return void
+ */
+ public function testCreatedAtFilter(): void
+ {
+ $createdFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_CREATED_AT => $createdFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($createdFilter, function () {
+ AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+ });
+
+ $entry = AnimeThemeEntry::query()->where(BaseModel::ATTRIBUTE_CREATED_AT, $createdFilter)->get();
+
+ $response = $this->get(route('api.animethemeentry.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryCollection($entry, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Index Endpoint shall support filtering by updated_at.
+ *
+ * @return void
+ */
+ public function testUpdatedAtFilter(): void
+ {
+ $updatedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_UPDATED_AT => $updatedFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($updatedFilter, function () {
+ AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+ });
+
+ $entry = AnimeThemeEntry::query()->where(BaseModel::ATTRIBUTE_UPDATED_AT, $updatedFilter)->get();
+
+ $response = $this->get(route('api.animethemeentry.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryCollection($entry, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithoutTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITHOUT,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $deleteEntry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $deleteEntry->each(function (AnimeThemeEntry $entry) {
+ $entry->delete();
+ });
+
+ $entry = AnimeThemeEntry::withoutTrashed()->get();
+
+ $response = $this->get(route('api.animethemeentry.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryCollection($entry, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $deleteEntry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $deleteEntry->each(function (AnimeThemeEntry $entry) {
+ $entry->delete();
+ });
+
+ $entry = AnimeThemeEntry::withTrashed()->get();
+
+ $response = $this->get(route('api.animethemeentry.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryCollection($entry, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testOnlyTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::ONLY,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $deleteEntry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $deleteEntry->each(function (AnimeThemeEntry $entry) {
+ $entry->delete();
+ });
+
+ $entry = AnimeThemeEntry::onlyTrashed()->get();
+
+ $response = $this->get(route('api.animethemeentry.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryCollection($entry, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Index Endpoint shall support filtering by deleted_at.
+ *
+ * @return void
+ */
+ public function testDeletedAtFilter(): void
+ {
+ $deletedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_DELETED_AT => $deletedFilter,
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($deletedFilter, function () {
+ $entries = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $entries->each(function (AnimeThemeEntry $entry) {
+ $entry->delete();
+ });
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ $entries = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $entries->each(function (AnimeThemeEntry $entry) {
+ $entry->delete();
+ });
+ });
+
+ $entry = AnimeThemeEntry::withTrashed()->where(BaseModel::ATTRIBUTE_DELETED_AT, $deletedFilter)->get();
+
+ $response = $this->get(route('api.animethemeentry.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryCollection($entry, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Index Endpoint shall support filtering by nsfw.
+ *
+ * @return void
+ */
+ public function testEntriesByNsfw(): void
+ {
+ $nsfwFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeThemeEntry::ATTRIBUTE_NSFW => $nsfwFilter,
+ ],
+ ];
+
+ AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $entries = AnimeThemeEntry::query()->where(AnimeThemeEntry::ATTRIBUTE_NSFW, $nsfwFilter)->get();
+
+ $response = $this->get(route('api.animethemeentry.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryCollection($entries, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Index Endpoint shall support filtering by spoiler.
+ *
+ * @return void
+ */
+ public function testEntriesBySpoiler(): void
+ {
+ $spoilerFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeThemeEntry::ATTRIBUTE_SPOILER => $spoilerFilter,
+ ],
+ ];
+
+ AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $entries = AnimeThemeEntry::query()->where(AnimeThemeEntry::ATTRIBUTE_SPOILER, $spoilerFilter)->get();
+
+ $response = $this->get(route('api.animethemeentry.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryCollection($entries, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Index Endpoint shall support filtering by version.
+ *
+ * @return void
+ */
+ public function testEntriesByVersion(): void
+ {
+ $versionFilter = $this->faker->randomDigitNotNull();
+ $excludedVersion = $versionFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeThemeEntry::ATTRIBUTE_VERSION => $versionFilter,
+ ],
+ ];
+
+ AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->state(new Sequence(
+ [AnimeThemeEntry::ATTRIBUTE_VERSION => $versionFilter],
+ [AnimeThemeEntry::ATTRIBUTE_VERSION => $excludedVersion],
+ ))
+ ->create();
+
+ $entries = AnimeThemeEntry::query()->where(AnimeThemeEntry::ATTRIBUTE_VERSION, $versionFilter)->get();
+
+ $response = $this->get(route('api.animethemeentry.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryCollection($entries, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Index Endpoint shall support constrained eager loading of anime by season.
+ *
+ * @return void
+ */
+ public function testAnimeBySeason(): void
+ {
+ $seasonFilter = AnimeSeason::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_SEASON => $seasonFilter->description,
+ ],
+ IncludeParser::param() => AnimeThemeEntry::RELATION_ANIME,
+ ];
+
+ AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $entries = AnimeThemeEntry::with([
+ AnimeThemeEntry::RELATION_ANIME => function (BelongsTo $query) use ($seasonFilter) {
+ $query->where(Anime::ATTRIBUTE_SEASON, $seasonFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.animethemeentry.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryCollection($entries, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Index Endpoint shall support constrained eager loading of anime by year.
+ *
+ * @return void
+ */
+ public function testAnimeByYear(): void
+ {
+ $yearFilter = intval($this->faker->year());
+ $excludedYear = $yearFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_YEAR => $yearFilter,
+ ],
+ IncludeParser::param() => AnimeThemeEntry::RELATION_ANIME,
+ ];
+
+ AnimeThemeEntry::factory()
+ ->for(
+ AnimeTheme::factory()->for(
+ Anime::factory()
+ ->state([
+ Anime::ATTRIBUTE_YEAR => $this->faker->boolean() ? $yearFilter : $excludedYear,
+ ])
+ )
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $entries = AnimeThemeEntry::with([
+ AnimeThemeEntry::RELATION_ANIME => function (BelongsTo $query) use ($yearFilter) {
+ $query->where(Anime::ATTRIBUTE_YEAR, $yearFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.animethemeentry.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryCollection($entries, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Index Endpoint shall support constrained eager loading of themes by group.
+ *
+ * @return void
+ */
+ public function testThemesByGroup(): void
+ {
+ $groupFilter = $this->faker->word();
+ $excludedGroup = $this->faker->word();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_GROUP => $groupFilter,
+ ],
+ IncludeParser::param() => AnimeThemeEntry::RELATION_THEME,
+ ];
+
+ AnimeThemeEntry::factory()
+ ->for(
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->state([
+ AnimeTheme::ATTRIBUTE_GROUP => $this->faker->boolean() ? $groupFilter : $excludedGroup,
+ ])
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $entries = AnimeThemeEntry::with([
+ AnimeThemeEntry::RELATION_THEME => function (BelongsTo $query) use ($groupFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_GROUP, $groupFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.animethemeentry.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryCollection($entries, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Index Endpoint shall support constrained eager loading of themes by sequence.
+ *
+ * @return void
+ */
+ public function testThemesBySequence(): void
+ {
+ $sequenceFilter = $this->faker->randomDigitNotNull();
+ $excludedSequence = $sequenceFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_SEQUENCE => $sequenceFilter,
+ ],
+ IncludeParser::param() => AnimeThemeEntry::RELATION_THEME,
+ ];
+
+ AnimeThemeEntry::factory()
+ ->for(
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->state([
+ AnimeTheme::ATTRIBUTE_SEQUENCE => $this->faker->boolean() ? $sequenceFilter : $excludedSequence,
+ ])
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $entries = AnimeThemeEntry::with([
+ AnimeThemeEntry::RELATION_THEME => function (BelongsTo $query) use ($sequenceFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_SEQUENCE, $sequenceFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.animethemeentry.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryCollection($entries, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Index Endpoint shall support constrained eager loading of themes by type.
+ *
+ * @return void
+ */
+ public function testThemesByType(): void
+ {
+ $typeFilter = ThemeType::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_TYPE => $typeFilter->description,
+ ],
+ IncludeParser::param() => AnimeThemeEntry::RELATION_THEME,
+ ];
+
+ AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $entries = AnimeThemeEntry::with([
+ AnimeThemeEntry::RELATION_THEME => function (BelongsTo $query) use ($typeFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_TYPE, $typeFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.animethemeentry.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryCollection($entries, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/Theme/Entry/EntryRestoreTest.php b/tests/Feature/Http/Api/Wiki/Anime/Theme/Entry/EntryRestoreTest.php
new file mode 100644
index 000000000..3fef4563c
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/Theme/Entry/EntryRestoreTest.php
@@ -0,0 +1,65 @@
+for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ $entry->delete();
+
+ $response = $this->patch(route('api.animethemeentry.restore', ['animethemeentry' => $entry]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Entry Restore Endpoint shall restore the entry.
+ *
+ * @return void
+ */
+ public function testRestored(): void
+ {
+ Event::fakeExcept(ThemeCreating::class);
+
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ $entry->delete();
+
+ $user = User::factory()->withPermission('restore anime theme entry')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->patch(route('api.animethemeentry.restore', ['animethemeentry' => $entry]));
+
+ $response->assertOk();
+ static::assertNotSoftDeleted($entry);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/Theme/Entry/EntryShowTest.php b/tests/Feature/Http/Api/Wiki/Anime/Theme/Entry/EntryShowTest.php
new file mode 100644
index 000000000..25c288811
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/Theme/Entry/EntryShowTest.php
@@ -0,0 +1,382 @@
+for(AnimeTheme::factory()->for(Anime::factory()))
+ ->create();
+
+ $response = $this->get(route('api.animethemeentry.show', ['animethemeentry' => $entry]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryResource($entry, new EntryReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Show Endpoint shall return an Entry Resource for soft deleted images.
+ *
+ * @return void
+ */
+ public function testSoftDelete(): void
+ {
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ $entry->delete();
+
+ $entry->unsetRelations();
+
+ $response = $this->get(route('api.animethemeentry.show', ['animethemeentry' => $entry]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryResource($entry, new EntryReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Show Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new EntrySchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->has(Video::factory()->count($this->faker->randomDigitNotNull()))
+ ->createOne();
+
+ $response = $this->get(route('api.animethemeentry.show', ['animethemeentry' => $entry] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryResource($entry, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Show Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $schema = new EntrySchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ EntryResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->create();
+
+ $response = $this->get(route('api.animethemeentry.show', ['animethemeentry' => $entry] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryResource($entry, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Show Endpoint shall support constrained eager loading of anime by season.
+ *
+ * @return void
+ */
+ public function testAnimeBySeason(): void
+ {
+ $seasonFilter = AnimeSeason::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_SEASON => $seasonFilter->description,
+ ],
+ IncludeParser::param() => AnimeThemeEntry::RELATION_ANIME,
+ ];
+
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ $entry->unsetRelations()->load([
+ AnimeThemeEntry::RELATION_ANIME => function (BelongsTo $query) use ($seasonFilter) {
+ $query->where(Anime::ATTRIBUTE_SEASON, $seasonFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.animethemeentry.show', ['animethemeentry' => $entry] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryResource($entry, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Show Endpoint shall support constrained eager loading of anime by year.
+ *
+ * @return void
+ */
+ public function testAnimeByYear(): void
+ {
+ $yearFilter = intval($this->faker->year());
+ $excludedYear = $yearFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_YEAR => $yearFilter,
+ ],
+ IncludeParser::param() => AnimeThemeEntry::RELATION_ANIME,
+ ];
+
+ $entry = AnimeThemeEntry::factory()
+ ->for(
+ AnimeTheme::factory()->for(
+ Anime::factory()
+ ->state([
+ Anime::ATTRIBUTE_YEAR => $this->faker->boolean() ? $yearFilter : $excludedYear,
+ ])
+ )
+ )
+ ->createOne();
+
+ $entry->unsetRelations()->load([
+ AnimeThemeEntry::RELATION_ANIME => function (BelongsTo $query) use ($yearFilter) {
+ $query->where(Anime::ATTRIBUTE_YEAR, $yearFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.animethemeentry.show', ['animethemeentry' => $entry] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryResource($entry, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Show Endpoint shall support constrained eager loading of themes by group.
+ *
+ * @return void
+ */
+ public function testThemesByGroup(): void
+ {
+ $groupFilter = $this->faker->word();
+ $excludedGroup = $this->faker->word();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_GROUP => $groupFilter,
+ ],
+ IncludeParser::param() => AnimeThemeEntry::RELATION_THEME,
+ ];
+
+ $entry = AnimeThemeEntry::factory()
+ ->for(
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->state([
+ AnimeTheme::ATTRIBUTE_GROUP => $this->faker->boolean() ? $groupFilter : $excludedGroup,
+ ])
+ )
+ ->createOne();
+
+ $entry->unsetRelations()->load([
+ AnimeThemeEntry::RELATION_THEME => function (BelongsTo $query) use ($groupFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_GROUP, $groupFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.animethemeentry.show', ['animethemeentry' => $entry] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryResource($entry, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Show Endpoint shall support constrained eager loading of themes by sequence.
+ *
+ * @return void
+ */
+ public function testThemesBySequence(): void
+ {
+ $sequenceFilter = $this->faker->randomDigitNotNull();
+ $excludedSequence = $sequenceFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_SEQUENCE => $sequenceFilter,
+ ],
+ IncludeParser::param() => AnimeThemeEntry::RELATION_THEME,
+ ];
+
+ $entry = AnimeThemeEntry::factory()
+ ->for(
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->state([
+ AnimeTheme::ATTRIBUTE_SEQUENCE => $this->faker->boolean() ? $sequenceFilter : $excludedSequence,
+ ])
+ )
+ ->createOne();
+
+ $entry->unsetRelations()->load([
+ AnimeThemeEntry::RELATION_THEME => function (BelongsTo $query) use ($sequenceFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_SEQUENCE, $sequenceFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.animethemeentry.show', ['animethemeentry' => $entry] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryResource($entry, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Entry Show Endpoint shall support constrained eager loading of themes by type.
+ *
+ * @return void
+ */
+ public function testThemesByType(): void
+ {
+ $typeFilter = ThemeType::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_TYPE => $typeFilter->description,
+ ],
+ IncludeParser::param() => AnimeThemeEntry::RELATION_THEME,
+ ];
+
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ $entry->unsetRelations()->load([
+ AnimeThemeEntry::RELATION_THEME => function (BelongsTo $query) use ($typeFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_TYPE, $typeFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.animethemeentry.show', ['animethemeentry' => $entry] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new EntryResource($entry, new EntryReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/Theme/Entry/EntryStoreTest.php b/tests/Feature/Http/Api/Wiki/Anime/Theme/Entry/EntryStoreTest.php
new file mode 100644
index 000000000..cfcbece28
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/Theme/Entry/EntryStoreTest.php
@@ -0,0 +1,84 @@
+for(AnimeTheme::factory()->for(Anime::factory()))
+ ->makeOne();
+
+ $response = $this->post(route('api.animethemeentry.store', $entry->toArray()));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Entry Store Endpoint shall require the theme_id field.
+ *
+ * @return void
+ */
+ public function testRequiredFields(): void
+ {
+ $this->withoutEvents();
+
+ $user = User::factory()->withPermission('create anime theme entry')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.animethemeentry.store'));
+
+ $response->assertJsonValidationErrors([
+ AnimeThemeEntry::ATTRIBUTE_THEME,
+ ]);
+ }
+
+ /**
+ * The Entry Store Endpoint shall create an entry.
+ *
+ * @return void
+ */
+ public function testCreate(): void
+ {
+ Event::fakeExcept(ThemeCreating::class);
+
+ $theme = AnimeTheme::factory()->for(Anime::factory())->createOne();
+
+ $parameters = array_merge(
+ AnimeThemeEntry::factory()->raw(),
+ [AnimeThemeEntry::ATTRIBUTE_THEME => $theme->getKey()],
+ );
+
+ $user = User::factory()->withPermission('create anime theme entry')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.animethemeentry.store', $parameters));
+
+ $response->assertCreated();
+ static::assertDatabaseCount(AnimeThemeEntry::TABLE, 1);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/Theme/Entry/EntryUpdateTest.php b/tests/Feature/Http/Api/Wiki/Anime/Theme/Entry/EntryUpdateTest.php
new file mode 100644
index 000000000..0f8e7e6ba
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/Theme/Entry/EntryUpdateTest.php
@@ -0,0 +1,64 @@
+for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ $parameters = AnimeThemeEntry::factory()->raw();
+
+ $response = $this->put(route('api.animethemeentry.update', ['animethemeentry' => $entry] + $parameters));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Entry Update Endpoint shall update an entry.
+ *
+ * @return void
+ */
+ public function testUpdate(): void
+ {
+ Event::fakeExcept(ThemeCreating::class);
+
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ $parameters = AnimeThemeEntry::factory()->raw();
+
+ $user = User::factory()->withPermission('update anime theme entry')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->put(route('api.animethemeentry.update', ['animethemeentry' => $entry] + $parameters));
+
+ $response->assertOk();
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/Theme/ThemeDestroyTest.php b/tests/Feature/Http/Api/Wiki/Anime/Theme/ThemeDestroyTest.php
new file mode 100644
index 000000000..bf4d01bcd
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/Theme/ThemeDestroyTest.php
@@ -0,0 +1,56 @@
+for(Anime::factory())->createOne();
+
+ $response = $this->delete(route('api.animetheme.destroy', ['animetheme' => $theme]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Theme Destroy Endpoint shall delete the theme.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ Event::fakeExcept(ThemeCreating::class);
+
+ $theme = AnimeTheme::factory()->for(Anime::factory())->createOne();
+
+ $user = User::factory()->withPermission('delete anime theme')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.animetheme.destroy', ['animetheme' => $theme]));
+
+ $response->assertOk();
+ static::assertSoftDeleted($theme);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/Theme/ThemeForceDeleteTest.php b/tests/Feature/Http/Api/Wiki/Anime/Theme/ThemeForceDeleteTest.php
new file mode 100644
index 000000000..c2c4ede1b
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/Theme/ThemeForceDeleteTest.php
@@ -0,0 +1,56 @@
+for(Anime::factory())->createOne();
+
+ $response = $this->delete(route('api.animetheme.forceDelete', ['animetheme' => $theme]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Theme Force Destroy Endpoint shall force delete the theme.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ Event::fakeExcept(ThemeCreating::class);
+
+ $theme = AnimeTheme::factory()->for(Anime::factory())->createOne();
+
+ $user = User::factory()->withPermission('force delete anime theme')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.animetheme.forceDelete', ['animetheme' => $theme]));
+
+ $response->assertOk();
+ static::assertModelMissing($theme);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/Theme/ThemeIndexTest.php b/tests/Feature/Http/Api/Wiki/Anime/Theme/ThemeIndexTest.php
new file mode 100644
index 000000000..f1c153b83
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/Theme/ThemeIndexTest.php
@@ -0,0 +1,1238 @@
+for(Anime::factory())
+ ->for(Song::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $themes = AnimeTheme::all();
+
+ $response = $this->get(route('api.animetheme.index'));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($themes, new ThemeReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall be paginated.
+ *
+ * @return void
+ */
+ public function testPaginated(): void
+ {
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $response = $this->get(route('api.animetheme.index'));
+
+ $response->assertJsonStructure([
+ ThemeCollection::$wrap,
+ 'links',
+ 'meta',
+ ]);
+ }
+
+ /**
+ * The Theme Index Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new ThemeSchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->for(Song::factory())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(Video::factory()->count($this->faker->randomDigitNotNull()))
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $themes = AnimeTheme::with($includedPaths->all())->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($themes, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $schema = new ThemeSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ ThemeResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $themes = AnimeTheme::all();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($themes, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall support sorting resources.
+ *
+ * @return void
+ */
+ public function testSorts(): void
+ {
+ $schema = new ThemeSchema();
+
+ $sort = collect($schema->fields())
+ ->filter(fn (Field $field) => $field instanceof SortableField)
+ ->map(fn (SortableField $field) => $field->getSort())
+ ->random();
+
+ $parameters = [
+ SortParser::param() => $sort->format(Direction::getRandomInstance()),
+ ];
+
+ $query = new ThemeReadQuery($parameters);
+
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ $query->collection($query->index())
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall support filtering by created_at.
+ *
+ * @return void
+ */
+ public function testCreatedAtFilter(): void
+ {
+ $createdFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_CREATED_AT => $createdFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($createdFilter, function () {
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+ });
+
+ $theme = AnimeTheme::query()->where(BaseModel::ATTRIBUTE_CREATED_AT, $createdFilter)->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($theme, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall support filtering by updated_at.
+ *
+ * @return void
+ */
+ public function testUpdatedAtFilter(): void
+ {
+ $updatedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_UPDATED_AT => $updatedFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($updatedFilter, function () {
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+ });
+
+ $theme = AnimeTheme::query()->where(BaseModel::ATTRIBUTE_UPDATED_AT, $updatedFilter)->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($theme, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithoutTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITHOUT,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $deleteTheme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $deleteTheme->each(function (AnimeTheme $theme) {
+ $theme->delete();
+ });
+
+ $theme = AnimeTheme::withoutTrashed()->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($theme, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $deleteTheme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $deleteTheme->each(function (AnimeTheme $theme) {
+ $theme->delete();
+ });
+
+ $theme = AnimeTheme::withTrashed()->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($theme, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testOnlyTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::ONLY,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $deleteTheme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $deleteTheme->each(function (AnimeTheme $theme) {
+ $theme->delete();
+ });
+
+ $theme = AnimeTheme::onlyTrashed()->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($theme, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall support filtering by deleted_at.
+ *
+ * @return void
+ */
+ public function testDeletedAtFilter(): void
+ {
+ $deletedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_DELETED_AT => $deletedFilter,
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($deletedFilter, function () {
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+ });
+
+ $theme = AnimeTheme::withTrashed()->where(BaseModel::ATTRIBUTE_DELETED_AT, $deletedFilter)->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($theme, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall support filtering by group.
+ *
+ * @return void
+ */
+ public function testGroupFilter(): void
+ {
+ $groupFilter = $this->faker->word();
+ $excludedGroup = $this->faker->word();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_GROUP => $groupFilter,
+ ],
+ ];
+
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->state(new Sequence(
+ [AnimeTheme::ATTRIBUTE_GROUP => $groupFilter],
+ [AnimeTheme::ATTRIBUTE_GROUP => $excludedGroup],
+ ))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $themes = AnimeTheme::query()->where(AnimeTheme::ATTRIBUTE_GROUP, $groupFilter)->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($themes, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall support filtering by sequence.
+ *
+ * @return void
+ */
+ public function testSequenceFilter(): void
+ {
+ $sequenceFilter = $this->faker->randomDigitNotNull();
+ $excludedSequence = $sequenceFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_SEQUENCE => $sequenceFilter,
+ ],
+ ];
+
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->state(new Sequence(
+ [AnimeTheme::ATTRIBUTE_SEQUENCE => $sequenceFilter],
+ [AnimeTheme::ATTRIBUTE_SEQUENCE => $excludedSequence],
+ ))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $themes = AnimeTheme::query()->where(AnimeTheme::ATTRIBUTE_SEQUENCE, $sequenceFilter)->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($themes, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall support filtering by type.
+ *
+ * @return void
+ */
+ public function testTypeFilter(): void
+ {
+ $typeFilter = ThemeType::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_TYPE => $typeFilter->description,
+ ],
+ ];
+
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $themes = AnimeTheme::query()->where(AnimeTheme::ATTRIBUTE_TYPE, $typeFilter->value)->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($themes, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall support constrained eager loading of anime by season.
+ *
+ * @return void
+ */
+ public function testAnimeBySeason(): void
+ {
+ $seasonFilter = AnimeSeason::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_SEASON => $seasonFilter->description,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_ANIME,
+ ];
+
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $themes = AnimeTheme::with([
+ AnimeTheme::RELATION_ANIME => function (BelongsTo $query) use ($seasonFilter) {
+ $query->where(Anime::ATTRIBUTE_SEASON, $seasonFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($themes, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall support constrained eager loading of anime by year.
+ *
+ * @return void
+ */
+ public function testAnimeByYear(): void
+ {
+ $yearFilter = intval($this->faker->year());
+ $excludedYear = $yearFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_YEAR => $yearFilter,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_ANIME,
+ ];
+
+ AnimeTheme::factory()
+ ->for(
+ Anime::factory()
+ ->state([
+ Anime::ATTRIBUTE_YEAR => $this->faker->boolean() ? $yearFilter : $excludedYear,
+ ])
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $themes = AnimeTheme::with([
+ AnimeTheme::RELATION_ANIME => function (BelongsTo $query) use ($yearFilter) {
+ $query->where(Anime::ATTRIBUTE_YEAR, $yearFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($themes, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall support constrained eager loading of images by facet.
+ *
+ * @return void
+ */
+ public function testImagesByFacet(): void
+ {
+ $facetFilter = ImageFacet::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Image::ATTRIBUTE_FACET => $facetFilter->description,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_IMAGES,
+ ];
+
+ AnimeTheme::factory()
+ ->for(
+ Anime::factory()
+ ->has(Image::factory()->count($this->faker->randomDigitNotNull()))
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $themes = AnimeTheme::with([
+ AnimeTheme::RELATION_IMAGES => function (BelongsToMany $query) use ($facetFilter) {
+ $query->where(Image::ATTRIBUTE_FACET, $facetFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($themes, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall support constrained eager loading of entries by nsfw.
+ *
+ * @return void
+ */
+ public function testEntriesByNsfw(): void
+ {
+ $nsfwFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeThemeEntry::ATTRIBUTE_NSFW => $nsfwFilter,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_ENTRIES,
+ ];
+
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->has(AnimeThemeEntry::factory()->count($this->faker->randomDigitNotNull()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $themes = AnimeTheme::with([
+ AnimeTheme::RELATION_ENTRIES => function (HasMany $query) use ($nsfwFilter) {
+ $query->where(AnimeThemeEntry::ATTRIBUTE_NSFW, $nsfwFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($themes, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support constrained eager loading of entries by spoiler.
+ *
+ * @return void
+ */
+ public function testEntriesBySpoiler(): void
+ {
+ $spoilerFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeThemeEntry::ATTRIBUTE_SPOILER => $spoilerFilter,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_ENTRIES,
+ ];
+
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->has(AnimeThemeEntry::factory()->count($this->faker->randomDigitNotNull()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $themes = AnimeTheme::with([
+ AnimeTheme::RELATION_ENTRIES => function (HasMany $query) use ($spoilerFilter) {
+ $query->where(AnimeThemeEntry::ATTRIBUTE_SPOILER, $spoilerFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($themes, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support constrained eager loading of entries by version.
+ *
+ * @return void
+ */
+ public function testEntriesByVersion(): void
+ {
+ $versionFilter = $this->faker->randomDigitNotNull();
+ $excludedVersion = $versionFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeThemeEntry::ATTRIBUTE_VERSION => $versionFilter,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_ENTRIES,
+ ];
+
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->state(new Sequence(
+ [AnimeThemeEntry::ATTRIBUTE_VERSION => $versionFilter],
+ [AnimeThemeEntry::ATTRIBUTE_VERSION => $excludedVersion],
+ ))
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $themes = AnimeTheme::with([
+ AnimeTheme::RELATION_ENTRIES => function (HasMany $query) use ($versionFilter) {
+ $query->where(AnimeThemeEntry::ATTRIBUTE_VERSION, $versionFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($themes, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall support constrained eager loading of videos by lyrics.
+ *
+ * @return void
+ */
+ public function testVideosByLyrics(): void
+ {
+ $lyricsFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_LYRICS => $lyricsFilter,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_VIDEOS,
+ ];
+
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(Video::factory()->count($this->faker->randomDigitNotNull()))
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $themes = AnimeTheme::with([
+ AnimeTheme::RELATION_VIDEOS => function (BelongsToMany $query) use ($lyricsFilter) {
+ $query->where(Video::ATTRIBUTE_LYRICS, $lyricsFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($themes, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall support constrained eager loading of videos by nc.
+ *
+ * @return void
+ */
+ public function testVideosByNc(): void
+ {
+ $ncFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_NC => $ncFilter,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_VIDEOS,
+ ];
+
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(Video::factory()->count($this->faker->randomDigitNotNull()))
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $themes = AnimeTheme::with([
+ AnimeTheme::RELATION_VIDEOS => function (BelongsToMany $query) use ($ncFilter) {
+ $query->where(Video::ATTRIBUTE_NC, $ncFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($themes, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall support constrained eager loading of videos by overlap.
+ *
+ * @return void
+ */
+ public function testVideosByOverlap(): void
+ {
+ $overlapFilter = VideoOverlap::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_OVERLAP => $overlapFilter->description,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_VIDEOS,
+ ];
+
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(Video::factory()->count($this->faker->randomDigitNotNull()))
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $themes = AnimeTheme::with([
+ AnimeTheme::RELATION_VIDEOS => function (BelongsToMany $query) use ($overlapFilter) {
+ $query->where(Video::ATTRIBUTE_OVERLAP, $overlapFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($themes, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall support constrained eager loading of videos by resolution.
+ *
+ * @return void
+ */
+ public function testVideosByResolution(): void
+ {
+ $resolutionFilter = $this->faker->randomNumber();
+ $excludedResolution = $resolutionFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_RESOLUTION => $resolutionFilter,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_VIDEOS,
+ ];
+
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(
+ Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->state(new Sequence(
+ [Video::ATTRIBUTE_RESOLUTION => $resolutionFilter],
+ [Video::ATTRIBUTE_RESOLUTION => $excludedResolution],
+ ))
+ )
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $themes = AnimeTheme::with([
+ AnimeTheme::RELATION_VIDEOS => function (BelongsToMany $query) use ($resolutionFilter) {
+ $query->where(Video::ATTRIBUTE_RESOLUTION, $resolutionFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($themes, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall support constrained eager loading of videos by source.
+ *
+ * @return void
+ */
+ public function testVideosBySource(): void
+ {
+ $sourceFilter = VideoSource::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_SOURCE => $sourceFilter->description,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_VIDEOS,
+ ];
+
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(Video::factory()->count($this->faker->randomDigitNotNull()))
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $themes = AnimeTheme::with([
+ AnimeTheme::RELATION_VIDEOS => function (BelongsToMany $query) use ($sourceFilter) {
+ $query->where(Video::ATTRIBUTE_SOURCE, $sourceFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($themes, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall support constrained eager loading of videos by subbed.
+ *
+ * @return void
+ */
+ public function testVideosBySubbed(): void
+ {
+ $subbedFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_SUBBED => $subbedFilter,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_VIDEOS,
+ ];
+
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(Video::factory()->count($this->faker->randomDigitNotNull()))
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $themes = AnimeTheme::with([
+ AnimeTheme::RELATION_VIDEOS => function (BelongsToMany $query) use ($subbedFilter) {
+ $query->where(Video::ATTRIBUTE_SUBBED, $subbedFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($themes, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Index Endpoint shall support constrained eager loading of videos by uncen.
+ *
+ * @return void
+ */
+ public function testVideosByUncen(): void
+ {
+ $uncenFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_UNCEN => $uncenFilter,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_VIDEOS,
+ ];
+
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(Video::factory()->count($this->faker->randomDigitNotNull()))
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $themes = AnimeTheme::with([
+ AnimeTheme::RELATION_VIDEOS => function (BelongsToMany $query) use ($uncenFilter) {
+ $query->where(Video::ATTRIBUTE_UNCEN, $uncenFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.animetheme.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeCollection($themes, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/Theme/ThemeRestoreTest.php b/tests/Feature/Http/Api/Wiki/Anime/Theme/ThemeRestoreTest.php
new file mode 100644
index 000000000..c4f853d03
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/Theme/ThemeRestoreTest.php
@@ -0,0 +1,60 @@
+for(Anime::factory())->createOne();
+
+ $theme->delete();
+
+ $response = $this->patch(route('api.animetheme.restore', ['animetheme' => $theme]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Theme Restore Endpoint shall restore the theme.
+ *
+ * @return void
+ */
+ public function testRestored(): void
+ {
+ Event::fakeExcept(ThemeCreating::class);
+
+ $theme = AnimeTheme::factory()->for(Anime::factory())->createOne();
+
+ $theme->delete();
+
+ $user = User::factory()->withPermission('restore anime theme')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->patch(route('api.animetheme.restore', ['animetheme' => $theme]));
+
+ $response->assertOk();
+ static::assertNotSoftDeleted($theme);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/Theme/ThemeShowTest.php b/tests/Feature/Http/Api/Wiki/Anime/Theme/ThemeShowTest.php
new file mode 100644
index 000000000..7b2614725
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/Theme/ThemeShowTest.php
@@ -0,0 +1,760 @@
+for(Anime::factory())
+ ->createOne();
+
+ $theme->unsetRelations();
+
+ $response = $this->get(route('api.animetheme.show', ['animetheme' => $theme]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeResource($theme, new ThemeReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Show Endpoint shall return a Theme Resource for soft deleted themes.
+ *
+ * @return void
+ */
+ public function testSoftDelete(): void
+ {
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ $theme->delete();
+
+ $theme->unsetRelations();
+
+ $response = $this->get(route('api.animetheme.show', ['animetheme' => $theme]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeResource($theme, new ThemeReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Show Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new ThemeSchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->for(Song::factory())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(Video::factory()->count($this->faker->randomDigitNotNull()))
+ )
+ ->createOne();
+
+ $response = $this->get(route('api.animetheme.show', ['animetheme' => $theme] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeResource($theme, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Show Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $schema = new ThemeSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ ThemeResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->createOne();
+
+ $theme->unsetRelations();
+
+ $response = $this->get(route('api.animetheme.show', ['animetheme' => $theme] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeResource($theme, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Show Endpoint shall support constrained eager loading of anime by season.
+ *
+ * @return void
+ */
+ public function testAnimeBySeason(): void
+ {
+ $seasonFilter = AnimeSeason::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_SEASON => $seasonFilter->description,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_ANIME,
+ ];
+
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ $theme->unsetRelations()->load([
+ AnimeTheme::RELATION_ANIME => function (BelongsTo $query) use ($seasonFilter) {
+ $query->where(Anime::ATTRIBUTE_SEASON, $seasonFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.animetheme.show', ['animetheme' => $theme] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeResource($theme, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Show Endpoint shall support constrained eager loading of anime by year.
+ *
+ * @return void
+ */
+ public function testAnimeByYear(): void
+ {
+ $yearFilter = intval($this->faker->year());
+ $excludedYear = $yearFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_YEAR => $yearFilter,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_ANIME,
+ ];
+
+ $theme = AnimeTheme::factory()
+ ->for(
+ Anime::factory()
+ ->state([
+ Anime::ATTRIBUTE_YEAR => $this->faker->boolean() ? $yearFilter : $excludedYear,
+ ])
+ )
+ ->createOne();
+
+ $theme->unsetRelations()->load([
+ AnimeTheme::RELATION_ANIME => function (BelongsTo $query) use ($yearFilter) {
+ $query->where(Anime::ATTRIBUTE_YEAR, $yearFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.animetheme.show', ['animetheme' => $theme] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeResource($theme, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Show Endpoint shall support constrained eager loading of images by facet.
+ *
+ * @return void
+ */
+ public function testImagesByFacet(): void
+ {
+ $facetFilter = ImageFacet::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Image::ATTRIBUTE_FACET => $facetFilter->description,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_IMAGES,
+ ];
+
+ $theme = AnimeTheme::factory()
+ ->for(
+ Anime::factory()
+ ->has(Image::factory()->count($this->faker->randomDigitNotNull()))
+ )
+ ->createOne();
+
+ $theme->unsetRelations()->load([
+ AnimeTheme::RELATION_IMAGES => function (BelongsToMany $query) use ($facetFilter) {
+ $query->where(Image::ATTRIBUTE_FACET, $facetFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.animetheme.show', ['animetheme' => $theme] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeResource($theme, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Show Endpoint shall support constrained eager loading of entries by nsfw.
+ *
+ * @return void
+ */
+ public function testEntriesByNsfw(): void
+ {
+ $nsfwFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeThemeEntry::ATTRIBUTE_NSFW => $nsfwFilter,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_ENTRIES,
+ ];
+
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->has(AnimeThemeEntry::factory()->count($this->faker->randomDigitNotNull()))
+ ->createOne();
+
+ $theme->unsetRelations()->load([
+ AnimeTheme::RELATION_ENTRIES => function (HasMany $query) use ($nsfwFilter) {
+ $query->where(AnimeThemeEntry::ATTRIBUTE_NSFW, $nsfwFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.animetheme.show', ['animetheme' => $theme] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeResource($theme, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support constrained eager loading of entries by spoiler.
+ *
+ * @return void
+ */
+ public function testEntriesBySpoiler(): void
+ {
+ $spoilerFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeThemeEntry::ATTRIBUTE_SPOILER => $spoilerFilter,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_ENTRIES,
+ ];
+
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->has(AnimeThemeEntry::factory()->count($this->faker->randomDigitNotNull()))
+ ->createOne();
+
+ $theme->unsetRelations()->load([
+ AnimeTheme::RELATION_ENTRIES => function (HasMany $query) use ($spoilerFilter) {
+ $query->where(AnimeThemeEntry::ATTRIBUTE_SPOILER, $spoilerFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.animetheme.show', ['animetheme' => $theme] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeResource($theme, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Anime Index Endpoint shall support constrained eager loading of entries by version.
+ *
+ * @return void
+ */
+ public function testEntriesByVersion(): void
+ {
+ $versionFilter = $this->faker->randomDigitNotNull();
+ $excludedVersion = $versionFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeThemeEntry::ATTRIBUTE_VERSION => $versionFilter,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_ENTRIES,
+ ];
+
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->state(new Sequence(
+ [AnimeThemeEntry::ATTRIBUTE_VERSION => $versionFilter],
+ [AnimeThemeEntry::ATTRIBUTE_VERSION => $excludedVersion],
+ ))
+ )
+ ->createOne();
+
+ $theme->unsetRelations()->load([
+ AnimeTheme::RELATION_ENTRIES => function (HasMany $query) use ($versionFilter) {
+ $query->where(AnimeThemeEntry::ATTRIBUTE_VERSION, $versionFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.animetheme.show', ['animetheme' => $theme] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeResource($theme, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Show Endpoint shall support constrained eager loading of videos by lyrics.
+ *
+ * @return void
+ */
+ public function testVideosByLyrics(): void
+ {
+ $lyricsFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_LYRICS => $lyricsFilter,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_VIDEOS,
+ ];
+
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(Video::factory()->count($this->faker->randomDigitNotNull()))
+ )
+ ->createOne();
+
+ $theme->unsetRelations()->load([
+ AnimeTheme::RELATION_VIDEOS => function (BelongsToMany $query) use ($lyricsFilter) {
+ $query->where(Video::ATTRIBUTE_LYRICS, $lyricsFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.animetheme.show', ['animetheme' => $theme] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeResource($theme, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Show Endpoint shall support constrained eager loading of videos by nc.
+ *
+ * @return void
+ */
+ public function testVideosByNc(): void
+ {
+ $ncFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_NC => $ncFilter,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_VIDEOS,
+ ];
+
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(Video::factory()->count($this->faker->randomDigitNotNull()))
+ )
+ ->createOne();
+
+ $theme->unsetRelations()->load([
+ AnimeTheme::RELATION_VIDEOS => function (BelongsToMany $query) use ($ncFilter) {
+ $query->where(Video::ATTRIBUTE_NC, $ncFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.animetheme.show', ['animetheme' => $theme] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeResource($theme, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Show Endpoint shall support constrained eager loading of videos by overlap.
+ *
+ * @return void
+ */
+ public function testVideosByOverlap(): void
+ {
+ $overlapFilter = VideoOverlap::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_OVERLAP => $overlapFilter->description,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_VIDEOS,
+ ];
+
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(Video::factory()->count($this->faker->randomDigitNotNull()))
+ )
+ ->createOne();
+
+ $theme->unsetRelations()->load([
+ AnimeTheme::RELATION_VIDEOS => function (BelongsToMany $query) use ($overlapFilter) {
+ $query->where(Video::ATTRIBUTE_OVERLAP, $overlapFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.animetheme.show', ['animetheme' => $theme] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeResource($theme, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Show Endpoint shall support constrained eager loading of videos by resolution.
+ *
+ * @return void
+ */
+ public function testVideosByResolution(): void
+ {
+ $resolutionFilter = $this->faker->randomNumber();
+ $excludedResolution = $resolutionFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_RESOLUTION => $resolutionFilter,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_VIDEOS,
+ ];
+
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(
+ Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->state(new Sequence(
+ [Video::ATTRIBUTE_RESOLUTION => $resolutionFilter],
+ [Video::ATTRIBUTE_RESOLUTION => $excludedResolution],
+ ))
+ )
+ )
+ ->createOne();
+
+ $theme->unsetRelations()->load([
+ AnimeTheme::RELATION_VIDEOS => function (BelongsToMany $query) use ($resolutionFilter) {
+ $query->where(Video::ATTRIBUTE_RESOLUTION, $resolutionFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.animetheme.show', ['animetheme' => $theme] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeResource($theme, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Show Endpoint shall support constrained eager loading of videos by source.
+ *
+ * @return void
+ */
+ public function testVideosBySource(): void
+ {
+ $sourceFilter = VideoSource::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_SOURCE => $sourceFilter->description,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_VIDEOS,
+ ];
+
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(Video::factory()->count($this->faker->randomDigitNotNull()))
+ )
+ ->createOne();
+
+ $theme->unsetRelations()->load([
+ AnimeTheme::RELATION_VIDEOS => function (BelongsToMany $query) use ($sourceFilter) {
+ $query->where(Video::ATTRIBUTE_SOURCE, $sourceFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.animetheme.show', ['animetheme' => $theme] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeResource($theme, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Show Endpoint shall support constrained eager loading of videos by subbed.
+ *
+ * @return void
+ */
+ public function testVideosBySubbed(): void
+ {
+ $subbedFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_SUBBED => $subbedFilter,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_VIDEOS,
+ ];
+
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(Video::factory()->count($this->faker->randomDigitNotNull()))
+ )
+ ->createOne();
+
+ $theme->unsetRelations()->load([
+ AnimeTheme::RELATION_VIDEOS => function (BelongsToMany $query) use ($subbedFilter) {
+ $query->where(Video::ATTRIBUTE_SUBBED, $subbedFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.animetheme.show', ['animetheme' => $theme] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeResource($theme, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Theme Show Endpoint shall support constrained eager loading of videos by uncen.
+ *
+ * @return void
+ */
+ public function testVideosByUncen(): void
+ {
+ $uncenFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_UNCEN => $uncenFilter,
+ ],
+ IncludeParser::param() => AnimeTheme::RELATION_VIDEOS,
+ ];
+
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(Video::factory()->count($this->faker->randomDigitNotNull()))
+ )
+ ->createOne();
+
+ $theme->unsetRelations()->load([
+ AnimeTheme::RELATION_VIDEOS => function (BelongsToMany $query) use ($uncenFilter) {
+ $query->where(Video::ATTRIBUTE_UNCEN, $uncenFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.animetheme.show', ['animetheme' => $theme] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ThemeResource($theme, new ThemeReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/Theme/ThemeStoreTest.php b/tests/Feature/Http/Api/Wiki/Anime/Theme/ThemeStoreTest.php
new file mode 100644
index 000000000..2bccfdda1
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/Theme/ThemeStoreTest.php
@@ -0,0 +1,84 @@
+for(Anime::factory())->makeOne();
+
+ $response = $this->post(route('api.animetheme.store', $theme->toArray()));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Theme Store Endpoint shall require the anime_id & type field.
+ *
+ * @return void
+ */
+ public function testRequiredFields(): void
+ {
+ Event::fakeExcept(ThemeCreating::class);
+
+ $user = User::factory()->withPermission('create anime theme')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.animetheme.store'));
+
+ $response->assertJsonValidationErrors([
+ AnimeTheme::ATTRIBUTE_ANIME,
+ AnimeTheme::ATTRIBUTE_TYPE,
+ ]);
+ }
+
+ /**
+ * The Theme Store Endpoint shall create a theme.
+ *
+ * @return void
+ */
+ public function testCreate(): void
+ {
+ Event::fakeExcept(ThemeCreating::class);
+
+ $anime = Anime::factory()->createOne();
+
+ $parameters = array_merge(
+ AnimeTheme::factory()->raw(),
+ [AnimeTheme::ATTRIBUTE_TYPE => ThemeType::getRandomInstance()->description],
+ [AnimeTheme::ATTRIBUTE_ANIME => $anime->getKey()],
+ );
+
+ $user = User::factory()->withPermission('create anime theme')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.animetheme.store', $parameters));
+
+ $response->assertCreated();
+ static::assertDatabaseCount(AnimeTheme::TABLE, 1);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/Theme/ThemeUpdateTest.php b/tests/Feature/Http/Api/Wiki/Anime/Theme/ThemeUpdateTest.php
new file mode 100644
index 000000000..c6da8e2ba
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/Theme/ThemeUpdateTest.php
@@ -0,0 +1,66 @@
+for(Anime::factory())->createOne();
+
+ $parameters = array_merge(
+ AnimeTheme::factory()->raw(),
+ [AnimeTheme::ATTRIBUTE_TYPE => ThemeType::getRandomInstance()->description],
+ );
+
+ $response = $this->put(route('api.animetheme.update', ['animetheme' => $theme] + $parameters));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Theme Update Endpoint shall update a theme.
+ *
+ * @return void
+ */
+ public function testUpdate(): void
+ {
+ Event::fakeExcept(ThemeCreating::class);
+
+ $theme = AnimeTheme::factory()->for(Anime::factory())->createOne();
+
+ $parameters = array_merge(
+ AnimeTheme::factory()->raw(),
+ [AnimeTheme::ATTRIBUTE_TYPE => ThemeType::getRandomInstance()->description],
+ );
+
+ $user = User::factory()->withPermission('update anime theme')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->put(route('api.animetheme.update', ['animetheme' => $theme] + $parameters));
+
+ $response->assertOk();
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/YearIndexTest.php b/tests/Feature/Http/Api/Wiki/Anime/YearIndexTest.php
new file mode 100644
index 000000000..8dd291c2f
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/YearIndexTest.php
@@ -0,0 +1,37 @@
+count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $response = $this->get(route('api.animeyear.index'));
+
+ $response->assertJson(
+ $anime->unique(Anime::ATTRIBUTE_YEAR)->sortBy(Anime::ATTRIBUTE_YEAR)->pluck(Anime::ATTRIBUTE_YEAR)->all(),
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Anime/YearShowTest.php b/tests/Feature/Http/Api/Wiki/Anime/YearShowTest.php
new file mode 100644
index 000000000..248481a35
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Anime/YearShowTest.php
@@ -0,0 +1,224 @@
+withoutEvents();
+
+ $year = intval($this->faker->year());
+
+ $winterAnime = Anime::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->create([
+ Anime::ATTRIBUTE_YEAR => $year,
+ Anime::ATTRIBUTE_SEASON => AnimeSeason::WINTER,
+ ]);
+
+ $winterResources = new AnimeCollection($winterAnime->sortBy(Anime::ATTRIBUTE_NAME)->values(), new AnimeReadQuery());
+
+ $springAnime = Anime::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->create([
+ Anime::ATTRIBUTE_YEAR => $year,
+ Anime::ATTRIBUTE_SEASON => AnimeSeason::SPRING,
+ ]);
+
+ $springResources = new AnimeCollection($springAnime->sortBy(Anime::ATTRIBUTE_NAME)->values(), new AnimeReadQuery());
+
+ $summerAnime = Anime::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->create([
+ Anime::ATTRIBUTE_YEAR => $year,
+ Anime::ATTRIBUTE_SEASON => AnimeSeason::SUMMER,
+ ]);
+
+ $summerResources = new AnimeCollection($summerAnime->sortBy(Anime::ATTRIBUTE_NAME)->values(), new AnimeReadQuery());
+
+ $fallAnime = Anime::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->create([
+ Anime::ATTRIBUTE_YEAR => $year,
+ Anime::ATTRIBUTE_SEASON => AnimeSeason::FALL,
+ ]);
+
+ $fallResources = new AnimeCollection($fallAnime->sortBy(Anime::ATTRIBUTE_NAME)->values(), new AnimeReadQuery());
+
+ $response = $this->get(route('api.animeyear.show', [Anime::ATTRIBUTE_YEAR => $year]));
+
+ $response->assertJson([
+ Str::lower(AnimeSeason::getDescription(AnimeSeason::WINTER)) => json_decode(json_encode($winterResources->response()->getData()->anime), true),
+ Str::lower(AnimeSeason::getDescription(AnimeSeason::SPRING)) => json_decode(json_encode($springResources->response()->getData()->anime), true),
+ Str::lower(AnimeSeason::getDescription(AnimeSeason::SUMMER)) => json_decode(json_encode($summerResources->response()->getData()->anime), true),
+ Str::lower(AnimeSeason::getDescription(AnimeSeason::FALL)) => json_decode(json_encode($fallResources->response()->getData()->anime), true),
+ ]);
+ }
+
+ /**
+ * The Year Show Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $year = intval($this->faker->year());
+
+ $schema = new AnimeSchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ $winterAnime = Anime::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->jsonApiResource()
+ ->create([
+ Anime::ATTRIBUTE_YEAR => $year,
+ Anime::ATTRIBUTE_SEASON => AnimeSeason::WINTER,
+ ]);
+
+ $winterResources = new AnimeCollection($winterAnime->sortBy(Anime::ATTRIBUTE_NAME)->values(), new AnimeReadQuery($parameters));
+
+ $springAnime = Anime::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->jsonApiResource()
+ ->create([
+ Anime::ATTRIBUTE_YEAR => $year,
+ Anime::ATTRIBUTE_SEASON => AnimeSeason::SPRING,
+ ]);
+
+ $springResources = new AnimeCollection($springAnime->sortBy(Anime::ATTRIBUTE_NAME)->values(), new AnimeReadQuery($parameters));
+
+ $summerAnime = Anime::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->jsonApiResource()
+ ->create([
+ Anime::ATTRIBUTE_YEAR => $year,
+ Anime::ATTRIBUTE_SEASON => AnimeSeason::SUMMER,
+ ]);
+
+ $summerResources = new AnimeCollection($summerAnime->sortBy(Anime::ATTRIBUTE_NAME)->values(), new AnimeReadQuery($parameters));
+
+ $fallAnime = Anime::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->jsonApiResource()
+ ->create([
+ Anime::ATTRIBUTE_YEAR => $year,
+ Anime::ATTRIBUTE_SEASON => AnimeSeason::FALL,
+ ]);
+
+ $fallResources = new AnimeCollection($fallAnime->sortBy(Anime::ATTRIBUTE_NAME)->values(), new AnimeReadQuery($parameters));
+
+ $response = $this->get(route('api.animeyear.show', [Anime::ATTRIBUTE_YEAR => $year] + $parameters));
+
+ $response->assertJson([
+ Str::lower(AnimeSeason::getDescription(AnimeSeason::WINTER)) => json_decode(json_encode($winterResources->response()->getData()->anime), true),
+ Str::lower(AnimeSeason::getDescription(AnimeSeason::SPRING)) => json_decode(json_encode($springResources->response()->getData()->anime), true),
+ Str::lower(AnimeSeason::getDescription(AnimeSeason::SUMMER)) => json_decode(json_encode($summerResources->response()->getData()->anime), true),
+ Str::lower(AnimeSeason::getDescription(AnimeSeason::FALL)) => json_decode(json_encode($fallResources->response()->getData()->anime), true),
+ ]);
+ }
+
+ /**
+ * The Year Show Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $this->withoutEvents();
+
+ $year = intval($this->faker->year());
+
+ $schema = new AnimeSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ AnimeResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $winterAnime = Anime::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->create([
+ Anime::ATTRIBUTE_YEAR => $year,
+ Anime::ATTRIBUTE_SEASON => AnimeSeason::WINTER,
+ ]);
+
+ $winterResources = new AnimeCollection($winterAnime->sortBy(Anime::ATTRIBUTE_NAME)->values(), new AnimeReadQuery($parameters));
+
+ $springAnime = Anime::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->create([
+ Anime::ATTRIBUTE_YEAR => $year,
+ Anime::ATTRIBUTE_SEASON => AnimeSeason::SPRING,
+ ]);
+
+ $springResources = new AnimeCollection($springAnime->sortBy(Anime::ATTRIBUTE_NAME)->values(), new AnimeReadQuery($parameters));
+
+ $summerAnime = Anime::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->create([
+ Anime::ATTRIBUTE_YEAR => $year,
+ Anime::ATTRIBUTE_SEASON => AnimeSeason::SUMMER,
+ ]);
+
+ $summerResources = new AnimeCollection($summerAnime->sortBy(Anime::ATTRIBUTE_NAME)->values(), new AnimeReadQuery($parameters));
+
+ $fallAnime = Anime::factory()
+ ->count($this->faker->numberBetween(1, 3))
+ ->create([
+ Anime::ATTRIBUTE_YEAR => $year,
+ Anime::ATTRIBUTE_SEASON => AnimeSeason::FALL,
+ ]);
+
+ $fallResources = new AnimeCollection($fallAnime->sortBy(Anime::ATTRIBUTE_NAME)->values(), new AnimeReadQuery($parameters));
+
+ $response = $this->get(route('api.animeyear.show', [Anime::ATTRIBUTE_YEAR => $year]));
+
+ $response->assertJson([
+ Str::lower(AnimeSeason::getDescription(AnimeSeason::WINTER)) => json_decode(json_encode($winterResources->response()->getData()->anime), true),
+ Str::lower(AnimeSeason::getDescription(AnimeSeason::SPRING)) => json_decode(json_encode($springResources->response()->getData()->anime), true),
+ Str::lower(AnimeSeason::getDescription(AnimeSeason::SUMMER)) => json_decode(json_encode($summerResources->response()->getData()->anime), true),
+ Str::lower(AnimeSeason::getDescription(AnimeSeason::FALL)) => json_decode(json_encode($fallResources->response()->getData()->anime), true),
+ ]);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Artist/ArtistDestroyTest.php b/tests/Feature/Http/Api/Wiki/Artist/ArtistDestroyTest.php
new file mode 100644
index 000000000..da45333e0
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Artist/ArtistDestroyTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.artist.destroy', ['artist' => $artist]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Artist Destroy Endpoint shall delete the artist.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $artist = Artist::factory()->createOne();
+
+ $user = User::factory()->withPermission('delete artist')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.artist.destroy', ['artist' => $artist]));
+
+ $response->assertOk();
+ static::assertSoftDeleted($artist);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Artist/ArtistForceDeleteTest.php b/tests/Feature/Http/Api/Wiki/Artist/ArtistForceDeleteTest.php
new file mode 100644
index 000000000..b047f1308
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Artist/ArtistForceDeleteTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.artist.forceDelete', ['artist' => $artist]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Artist Force Destroy Endpoint shall force delete the artist.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $artist = Artist::factory()->createOne();
+
+ $user = User::factory()->withPermission('force delete artist')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.artist.forceDelete', ['artist' => $artist]));
+
+ $response->assertOk();
+ static::assertModelMissing($artist);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Artist/ArtistIndexTest.php b/tests/Feature/Http/Api/Wiki/Artist/ArtistIndexTest.php
new file mode 100644
index 000000000..1bd01b9d5
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Artist/ArtistIndexTest.php
@@ -0,0 +1,827 @@
+withoutEvents();
+
+ $artists = Artist::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.artist.index'));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistCollection($artists, new ArtistReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Index Endpoint shall be paginated.
+ *
+ * @return void
+ */
+ public function testPaginated(): void
+ {
+ $this->withoutEvents();
+
+ Artist::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.artist.index'));
+
+ $response->assertJsonStructure([
+ ArtistCollection::$wrap,
+ 'links',
+ 'meta',
+ ]);
+ }
+
+ /**
+ * The Artist Index Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new ArtistSchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Artist::factory()->jsonApiResource()->create();
+ $artists = Artist::with($includedPaths->all())->get();
+
+ $response = $this->get(route('api.artist.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistCollection($artists, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Index Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $this->withoutEvents();
+
+ $schema = new ArtistSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ ArtistResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $artists = Artist::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.artist.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistCollection($artists, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Index Endpoint shall support sorting resources.
+ *
+ * @return void
+ */
+ public function testSorts(): void
+ {
+ $this->withoutEvents();
+
+ $schema = new ArtistSchema();
+
+ $sort = collect($schema->fields())
+ ->filter(fn (Field $field) => $field instanceof SortableField)
+ ->map(fn (SortableField $field) => $field->getSort())
+ ->random();
+
+ $parameters = [
+ SortParser::param() => $sort->format(Direction::getRandomInstance()),
+ ];
+
+ $query = new ArtistReadQuery($parameters);
+
+ Artist::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.artist.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ $query->collection($query->index())
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Index Endpoint shall support filtering by created_at.
+ *
+ * @return void
+ */
+ public function testCreatedAtFilter(): void
+ {
+ $this->withoutEvents();
+
+ $createdFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_CREATED_AT => $createdFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($createdFilter, function () {
+ Artist::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Artist::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $artist = Artist::query()->where(BaseModel::ATTRIBUTE_CREATED_AT, $createdFilter)->get();
+
+ $response = $this->get(route('api.artist.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistCollection($artist, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Index Endpoint shall support filtering by updated_at.
+ *
+ * @return void
+ */
+ public function testUpdatedAtFilter(): void
+ {
+ $this->withoutEvents();
+
+ $updatedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_UPDATED_AT => $updatedFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($updatedFilter, function () {
+ Artist::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Artist::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $artist = Artist::query()->where(BaseModel::ATTRIBUTE_UPDATED_AT, $updatedFilter)->get();
+
+ $response = $this->get(route('api.artist.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistCollection($artist, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithoutTrashedFilter(): void
+ {
+ $this->withoutEvents();
+
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITHOUT,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Artist::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteArtist = Artist::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteArtist->each(function (Artist $artist) {
+ $artist->delete();
+ });
+
+ $artist = Artist::withoutTrashed()->get();
+
+ $response = $this->get(route('api.artist.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistCollection($artist, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithTrashedFilter(): void
+ {
+ $this->withoutEvents();
+
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Artist::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteArtist = Artist::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteArtist->each(function (Artist $artist) {
+ $artist->delete();
+ });
+
+ $artist = Artist::withTrashed()->get();
+
+ $response = $this->get(route('api.artist.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistCollection($artist, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testOnlyTrashedFilter(): void
+ {
+ $this->withoutEvents();
+
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::ONLY,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Artist::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteArtist = Artist::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteArtist->each(function (Artist $artist) {
+ $artist->delete();
+ });
+
+ $artist = Artist::onlyTrashed()->get();
+
+ $response = $this->get(route('api.artist.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistCollection($artist, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Index Endpoint shall support filtering by deleted_at.
+ *
+ * @return void
+ */
+ public function testDeletedAtFilter(): void
+ {
+ $this->withoutEvents();
+
+ $deletedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_DELETED_AT => $deletedFilter,
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($deletedFilter, function () {
+ $artists = Artist::factory()->count($this->faker->randomDigitNotNull())->create();
+ $artists->each(function (Artist $artist) {
+ $artist->delete();
+ });
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ $artists = Artist::factory()->count($this->faker->randomDigitNotNull())->create();
+ $artists->each(function (Artist $artist) {
+ $artist->delete();
+ });
+ });
+
+ $artist = Artist::withTrashed()->where(BaseModel::ATTRIBUTE_DELETED_AT, $deletedFilter)->get();
+
+ $response = $this->get(route('api.artist.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistCollection($artist, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Index Endpoint shall support constrained eager loading of themes by group.
+ *
+ * @return void
+ */
+ public function testThemesByGroup(): void
+ {
+ $groupFilter = $this->faker->word();
+ $excludedGroup = $this->faker->word();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_GROUP => $groupFilter,
+ ],
+ IncludeParser::param() => Artist::RELATION_ANIMETHEMES,
+ ];
+
+ Artist::factory()
+ ->has(
+ Song::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->state(new Sequence(
+ [AnimeTheme::ATTRIBUTE_GROUP => $groupFilter],
+ [AnimeTheme::ATTRIBUTE_GROUP => $excludedGroup],
+ ))
+ )
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $artists = Artist::with([
+ Artist::RELATION_ANIMETHEMES => function (HasMany $query) use ($groupFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_GROUP, $groupFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.artist.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistCollection($artists, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Index Endpoint shall support constrained eager loading of themes by sequence.
+ *
+ * @return void
+ */
+ public function testThemesBySequence(): void
+ {
+ $sequenceFilter = $this->faker->randomDigitNotNull();
+ $excludedSequence = $sequenceFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_SEQUENCE => $sequenceFilter,
+ ],
+ IncludeParser::param() => Artist::RELATION_ANIMETHEMES,
+ ];
+
+ Artist::factory()
+ ->has(
+ Song::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->state(new Sequence(
+ [AnimeTheme::ATTRIBUTE_SEQUENCE => $sequenceFilter],
+ [AnimeTheme::ATTRIBUTE_SEQUENCE => $excludedSequence],
+ ))
+ )
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $artists = Artist::with([
+ Artist::RELATION_ANIMETHEMES => function (HasMany $query) use ($sequenceFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_SEQUENCE, $sequenceFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.artist.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistCollection($artists, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Index Endpoint shall support constrained eager loading of themes by type.
+ *
+ * @return void
+ */
+ public function testThemesByType(): void
+ {
+ $typeFilter = ThemeType::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_TYPE => $typeFilter->description,
+ ],
+ IncludeParser::param() => Artist::RELATION_ANIMETHEMES,
+ ];
+
+ Artist::factory()
+ ->has(
+ Song::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ )
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $artists = Artist::with([
+ Artist::RELATION_ANIMETHEMES => function (HasMany $query) use ($typeFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_TYPE, $typeFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.artist.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistCollection($artists, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Index Endpoint shall support constrained eager loading of anime by season.
+ *
+ * @return void
+ */
+ public function testAnimeBySeason(): void
+ {
+ $seasonFilter = AnimeSeason::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_SEASON => $seasonFilter->description,
+ ],
+ IncludeParser::param() => Artist::RELATION_ANIME,
+ ];
+
+ Artist::factory()
+ ->has(
+ Song::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ )
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $artists = Artist::with([
+ Artist::RELATION_ANIME => function (BelongsTo $query) use ($seasonFilter) {
+ $query->where(Anime::ATTRIBUTE_SEASON, $seasonFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.artist.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistCollection($artists, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Index Endpoint shall support constrained eager loading of anime by year.
+ *
+ * @return void
+ */
+ public function testAnimeByYear(): void
+ {
+ $yearFilter = intval($this->faker->year());
+ $excludedYear = $yearFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_YEAR => $yearFilter,
+ ],
+ IncludeParser::param() => Artist::RELATION_ANIME,
+ ];
+
+ Artist::factory()
+ ->has(
+ Song::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(
+ AnimeTheme::factory()
+ ->for(
+ Anime::factory()
+ ->state([
+ Anime::ATTRIBUTE_YEAR => $this->faker->boolean() ? $yearFilter : $excludedYear,
+ ])
+ )
+ ->count($this->faker->randomDigitNotNull())
+ )
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $artists = Artist::with([
+ Artist::RELATION_ANIME => function (BelongsTo $query) use ($yearFilter) {
+ $query->where(Anime::ATTRIBUTE_YEAR, $yearFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.artist.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistCollection($artists, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Index Endpoint shall support constrained eager loading of resources by site.
+ *
+ * @return void
+ */
+ public function testResourcesBySite(): void
+ {
+ $this->withoutEvents();
+
+ $siteFilter = ResourceSite::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ ExternalResource::ATTRIBUTE_SITE => $siteFilter->description,
+ ],
+ IncludeParser::param() => Artist::RELATION_RESOURCES,
+ ];
+
+ Artist::factory()
+ ->has(ExternalResource::factory()->count($this->faker->randomDigitNotNull()), Artist::RELATION_RESOURCES)
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $artists = Artist::with([
+ Artist::RELATION_RESOURCES => function (BelongsToMany $query) use ($siteFilter) {
+ $query->where(ExternalResource::ATTRIBUTE_SITE, $siteFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.artist.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistCollection($artists, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Index Endpoint shall support constrained eager loading of images by facet.
+ *
+ * @return void
+ */
+ public function testImagesByFacet(): void
+ {
+ $this->withoutEvents();
+
+ $facetFilter = ImageFacet::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Image::ATTRIBUTE_FACET => $facetFilter->description,
+ ],
+ IncludeParser::param() => Artist::RELATION_IMAGES,
+ ];
+
+ Artist::factory()
+ ->has(Image::factory()->count($this->faker->randomDigitNotNull()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $artists = Artist::with([
+ Artist::RELATION_IMAGES => function (BelongsToMany $query) use ($facetFilter) {
+ $query->where(Image::ATTRIBUTE_FACET, $facetFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.artist.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistCollection($artists, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Artist/ArtistRestoreTest.php b/tests/Feature/Http/Api/Wiki/Artist/ArtistRestoreTest.php
new file mode 100644
index 000000000..a066377be
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Artist/ArtistRestoreTest.php
@@ -0,0 +1,56 @@
+createOne();
+
+ $artist->delete();
+
+ $response = $this->patch(route('api.artist.restore', ['artist' => $artist]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Artist Restore Endpoint shall restore the artist.
+ *
+ * @return void
+ */
+ public function testRestored(): void
+ {
+ $artist = Artist::factory()->createOne();
+
+ $artist->delete();
+
+ $user = User::factory()->withPermission('restore artist')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->patch(route('api.artist.restore', ['artist' => $artist]));
+
+ $response->assertOk();
+ static::assertNotSoftDeleted($artist);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Artist/ArtistShowTest.php b/tests/Feature/Http/Api/Wiki/Artist/ArtistShowTest.php
new file mode 100644
index 000000000..48aff087b
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Artist/ArtistShowTest.php
@@ -0,0 +1,504 @@
+withoutEvents();
+
+ $artist = Artist::factory()->create();
+
+ $response = $this->get(route('api.artist.show', ['artist' => $artist]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistResource($artist, new ArtistReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Show Endpoint shall return an Artist Resource for soft deleted images.
+ *
+ * @return void
+ */
+ public function testSoftDelete(): void
+ {
+ $this->withoutEvents();
+
+ $artist = Artist::factory()->createOne();
+
+ $artist->delete();
+
+ $artist->unsetRelations();
+
+ $response = $this->get(route('api.artist.show', ['artist' => $artist]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistResource($artist, new ArtistReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Show Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new ArtistSchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ $artist = Artist::factory()->jsonApiResource()->createOne();
+
+ $response = $this->get(route('api.artist.show', ['artist' => $artist] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistResource($artist, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Show Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $this->withoutEvents();
+
+ $schema = new ArtistSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ ArtistResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $artist = Artist::factory()->create();
+
+ $response = $this->get(route('api.artist.show', ['artist' => $artist] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistResource($artist, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Show Endpoint shall support constrained eager loading of themes by group.
+ *
+ * @return void
+ */
+ public function testThemesByGroup(): void
+ {
+ $groupFilter = $this->faker->word();
+ $excludedGroup = $this->faker->word();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_GROUP => $groupFilter,
+ ],
+ IncludeParser::param() => Artist::RELATION_ANIMETHEMES,
+ ];
+
+ $artist = Artist::factory()
+ ->has(
+ Song::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->state(new Sequence(
+ [AnimeTheme::ATTRIBUTE_GROUP => $groupFilter],
+ [AnimeTheme::ATTRIBUTE_GROUP => $excludedGroup],
+ ))
+ )
+ )
+ ->createOne();
+
+ $artist->unsetRelations()->load([
+ Artist::RELATION_ANIMETHEMES => function (HasMany $query) use ($groupFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_GROUP, $groupFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.artist.show', ['artist' => $artist] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistResource($artist, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Show Endpoint shall support constrained eager loading of themes by sequence.
+ *
+ * @return void
+ */
+ public function testThemesBySequence(): void
+ {
+ $sequenceFilter = $this->faker->randomDigitNotNull();
+ $excludedSequence = $sequenceFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_SEQUENCE => $sequenceFilter,
+ ],
+ IncludeParser::param() => Artist::RELATION_ANIMETHEMES,
+ ];
+
+ $artist = Artist::factory()
+ ->has(
+ Song::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ ->state(new Sequence(
+ [AnimeTheme::ATTRIBUTE_SEQUENCE => $sequenceFilter],
+ [AnimeTheme::ATTRIBUTE_SEQUENCE => $excludedSequence],
+ ))
+ )
+ )
+ ->createOne();
+
+ $artist->unsetRelations()->load([
+ Artist::RELATION_ANIMETHEMES => function (HasMany $query) use ($sequenceFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_SEQUENCE, $sequenceFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.artist.show', ['artist' => $artist] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistResource($artist, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Show Endpoint shall support constrained eager loading of themes by type.
+ *
+ * @return void
+ */
+ public function testThemesByType(): void
+ {
+ $typeFilter = ThemeType::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_TYPE => $typeFilter->description,
+ ],
+ IncludeParser::param() => Artist::RELATION_ANIMETHEMES,
+ ];
+
+ $artist = Artist::factory()
+ ->has(
+ Song::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ )
+ )
+ ->createOne();
+
+ $artist->unsetRelations()->load([
+ Artist::RELATION_ANIMETHEMES => function (HasMany $query) use ($typeFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_TYPE, $typeFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.artist.show', ['artist' => $artist] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistResource($artist, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Show Endpoint shall support constrained eager loading of anime by season.
+ *
+ * @return void
+ */
+ public function testAnimeBySeason(): void
+ {
+ $seasonFilter = AnimeSeason::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_SEASON => $seasonFilter->description,
+ ],
+ IncludeParser::param() => Artist::RELATION_ANIME,
+ ];
+
+ $artist = Artist::factory()
+ ->has(
+ Song::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->count($this->faker->randomDigitNotNull())
+ )
+ )
+ ->createOne();
+
+ $artist->unsetRelations()->load([
+ Artist::RELATION_ANIME => function (BelongsTo $query) use ($seasonFilter) {
+ $query->where(Anime::ATTRIBUTE_SEASON, $seasonFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.artist.show', ['artist' => $artist] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistResource($artist, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Show Endpoint shall support constrained eager loading of anime by year.
+ *
+ * @return void
+ */
+ public function testAnimeByYear(): void
+ {
+ $yearFilter = intval($this->faker->year());
+ $excludedYear = $yearFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_YEAR => $yearFilter,
+ ],
+ IncludeParser::param() => Artist::RELATION_ANIME,
+ ];
+
+ $artist = Artist::factory()
+ ->has(
+ Song::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(
+ AnimeTheme::factory()
+ ->for(
+ Anime::factory()
+ ->state([
+ Anime::ATTRIBUTE_YEAR => $this->faker->boolean() ? $yearFilter : $excludedYear,
+ ])
+ )
+ ->count($this->faker->randomDigitNotNull())
+ )
+ )
+ ->createOne();
+
+ $artist->unsetRelations()->load([
+ Artist::RELATION_ANIME => function (BelongsTo $query) use ($yearFilter) {
+ $query->where(Anime::ATTRIBUTE_YEAR, $yearFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.artist.show', ['artist' => $artist] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistResource($artist, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Show Endpoint shall support constrained eager loading of resources by site.
+ *
+ * @return void
+ */
+ public function testResourcesBySite(): void
+ {
+ $this->withoutEvents();
+
+ $siteFilter = ResourceSite::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ ExternalResource::ATTRIBUTE_SITE => $siteFilter->description,
+ ],
+ IncludeParser::param() => Artist::RELATION_RESOURCES,
+ ];
+
+ $artist = Artist::factory()
+ ->has(ExternalResource::factory()->count($this->faker->randomDigitNotNull()), Artist::RELATION_RESOURCES)
+ ->createOne();
+
+ $artist->unsetRelations()->load([
+ Artist::RELATION_RESOURCES => function (BelongsToMany $query) use ($siteFilter) {
+ $query->where(ExternalResource::ATTRIBUTE_SITE, $siteFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.artist.show', ['artist' => $artist] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistResource($artist, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Artist Show Endpoint shall support constrained eager loading of images by facet.
+ *
+ * @return void
+ */
+ public function testImagesByFacet(): void
+ {
+ $this->withoutEvents();
+
+ $facetFilter = ImageFacet::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Image::ATTRIBUTE_FACET => $facetFilter->description,
+ ],
+ IncludeParser::param() => Artist::RELATION_IMAGES,
+ ];
+
+ $artist = Artist::factory()
+ ->has(Image::factory()->count($this->faker->randomDigitNotNull()))
+ ->createOne();
+
+ $artist->unsetRelations()->load([
+ Artist::RELATION_IMAGES => function (BelongsToMany $query) use ($facetFilter) {
+ $query->where(Image::ATTRIBUTE_FACET, $facetFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.artist.show', ['artist' => $artist] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ArtistResource($artist, new ArtistReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Artist/ArtistStoreTest.php b/tests/Feature/Http/Api/Wiki/Artist/ArtistStoreTest.php
new file mode 100644
index 000000000..5f5e2c60b
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Artist/ArtistStoreTest.php
@@ -0,0 +1,71 @@
+makeOne();
+
+ $response = $this->post(route('api.artist.store', $artist->toArray()));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Artist Store Endpoint shall require name & slug fields.
+ *
+ * @return void
+ */
+ public function testRequiredFields(): void
+ {
+ $user = User::factory()->withPermission('create artist')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.artist.store'));
+
+ $response->assertJsonValidationErrors([
+ Artist::ATTRIBUTE_NAME,
+ Artist::ATTRIBUTE_SLUG,
+ ]);
+ }
+
+ /**
+ * The Artist Store Endpoint shall create an artist.
+ *
+ * @return void
+ */
+ public function testCreate(): void
+ {
+ $parameters = Artist::factory()->raw();
+
+ $user = User::factory()->withPermission('create artist')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.artist.store', $parameters));
+
+ $response->assertCreated();
+ static::assertDatabaseCount(Artist::TABLE, 1);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Artist/ArtistUpdateTest.php b/tests/Feature/Http/Api/Wiki/Artist/ArtistUpdateTest.php
new file mode 100644
index 000000000..11f719304
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Artist/ArtistUpdateTest.php
@@ -0,0 +1,55 @@
+createOne();
+
+ $parameters = Artist::factory()->raw();
+
+ $response = $this->put(route('api.artist.update', ['artist' => $artist] + $parameters));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Artist Update Endpoint shall update an artist.
+ *
+ * @return void
+ */
+ public function testUpdate(): void
+ {
+ $artist = Artist::factory()->createOne();
+
+ $parameters = Artist::factory()->raw();
+
+ $user = User::factory()->withPermission('update artist')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->put(route('api.artist.update', ['artist' => $artist] + $parameters));
+
+ $response->assertOk();
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/ExternalResource/ExternalResourceDestroyTest.php b/tests/Feature/Http/Api/Wiki/ExternalResource/ExternalResourceDestroyTest.php
new file mode 100644
index 000000000..fb9f1bb6f
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/ExternalResource/ExternalResourceDestroyTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.resource.destroy', ['resource' => $resource]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The ExternalResource Destroy Endpoint shall delete the resource.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $resource = ExternalResource::factory()->createOne();
+
+ $user = User::factory()->withPermission('delete external resource')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.resource.destroy', ['resource' => $resource]));
+
+ $response->assertOk();
+ static::assertSoftDeleted($resource);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/ExternalResource/ExternalResourceForceDeleteTest.php b/tests/Feature/Http/Api/Wiki/ExternalResource/ExternalResourceForceDeleteTest.php
new file mode 100644
index 000000000..85f903c2a
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/ExternalResource/ExternalResourceForceDeleteTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.resource.forceDelete', ['resource' => $resource]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The ExternalResource Force Destroy Endpoint shall force delete the resource.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $resource = ExternalResource::factory()->createOne();
+
+ $user = User::factory()->withPermission('force delete external resource')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.resource.forceDelete', ['resource' => $resource]));
+
+ $response->assertOk();
+ static::assertModelMissing($resource);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/ExternalResource/ExternalResourceIndexTest.php b/tests/Feature/Http/Api/Wiki/ExternalResource/ExternalResourceIndexTest.php
new file mode 100644
index 000000000..509afbd71
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/ExternalResource/ExternalResourceIndexTest.php
@@ -0,0 +1,579 @@
+count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $response = $this->get(route('api.resource.index'));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ExternalResourceCollection($resources, new ExternalResourceReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Resource Index Endpoint shall be paginated.
+ *
+ * @return void
+ */
+ public function testPaginated(): void
+ {
+ ExternalResource::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.resource.index'));
+
+ $response->assertJsonStructure([
+ ExternalResourceCollection::$wrap,
+ 'links',
+ 'meta',
+ ]);
+ }
+
+ /**
+ * The Resource Index Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new ExternalResourceSchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ ExternalResource::factory()
+ ->has(Anime::factory()->count($this->faker->randomDigitNotNull()))
+ ->has(Artist::factory()->count($this->faker->randomDigitNotNull()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $resources = ExternalResource::with($includedPaths->all())->get();
+
+ $response = $this->get(route('api.resource.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ExternalResourceCollection($resources, new ExternalResourceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Resource Index Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $schema = new ExternalResourceSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ ExternalResourceResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $resources = ExternalResource::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $response = $this->get(route('api.resource.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ExternalResourceCollection($resources, new ExternalResourceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Resource Index Endpoint shall support sorting resources.
+ *
+ * @return void
+ */
+ public function testSorts(): void
+ {
+ $schema = new ExternalResourceSchema();
+
+ $sort = collect($schema->fields())
+ ->filter(fn (Field $field) => $field instanceof SortableField)
+ ->map(fn (SortableField $field) => $field->getSort())
+ ->random();
+
+ $parameters = [
+ SortParser::param() => $sort->format(Direction::getRandomInstance()),
+ ];
+
+ $query = new ExternalResourceReadQuery($parameters);
+
+ ExternalResource::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.resource.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ $query->collection($query->index())
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Resource Index Endpoint shall support filtering by created_at.
+ *
+ * @return void
+ */
+ public function testCreatedAtFilter(): void
+ {
+ $createdFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_CREATED_AT => $createdFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($createdFilter, function () {
+ ExternalResource::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ ExternalResource::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $resource = ExternalResource::query()->where(BaseModel::ATTRIBUTE_CREATED_AT, $createdFilter)->get();
+
+ $response = $this->get(route('api.resource.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ExternalResourceCollection($resource, new ExternalResourceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Resource Index Endpoint shall support filtering by updated_at.
+ *
+ * @return void
+ */
+ public function testUpdatedAtFilter(): void
+ {
+ $updatedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_UPDATED_AT => $updatedFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($updatedFilter, function () {
+ ExternalResource::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ ExternalResource::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $resource = ExternalResource::query()->where(BaseModel::ATTRIBUTE_UPDATED_AT, $updatedFilter)->get();
+
+ $response = $this->get(route('api.resource.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ExternalResourceCollection($resource, new ExternalResourceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Resource Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithoutTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITHOUT,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ ExternalResource::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteResource = ExternalResource::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteResource->each(function (ExternalResource $resource) {
+ $resource->delete();
+ });
+
+ $resource = ExternalResource::withoutTrashed()->get();
+
+ $response = $this->get(route('api.resource.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ExternalResourceCollection($resource, new ExternalResourceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Resource Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ ExternalResource::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteResource = ExternalResource::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteResource->each(function (ExternalResource $resource) {
+ $resource->delete();
+ });
+
+ $resource = ExternalResource::withTrashed()->get();
+
+ $response = $this->get(route('api.resource.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ExternalResourceCollection($resource, new ExternalResourceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Resource Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testOnlyTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::ONLY,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ ExternalResource::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteResource = ExternalResource::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteResource->each(function (ExternalResource $resource) {
+ $resource->delete();
+ });
+
+ $resource = ExternalResource::onlyTrashed()->get();
+
+ $response = $this->get(route('api.resource.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ExternalResourceCollection($resource, new ExternalResourceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Resource Index Endpoint shall support filtering by deleted_at.
+ *
+ * @return void
+ */
+ public function testDeletedAtFilter(): void
+ {
+ $deletedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_DELETED_AT => $deletedFilter,
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($deletedFilter, function () {
+ $resources = ExternalResource::factory()->count($this->faker->randomDigitNotNull())->create();
+ $resources->each(function (ExternalResource $resource) {
+ $resource->delete();
+ });
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ $resources = ExternalResource::factory()->count($this->faker->randomDigitNotNull())->create();
+ $resources->each(function (ExternalResource $resource) {
+ $resource->delete();
+ });
+ });
+
+ $resource = ExternalResource::withTrashed()->where(BaseModel::ATTRIBUTE_DELETED_AT, $deletedFilter)->get();
+
+ $response = $this->get(route('api.resource.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ExternalResourceCollection($resource, new ExternalResourceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Resource Index Endpoint shall support filtering by site.
+ *
+ * @return void
+ */
+ public function testSiteFilter(): void
+ {
+ $siteFilter = ResourceSite::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ ExternalResource::ATTRIBUTE_SITE => $siteFilter->description,
+ ],
+ ];
+
+ ExternalResource::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $resources = ExternalResource::query()->where(ExternalResource::ATTRIBUTE_SITE, $siteFilter->value)->get();
+
+ $response = $this->get(route('api.resource.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ExternalResourceCollection($resources, new ExternalResourceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Resource Index Endpoint shall support constrained eager loading of anime by season.
+ *
+ * @return void
+ */
+ public function testAnimeBySeason(): void
+ {
+ $seasonFilter = AnimeSeason::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_SEASON => $seasonFilter->description,
+ ],
+ IncludeParser::param() => ExternalResource::RELATION_ANIME,
+ ];
+
+ ExternalResource::factory()
+ ->has(Anime::factory()->count($this->faker->randomDigitNotNull()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $resources = ExternalResource::with([
+ ExternalResource::RELATION_ANIME => function (BelongsToMany $query) use ($seasonFilter) {
+ $query->where(Anime::ATTRIBUTE_SEASON, $seasonFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.resource.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ExternalResourceCollection($resources, new ExternalResourceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Resource Index Endpoint shall support constrained eager loading of anime by year.
+ *
+ * @return void
+ */
+ public function testAnimeByYear(): void
+ {
+ $yearFilter = intval($this->faker->year());
+ $excludedYear = $yearFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_YEAR => $yearFilter,
+ ],
+ IncludeParser::param() => ExternalResource::RELATION_ANIME,
+ ];
+
+ ExternalResource::factory()
+ ->has(
+ Anime::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->state([
+ Anime::ATTRIBUTE_YEAR => $this->faker->boolean() ? $yearFilter : $excludedYear,
+ ])
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $resources = ExternalResource::with([
+ ExternalResource::RELATION_ANIME => function (BelongsToMany $query) use ($yearFilter) {
+ $query->where(Anime::ATTRIBUTE_YEAR, $yearFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.resource.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ExternalResourceCollection($resources, new ExternalResourceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/ExternalResource/ExternalResourceRestoreTest.php b/tests/Feature/Http/Api/Wiki/ExternalResource/ExternalResourceRestoreTest.php
new file mode 100644
index 000000000..b68d30ced
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/ExternalResource/ExternalResourceRestoreTest.php
@@ -0,0 +1,56 @@
+createOne();
+
+ $resource->delete();
+
+ $response = $this->patch(route('api.resource.restore', ['resource' => $resource]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The ExternalResource Restore Endpoint shall restore the resource.
+ *
+ * @return void
+ */
+ public function testRestored(): void
+ {
+ $resource = ExternalResource::factory()->createOne();
+
+ $resource->delete();
+
+ $user = User::factory()->withPermission('restore external resource')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->patch(route('api.resource.restore', ['resource' => $resource]));
+
+ $response->assertOk();
+ static::assertNotSoftDeleted($resource);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/ExternalResource/ExternalResourceShowTest.php b/tests/Feature/Http/Api/Wiki/ExternalResource/ExternalResourceShowTest.php
new file mode 100644
index 000000000..4a7f637c4
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/ExternalResource/ExternalResourceShowTest.php
@@ -0,0 +1,241 @@
+create();
+
+ $response = $this->get(route('api.resource.show', ['resource' => $resource]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ExternalResourceResource($resource, new ExternalResourceReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Resource Show Endpoint shall return an External Resource for soft deleted images.
+ *
+ * @return void
+ */
+ public function testSoftDelete(): void
+ {
+ $resource = ExternalResource::factory()->createOne();
+
+ $resource->delete();
+
+ $resource->unsetRelations();
+
+ $response = $this->get(route('api.resource.show', ['resource' => $resource]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ExternalResourceResource($resource, new ExternalResourceReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Resource Show Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new ExternalResourceSchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ $resource = ExternalResource::factory()
+ ->has(Anime::factory()->count($this->faker->randomDigitNotNull()))
+ ->has(Artist::factory()->count($this->faker->randomDigitNotNull()))
+ ->createOne();
+
+ $response = $this->get(route('api.resource.show', ['resource' => $resource] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ExternalResourceResource($resource, new ExternalResourceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Resource Show Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $schema = new ExternalResourceSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ ExternalResourceResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $resource = ExternalResource::factory()->create();
+
+ $response = $this->get(route('api.resource.show', ['resource' => $resource] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ExternalResourceResource($resource, new ExternalResourceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Resource Show Endpoint shall support constrained eager loading of anime by season.
+ *
+ * @return void
+ */
+ public function testAnimeBySeason(): void
+ {
+ $seasonFilter = AnimeSeason::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_SEASON => $seasonFilter->description,
+ ],
+ IncludeParser::param() => ExternalResource::RELATION_ANIME,
+ ];
+
+ $resource = ExternalResource::factory()
+ ->has(Anime::factory()->count($this->faker->randomDigitNotNull()))
+ ->createOne();
+
+ $resource->unsetRelations()->load([
+ ExternalResource::RELATION_ANIME => function (BelongsToMany $query) use ($seasonFilter) {
+ $query->where(Anime::ATTRIBUTE_SEASON, $seasonFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.resource.show', ['resource' => $resource] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ExternalResourceResource($resource, new ExternalResourceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Resource Show Endpoint shall support constrained eager loading of anime by year.
+ *
+ * @return void
+ */
+ public function testAnimeByYear(): void
+ {
+ $yearFilter = intval($this->faker->year());
+ $excludedYear = $yearFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_YEAR => $yearFilter,
+ ],
+ IncludeParser::param() => ExternalResource::RELATION_ANIME,
+ ];
+
+ $resource = ExternalResource::factory()
+ ->has(
+ Anime::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->state([
+ Anime::ATTRIBUTE_YEAR => $this->faker->boolean() ? $yearFilter : $excludedYear,
+ ])
+ )
+ ->createOne();
+
+ $resource->unsetRelations()->load([
+ ExternalResource::RELATION_ANIME => function (BelongsToMany $query) use ($yearFilter) {
+ $query->where(Anime::ATTRIBUTE_YEAR, $yearFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.resource.show', ['resource' => $resource] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ExternalResourceResource($resource, new ExternalResourceReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/ExternalResource/ExternalResourceStoreTest.php b/tests/Feature/Http/Api/Wiki/ExternalResource/ExternalResourceStoreTest.php
new file mode 100644
index 000000000..e29aa5127
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/ExternalResource/ExternalResourceStoreTest.php
@@ -0,0 +1,75 @@
+makeOne();
+
+ $response = $this->post(route('api.resource.store', $resource->toArray()));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The ExternalResource Store Endpoint shall require link & site fields.
+ *
+ * @return void
+ */
+ public function testRequiredFields(): void
+ {
+ $user = User::factory()->withPermission('create external resource')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.resource.store'));
+
+ $response->assertJsonValidationErrors([
+ ExternalResource::ATTRIBUTE_LINK,
+ ExternalResource::ATTRIBUTE_SITE,
+ ]);
+ }
+
+ /**
+ * The ExternalResource Store Endpoint shall create an resource.
+ *
+ * @return void
+ */
+ public function testCreate(): void
+ {
+ $parameters = array_merge(
+ ExternalResource::factory()->raw(),
+ [ExternalResource::ATTRIBUTE_SITE => ResourceSite::getDescription(ResourceSite::OFFICIAL_SITE)],
+ );
+
+ $user = User::factory()->withPermission('create external resource')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.resource.store', $parameters));
+
+ $response->assertCreated();
+ static::assertDatabaseCount(ExternalResource::TABLE, 1);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/ExternalResource/ExternalResourceUpdateTest.php b/tests/Feature/Http/Api/Wiki/ExternalResource/ExternalResourceUpdateTest.php
new file mode 100644
index 000000000..19756ceb5
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/ExternalResource/ExternalResourceUpdateTest.php
@@ -0,0 +1,64 @@
+createOne();
+
+ $parameters = array_merge(
+ ExternalResource::factory()->raw(),
+ [ExternalResource::ATTRIBUTE_SITE => ResourceSite::getDescription(ResourceSite::OFFICIAL_SITE)]
+ );
+
+ $response = $this->put(route('api.resource.update', ['resource' => $resource] + $parameters));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The ExternalResource Update Endpoint shall update a resource.
+ *
+ * @return void
+ */
+ public function testUpdate(): void
+ {
+ $resource = ExternalResource::factory()->createOne([
+ ExternalResource::ATTRIBUTE_SITE => ResourceSite::OFFICIAL_SITE,
+ ]);
+
+ $parameters = array_merge(
+ ExternalResource::factory()->raw(),
+ [ExternalResource::ATTRIBUTE_SITE => ResourceSite::getDescription(ResourceSite::OFFICIAL_SITE)]
+ );
+
+ $user = User::factory()->withPermission('update external resource')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->put(route('api.resource.update', ['resource' => $resource] + $parameters));
+
+ $response->assertOk();
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Image/ImageDestroyTest.php b/tests/Feature/Http/Api/Wiki/Image/ImageDestroyTest.php
new file mode 100644
index 000000000..335afbb2d
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Image/ImageDestroyTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.image.destroy', ['image' => $image]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Image Destroy Endpoint shall delete the image.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $image = Image::factory()->createOne();
+
+ $user = User::factory()->withPermission('delete image')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.image.destroy', ['image' => $image]));
+
+ $response->assertOk();
+ static::assertSoftDeleted($image);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Image/ImageForceDeleteTest.php b/tests/Feature/Http/Api/Wiki/Image/ImageForceDeleteTest.php
new file mode 100644
index 000000000..8e43c1cb1
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Image/ImageForceDeleteTest.php
@@ -0,0 +1,51 @@
+createOne();
+
+ $response = $this->delete(route('api.image.forceDelete', ['image' => $image]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Image Force Destroy Endpoint shall force delete the image.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $image = Image::factory()->createOne();
+
+ $user = User::factory()->withPermission('force delete image')->createOne();
+
+ Sanctum::actingAs($user);
+ $response = $this->delete(route('api.image.forceDelete', ['image' => $image]));
+
+ $response->assertOk();
+ static::assertModelMissing($image);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Image/ImageIndexTest.php b/tests/Feature/Http/Api/Wiki/Image/ImageIndexTest.php
new file mode 100644
index 000000000..4db201750
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Image/ImageIndexTest.php
@@ -0,0 +1,579 @@
+count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $response = $this->get(route('api.image.index'));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ImageCollection($images, new ImageReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Image Index Endpoint shall be paginated.
+ *
+ * @return void
+ */
+ public function testPaginated(): void
+ {
+ Image::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.image.index'));
+
+ $response->assertJsonStructure([
+ ImageCollection::$wrap,
+ 'links',
+ 'meta',
+ ]);
+ }
+
+ /**
+ * The Image Index Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new ImageSchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ Image::factory()
+ ->has(Anime::factory()->count($this->faker->randomDigitNotNull()))
+ ->has(Artist::factory()->count($this->faker->randomDigitNotNull()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $images = Image::with($includedPaths->all())->get();
+
+ $response = $this->get(route('api.image.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ImageCollection($images, new ImageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Image Index Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $schema = new ImageSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ ImageResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $images = Image::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $response = $this->get(route('api.image.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ImageCollection($images, new ImageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Image Index Endpoint shall support sorting resources.
+ *
+ * @return void
+ */
+ public function testSorts(): void
+ {
+ $schema = new ImageSchema();
+
+ $sort = collect($schema->fields())
+ ->filter(fn (Field $field) => $field instanceof SortableField)
+ ->map(fn (SortableField $field) => $field->getSort())
+ ->random();
+
+ $parameters = [
+ SortParser::param() => $sort->format(Direction::getRandomInstance()),
+ ];
+
+ $query = new ImageReadQuery($parameters);
+
+ Image::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.image.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ $query->collection($query->index())
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Image Index Endpoint shall support filtering by created_at.
+ *
+ * @return void
+ */
+ public function testCreatedAtFilter(): void
+ {
+ $createdFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_CREATED_AT => $createdFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($createdFilter, function () {
+ Image::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Image::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $image = Image::query()->where(BaseModel::ATTRIBUTE_CREATED_AT, $createdFilter)->get();
+
+ $response = $this->get(route('api.image.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ImageCollection($image, new ImageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Image Index Endpoint shall support filtering by updated_at.
+ *
+ * @return void
+ */
+ public function testUpdatedAtFilter(): void
+ {
+ $updatedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_UPDATED_AT => $updatedFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($updatedFilter, function () {
+ Image::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Image::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $image = Image::query()->where(BaseModel::ATTRIBUTE_UPDATED_AT, $updatedFilter)->get();
+
+ $response = $this->get(route('api.image.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ImageCollection($image, new ImageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Image Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithoutTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITHOUT,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Image::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteImage = Image::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteImage->each(function (Image $image) {
+ $image->delete();
+ });
+
+ $image = Image::withoutTrashed()->get();
+
+ $response = $this->get(route('api.image.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ImageCollection($image, new ImageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Image Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Image::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteImage = Image::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteImage->each(function (Image $image) {
+ $image->delete();
+ });
+
+ $image = Image::withTrashed()->get();
+
+ $response = $this->get(route('api.image.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ImageCollection($image, new ImageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Image Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testOnlyTrashedFilter(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::ONLY,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Image::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteImage = Image::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteImage->each(function (Image $image) {
+ $image->delete();
+ });
+
+ $image = Image::onlyTrashed()->get();
+
+ $response = $this->get(route('api.image.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ImageCollection($image, new ImageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Image Index Endpoint shall support filtering by deleted_at.
+ *
+ * @return void
+ */
+ public function testDeletedAtFilter(): void
+ {
+ $deletedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_DELETED_AT => $deletedFilter,
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($deletedFilter, function () {
+ $images = Image::factory()->count($this->faker->randomDigitNotNull())->create();
+ $images->each(function (Image $image) {
+ $image->delete();
+ });
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ $images = Image::factory()->count($this->faker->randomDigitNotNull())->create();
+ $images->each(function (Image $image) {
+ $image->delete();
+ });
+ });
+
+ $image = Image::withTrashed()->where(BaseModel::ATTRIBUTE_DELETED_AT, $deletedFilter)->get();
+
+ $response = $this->get(route('api.image.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ImageCollection($image, new ImageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Image Index Endpoint shall support filtering by facet.
+ *
+ * @return void
+ */
+ public function testFacetFilter(): void
+ {
+ $facetFilter = ImageFacet::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Image::ATTRIBUTE_FACET => $facetFilter->description,
+ ],
+ ];
+
+ Image::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $images = Image::query()->where(Image::ATTRIBUTE_FACET, $facetFilter->value)->get();
+
+ $response = $this->get(route('api.image.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ImageCollection($images, new ImageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Image Index Endpoint shall support constrained eager loading of anime by season.
+ *
+ * @return void
+ */
+ public function testAnimeBySeason(): void
+ {
+ $seasonFilter = AnimeSeason::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_SEASON => $seasonFilter->description,
+ ],
+ IncludeParser::param() => Image::RELATION_ANIME,
+ ];
+
+ Image::factory()
+ ->has(Anime::factory()->count($this->faker->randomDigitNotNull()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $images = Image::with([
+ Image::RELATION_ANIME => function (BelongsToMany $query) use ($seasonFilter) {
+ $query->where(Anime::ATTRIBUTE_SEASON, $seasonFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.image.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ImageCollection($images, new ImageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Image Index Endpoint shall support constrained eager loading of anime by year.
+ *
+ * @return void
+ */
+ public function testAnimeByYear(): void
+ {
+ $yearFilter = intval($this->faker->year());
+ $excludedYear = $yearFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_YEAR => $yearFilter,
+ ],
+ IncludeParser::param() => Image::RELATION_ANIME,
+ ];
+
+ Image::factory()
+ ->has(
+ Anime::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->state([
+ Anime::ATTRIBUTE_YEAR => $this->faker->boolean() ? $yearFilter : $excludedYear,
+ ])
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $images = Image::with([
+ Image::RELATION_ANIME => function (BelongsToMany $query) use ($yearFilter) {
+ $query->where(Anime::ATTRIBUTE_YEAR, $yearFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.image.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ImageCollection($images, new ImageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Image/ImageRestoreTest.php b/tests/Feature/Http/Api/Wiki/Image/ImageRestoreTest.php
new file mode 100644
index 000000000..4010135cd
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Image/ImageRestoreTest.php
@@ -0,0 +1,56 @@
+createOne();
+
+ $image->delete();
+
+ $response = $this->patch(route('api.image.restore', ['image' => $image]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Image Restore Endpoint shall restore the image.
+ *
+ * @return void
+ */
+ public function testRestored(): void
+ {
+ $image = Image::factory()->createOne();
+
+ $image->delete();
+
+ $user = User::factory()->withPermission('restore image')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->patch(route('api.image.restore', ['image' => $image]));
+
+ $response->assertOk();
+ static::assertNotSoftDeleted($image);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Image/ImageShowTest.php b/tests/Feature/Http/Api/Wiki/Image/ImageShowTest.php
new file mode 100644
index 000000000..bd0750126
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Image/ImageShowTest.php
@@ -0,0 +1,241 @@
+create();
+
+ $response = $this->get(route('api.image.show', ['image' => $image]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ImageResource($image, new ImageReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Image Show Endpoint shall return an Image Resource for soft deleted images.
+ *
+ * @return void
+ */
+ public function testSoftDelete(): void
+ {
+ $image = Image::factory()->createOne();
+
+ $image->delete();
+
+ $image->unsetRelations();
+
+ $response = $this->get(route('api.image.show', ['image' => $image]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ImageResource($image, new ImageReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Image Show Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new ImageSchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ $image = Image::factory()
+ ->has(Anime::factory()->count($this->faker->randomDigitNotNull()))
+ ->has(Artist::factory()->count($this->faker->randomDigitNotNull()))
+ ->createOne();
+
+ $response = $this->get(route('api.image.show', ['image' => $image] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ImageResource($image, new ImageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Image Show Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $schema = new ImageSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ ImageResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $image = Image::factory()->create();
+
+ $response = $this->get(route('api.image.show', ['image' => $image] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ImageResource($image, new ImageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Image Show Endpoint shall support constrained eager loading of anime by season.
+ *
+ * @return void
+ */
+ public function testAnimeBySeason(): void
+ {
+ $seasonFilter = AnimeSeason::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_SEASON => $seasonFilter->description,
+ ],
+ IncludeParser::param() => Image::RELATION_ANIME,
+ ];
+
+ $image = Image::factory()
+ ->has(Anime::factory()->count($this->faker->randomDigitNotNull()))
+ ->createOne();
+
+ $image->unsetRelations()->load([
+ Image::RELATION_ANIME => function (BelongsToMany $query) use ($seasonFilter) {
+ $query->where(Anime::ATTRIBUTE_SEASON, $seasonFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.image.show', ['image' => $image] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ImageResource($image, new ImageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Image Show Endpoint shall support constrained eager loading of anime by year.
+ *
+ * @return void
+ */
+ public function testAnimeByYear(): void
+ {
+ $yearFilter = intval($this->faker->year());
+ $excludedYear = $yearFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_YEAR => $yearFilter,
+ ],
+ IncludeParser::param() => Image::RELATION_ANIME,
+ ];
+
+ $image = Image::factory()
+ ->has(
+ Anime::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->state([
+ Anime::ATTRIBUTE_YEAR => $this->faker->boolean() ? $yearFilter : $excludedYear,
+ ])
+ )
+ ->createOne();
+
+ $image->unsetRelations()->load([
+ Image::RELATION_ANIME => function (BelongsToMany $query) use ($yearFilter) {
+ $query->where(Anime::ATTRIBUTE_YEAR, $yearFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.image.show', ['image' => $image] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new ImageResource($image, new ImageReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Image/ImageStoreTest.php b/tests/Feature/Http/Api/Wiki/Image/ImageStoreTest.php
new file mode 100644
index 000000000..83567207b
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Image/ImageStoreTest.php
@@ -0,0 +1,80 @@
+makeOne();
+
+ $response = $this->post(route('api.image.store', $image->toArray()));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Image Store Endpoint shall require the file field.
+ *
+ * @return void
+ */
+ public function testRequiredFields(): void
+ {
+ $user = User::factory()->withPermission('create image')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.image.store'));
+
+ $response->assertJsonValidationErrors([
+ ImageFileField::ATTRIBUTE_FILE,
+ ]);
+ }
+
+ /**
+ * The Image Store Endpoint shall create an image.
+ *
+ * @return void
+ */
+ public function testCreate(): void
+ {
+ Storage::fake('images');
+
+ $parameters = [Image::ATTRIBUTE_FACET => ImageFacet::getRandomInstance()->description];
+
+ $user = User::factory()->withPermission('create image')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.image.store', $parameters), [
+ ImageFileField::ATTRIBUTE_FILE => UploadedFile::fake()->image($this->faker->word().'.jpg'),
+ ]);
+
+ $response->assertCreated();
+ static::assertDatabaseCount(Image::TABLE, 1);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Image/ImageUpdateTest.php b/tests/Feature/Http/Api/Wiki/Image/ImageUpdateTest.php
new file mode 100644
index 000000000..98ef3b678
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Image/ImageUpdateTest.php
@@ -0,0 +1,62 @@
+createOne();
+
+ $parameters = array_merge(
+ Image::factory()->raw(),
+ [Image::ATTRIBUTE_FACET => ImageFacet::getRandomInstance()->description],
+ );
+
+ $response = $this->put(route('api.image.update', ['image' => $image] + $parameters));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Image Update Endpoint shall update an image.
+ *
+ * @return void
+ */
+ public function testUpdate(): void
+ {
+ $image = Image::factory()->createOne();
+
+ $parameters = array_merge(
+ Image::factory()->raw(),
+ [Image::ATTRIBUTE_FACET => ImageFacet::getRandomInstance()->description],
+ );
+
+ $user = User::factory()->withPermission('update image')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->put(route('api.image.update', ['image' => $image] + $parameters));
+
+ $response->assertOk();
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/SearchTest.php b/tests/Feature/Http/Api/Wiki/SearchTest.php
new file mode 100644
index 000000000..5073d21da
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/SearchTest.php
@@ -0,0 +1,113 @@
+get(route('api.search.show'));
+
+ $response->assertJsonValidationErrors(SearchParser::param());
+ }
+
+ /**
+ * The Search Endpoint shall display the Search attributes.
+ *
+ * @return void
+ */
+ public function testSearchAttributes(): void
+ {
+ $driver = Config::get('scout.driver');
+ if (empty($driver)) {
+ static::markTestSkipped('A driver must be configured for this test');
+ }
+
+ $q = $this->faker->word();
+
+ $parameters = [
+ SearchParser::param() => $q,
+ ];
+
+ $response = $this->get(route('api.search.show', $parameters));
+
+ $response->assertJson([
+ SearchResource::$wrap => [
+ AnimeCollection::$wrap => [],
+ ThemeCollection::$wrap => [],
+ ArtistCollection::$wrap => [],
+ SeriesCollection::$wrap => [],
+ SongCollection::$wrap => [],
+ StudioCollection::$wrap => [],
+ VideoCollection::$wrap => [],
+ ],
+ ]);
+ }
+
+ /**
+ * The Search Endpoint shall allow each resource to be included/excluded in a sparse fieldset.
+ *
+ * @return void
+ */
+ public function testSearchSparseFieldsets(): void
+ {
+ $driver = Config::get('scout.driver');
+ if (empty($driver)) {
+ static::markTestSkipped('A driver must be configured for this test');
+ }
+
+ $fields = [
+ AnimeCollection::$wrap,
+ ThemeCollection::$wrap,
+ ArtistCollection::$wrap,
+ SeriesCollection::$wrap,
+ SongCollection::$wrap,
+ StudioCollection::$wrap,
+ VideoCollection::$wrap,
+ ];
+
+ $includedFields = Arr::random($fields, $this->faker->numberBetween(1, count($fields)));
+
+ $q = $this->faker->word();
+
+ $parameters = [
+ SearchParser::param() => $q,
+ FieldParser::param() => [
+ SearchResource::$wrap => implode(',', $includedFields),
+ ],
+ ];
+
+ $response = $this->get(route('api.search.show', $parameters));
+
+ $response->assertJsonStructure([
+ SearchResource::$wrap => $includedFields,
+ ]);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Series/SeriesDestroyTest.php b/tests/Feature/Http/Api/Wiki/Series/SeriesDestroyTest.php
new file mode 100644
index 000000000..004298251
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Series/SeriesDestroyTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.series.destroy', ['series' => $series]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Series Destroy Endpoint shall delete the series.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $series = Series::factory()->createOne();
+
+ $user = User::factory()->withPermission('delete series')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.series.destroy', ['series' => $series]));
+
+ $response->assertOk();
+ static::assertSoftDeleted($series);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Series/SeriesForceDeleteTest.php b/tests/Feature/Http/Api/Wiki/Series/SeriesForceDeleteTest.php
new file mode 100644
index 000000000..d9921d304
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Series/SeriesForceDeleteTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.series.forceDelete', ['series' => $series]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Series Force Destroy Endpoint shall force delete the series.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $series = Series::factory()->createOne();
+
+ $user = User::factory()->withPermission('force delete series')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.series.forceDelete', ['series' => $series]));
+
+ $response->assertOk();
+ static::assertModelMissing($series);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Series/SeriesIndexTest.php b/tests/Feature/Http/Api/Wiki/Series/SeriesIndexTest.php
new file mode 100644
index 000000000..84c4ea6e8
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Series/SeriesIndexTest.php
@@ -0,0 +1,561 @@
+withoutEvents();
+
+ $series = Series::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.series.index'));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SeriesCollection($series, new SeriesReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Series Index Endpoint shall be paginated.
+ *
+ * @return void
+ */
+ public function testPaginated(): void
+ {
+ $this->withoutEvents();
+
+ Series::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.series.index'));
+
+ $response->assertJsonStructure([
+ SeriesCollection::$wrap,
+ 'links',
+ 'meta',
+ ]);
+ }
+
+ /**
+ * The Series Index Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new SeriesSchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ Series::factory()
+ ->has(Anime::factory()->count($this->faker->randomDigitNotNull()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $series = Series::with($includedPaths->all())->get();
+
+ $response = $this->get(route('api.series.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SeriesCollection($series, new SeriesReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Series Index Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $this->withoutEvents();
+
+ $schema = new SeriesSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ SeriesResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $series = Series::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.series.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SeriesCollection($series, new SeriesReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Series Index Endpoint shall support sorting resources.
+ *
+ * @return void
+ */
+ public function testSorts(): void
+ {
+ $this->withoutEvents();
+
+ $schema = new SeriesSchema();
+
+ $sort = collect($schema->fields())
+ ->filter(fn (Field $field) => $field instanceof SortableField)
+ ->map(fn (SortableField $field) => $field->getSort())
+ ->random();
+
+ $parameters = [
+ SortParser::param() => $sort->format(Direction::getRandomInstance()),
+ ];
+
+ $query = new SeriesReadQuery($parameters);
+
+ Series::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.series.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ $query->collection($query->index())
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Series Index Endpoint shall support filtering by created_at.
+ *
+ * @return void
+ */
+ public function testCreatedAtFilter(): void
+ {
+ $this->withoutEvents();
+
+ $createdFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_CREATED_AT => $createdFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($createdFilter, function () {
+ Series::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Series::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $series = Series::query()->where(BaseModel::ATTRIBUTE_CREATED_AT, $createdFilter)->get();
+
+ $response = $this->get(route('api.series.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SeriesCollection($series, new SeriesReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Series Index Endpoint shall support filtering by updated_at.
+ *
+ * @return void
+ */
+ public function testUpdatedAtFilter(): void
+ {
+ $this->withoutEvents();
+
+ $updatedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_UPDATED_AT => $updatedFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($updatedFilter, function () {
+ Series::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Series::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $series = Series::query()->where(BaseModel::ATTRIBUTE_UPDATED_AT, $updatedFilter)->get();
+
+ $response = $this->get(route('api.series.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SeriesCollection($series, new SeriesReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Series Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithoutTrashedFilter(): void
+ {
+ $this->withoutEvents();
+
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITHOUT,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Series::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteSeries = Series::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteSeries->each(function (Series $series) {
+ $series->delete();
+ });
+
+ $series = Series::withoutTrashed()->get();
+
+ $response = $this->get(route('api.series.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SeriesCollection($series, new SeriesReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Series Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithTrashedFilter(): void
+ {
+ $this->withoutEvents();
+
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Series::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteSeries = Series::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteSeries->each(function (Series $series) {
+ $series->delete();
+ });
+
+ $series = Series::withTrashed()->get();
+
+ $response = $this->get(route('api.series.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SeriesCollection($series, new SeriesReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Series Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testOnlyTrashedFilter(): void
+ {
+ $this->withoutEvents();
+
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::ONLY,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Series::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteSeries = Series::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteSeries->each(function (Series $series) {
+ $series->delete();
+ });
+
+ $series = Series::onlyTrashed()->get();
+
+ $response = $this->get(route('api.series.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SeriesCollection($series, new SeriesReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Series Index Endpoint shall support filtering by deleted_at.
+ *
+ * @return void
+ */
+ public function testDeletedAtFilter(): void
+ {
+ $this->withoutEvents();
+
+ $deletedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_DELETED_AT => $deletedFilter,
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($deletedFilter, function () {
+ $series = Series::factory()->count($this->faker->randomDigitNotNull())->create();
+ $series->each(function (Series $item) {
+ $item->delete();
+ });
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ $series = Series::factory()->count($this->faker->randomDigitNotNull())->create();
+ $series->each(function (Series $item) {
+ $item->delete();
+ });
+ });
+
+ $series = Series::withTrashed()->where(BaseModel::ATTRIBUTE_DELETED_AT, $deletedFilter)->get();
+
+ $response = $this->get(route('api.series.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SeriesCollection($series, new SeriesReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Series Index Endpoint shall support constrained eager loading of anime by season.
+ *
+ * @return void
+ */
+ public function testAnimeBySeason(): void
+ {
+ $this->withoutEvents();
+
+ $seasonFilter = AnimeSeason::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_SEASON => $seasonFilter->description,
+ ],
+ IncludeParser::param() => Series::RELATION_ANIME,
+ ];
+
+ Series::factory()
+ ->has(Anime::factory()->count($this->faker->randomDigitNotNull()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $series = Series::with([
+ Series::RELATION_ANIME => function (BelongsToMany $query) use ($seasonFilter) {
+ $query->where(Anime::ATTRIBUTE_SEASON, $seasonFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.series.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SeriesCollection($series, new SeriesReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Series Index Endpoint shall support constrained eager loading of anime by year.
+ *
+ * @return void
+ */
+ public function testAnimeByYear(): void
+ {
+ $this->withoutEvents();
+
+ $yearFilter = $this->faker->numberBetween(2000, 2002);
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_YEAR => $yearFilter,
+ ],
+ IncludeParser::param() => Series::RELATION_ANIME,
+ ];
+
+ Series::factory()
+ ->has(
+ Anime::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->state(new Sequence(
+ [Anime::ATTRIBUTE_YEAR => 2000],
+ [Anime::ATTRIBUTE_YEAR => 2001],
+ [Anime::ATTRIBUTE_YEAR => 2002],
+ ))
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $series = Series::with([
+ Series::RELATION_ANIME => function (BelongsToMany $query) use ($yearFilter) {
+ $query->where(Anime::ATTRIBUTE_YEAR, $yearFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.series.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SeriesCollection($series, new SeriesReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Series/SeriesRestoreTest.php b/tests/Feature/Http/Api/Wiki/Series/SeriesRestoreTest.php
new file mode 100644
index 000000000..ced63bd86
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Series/SeriesRestoreTest.php
@@ -0,0 +1,56 @@
+createOne();
+
+ $series->delete();
+
+ $response = $this->patch(route('api.series.restore', ['series' => $series]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Series Restore Endpoint shall restore the series.
+ *
+ * @return void
+ */
+ public function testRestored(): void
+ {
+ $series = Series::factory()->createOne();
+
+ $series->delete();
+
+ $user = User::factory()->withPermission('restore series')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->patch(route('api.series.restore', ['series' => $series]));
+
+ $response->assertOk();
+ static::assertNotSoftDeleted($series);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Series/SeriesShowTest.php b/tests/Feature/Http/Api/Wiki/Series/SeriesShowTest.php
new file mode 100644
index 000000000..a23312cfa
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Series/SeriesShowTest.php
@@ -0,0 +1,249 @@
+withoutEvents();
+
+ $series = Series::factory()->create();
+
+ $response = $this->get(route('api.series.show', ['series' => $series]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SeriesResource($series, new SeriesReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Series Show Endpoint shall return a Series Resource for soft deleted series.
+ *
+ * @return void
+ */
+ public function testSoftDelete(): void
+ {
+ $this->withoutEvents();
+
+ $series = Series::factory()->createOne();
+
+ $series->delete();
+
+ $series->unsetRelations();
+
+ $response = $this->get(route('api.series.show', ['series' => $series]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SeriesResource($series, new SeriesReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Series Show Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new SeriesSchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ $series = Series::factory()
+ ->has(Anime::factory()->count($this->faker->randomDigitNotNull()))
+ ->createOne();
+
+ $response = $this->get(route('api.series.show', ['series' => $series] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SeriesResource($series, new SeriesReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Series Show Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $this->withoutEvents();
+
+ $schema = new SeriesSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ SeriesResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $series = Series::factory()->create();
+
+ $response = $this->get(route('api.series.show', ['series' => $series] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SeriesResource($series, new SeriesReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Series Show Endpoint shall support constrained eager loading of anime by season.
+ *
+ * @return void
+ */
+ public function testAnimeBySeason(): void
+ {
+ $this->withoutEvents();
+
+ $seasonFilter = AnimeSeason::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_SEASON => $seasonFilter->description,
+ ],
+ IncludeParser::param() => Series::RELATION_ANIME,
+ ];
+
+ $series = Series::factory()
+ ->has(Anime::factory()->count($this->faker->randomDigitNotNull()))
+ ->createOne();
+
+ $series->unsetRelations()->load([
+ Series::RELATION_ANIME => function (BelongsToMany $query) use ($seasonFilter) {
+ $query->where(Anime::ATTRIBUTE_SEASON, $seasonFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.series.show', ['series' => $series] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SeriesResource($series, new SeriesReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Series Index Endpoint shall support constrained eager loading of anime by year.
+ *
+ * @return void
+ */
+ public function testAnimeByYear(): void
+ {
+ $this->withoutEvents();
+
+ $yearFilter = $this->faker->numberBetween(2000, 2002);
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_YEAR => $yearFilter,
+ ],
+ IncludeParser::param() => Series::RELATION_ANIME,
+ ];
+
+ $series = Series::factory()
+ ->has(
+ Anime::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->state(new Sequence(
+ [Anime::ATTRIBUTE_YEAR => 2000],
+ [Anime::ATTRIBUTE_YEAR => 2001],
+ [Anime::ATTRIBUTE_YEAR => 2002],
+ ))
+ )
+ ->createOne();
+
+ $series->unsetRelations()->load([
+ Series::RELATION_ANIME => function (BelongsToMany $query) use ($yearFilter) {
+ $query->where(Anime::ATTRIBUTE_YEAR, $yearFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.series.show', ['series' => $series] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SeriesResource($series, new SeriesReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Series/SeriesStoreTest.php b/tests/Feature/Http/Api/Wiki/Series/SeriesStoreTest.php
new file mode 100644
index 000000000..65077a689
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Series/SeriesStoreTest.php
@@ -0,0 +1,71 @@
+makeOne();
+
+ $response = $this->post(route('api.series.store', $series->toArray()));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Series Store Endpoint shall require name & slug fields.
+ *
+ * @return void
+ */
+ public function testRequiredFields(): void
+ {
+ $user = User::factory()->withPermission('create series')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.series.store'));
+
+ $response->assertJsonValidationErrors([
+ Series::ATTRIBUTE_NAME,
+ Series::ATTRIBUTE_SLUG,
+ ]);
+ }
+
+ /**
+ * The Series Store Endpoint shall create a series.
+ *
+ * @return void
+ */
+ public function testCreate(): void
+ {
+ $parameters = Series::factory()->raw();
+
+ $user = User::factory()->withPermission('create series')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.series.store', $parameters));
+
+ $response->assertCreated();
+ static::assertDatabaseCount(Series::TABLE, 1);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Series/SeriesUpdateTest.php b/tests/Feature/Http/Api/Wiki/Series/SeriesUpdateTest.php
new file mode 100644
index 000000000..9e5f8173d
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Series/SeriesUpdateTest.php
@@ -0,0 +1,55 @@
+createOne();
+
+ $parameters = Series::factory()->raw();
+
+ $response = $this->put(route('api.series.update', ['series' => $series] + $parameters));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Series Update Endpoint shall update a series.
+ *
+ * @return void
+ */
+ public function testUpdate(): void
+ {
+ $series = Series::factory()->createOne();
+
+ $parameters = Series::factory()->raw();
+
+ $user = User::factory()->withPermission('update series')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->put(route('api.series.update', ['series' => $series] + $parameters));
+
+ $response->assertOk();
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Song/SongDestroyTest.php b/tests/Feature/Http/Api/Wiki/Song/SongDestroyTest.php
new file mode 100644
index 000000000..be14f2fa4
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Song/SongDestroyTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.song.destroy', ['song' => $song]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Song Destroy Endpoint shall delete the song.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $song = Song::factory()->createOne();
+
+ $user = User::factory()->withPermission('delete song')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.song.destroy', ['song' => $song]));
+
+ $response->assertOk();
+ static::assertSoftDeleted($song);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Song/SongForceDeleteTest.php b/tests/Feature/Http/Api/Wiki/Song/SongForceDeleteTest.php
new file mode 100644
index 000000000..09b4aef5c
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Song/SongForceDeleteTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.song.forceDelete', ['song' => $song]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Song Force Destroy Endpoint shall force delete the song.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $song = Song::factory()->createOne();
+
+ $user = User::factory()->withPermission('force delete song')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.song.forceDelete', ['song' => $song]));
+
+ $response->assertOk();
+ static::assertModelMissing($song);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Song/SongIndexTest.php b/tests/Feature/Http/Api/Wiki/Song/SongIndexTest.php
new file mode 100644
index 000000000..962e67444
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Song/SongIndexTest.php
@@ -0,0 +1,706 @@
+withoutEvents();
+
+ $songs = Song::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.song.index'));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongCollection($songs, new SongReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Index Endpoint shall be paginated.
+ *
+ * @return void
+ */
+ public function testPaginated(): void
+ {
+ $this->withoutEvents();
+
+ Song::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.song.index'));
+
+ $response->assertJsonStructure([
+ SongCollection::$wrap,
+ 'links',
+ 'meta',
+ ]);
+ }
+
+ /**
+ * The Series Index Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new SongSchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ Song::factory()
+ ->has(AnimeTheme::factory()->count($this->faker->randomDigitNotNull())->for(Anime::factory()))
+ ->has(Artist::factory()->count($this->faker->randomDigitNotNull()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $songs = Song::with($includedPaths->all())->get();
+
+ $response = $this->get(route('api.song.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongCollection($songs, new SongReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Index Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $this->withoutEvents();
+
+ $schema = new SongSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ SongResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $songs = Song::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.song.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongCollection($songs, new SongReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Index Endpoint shall support sorting resources.
+ *
+ * @return void
+ */
+ public function testSorts(): void
+ {
+ $schema = new SongSchema();
+
+ $sort = collect($schema->fields())
+ ->filter(fn (Field $field) => $field instanceof SortableField)
+ ->map(fn (SortableField $field) => $field->getSort())
+ ->random();
+
+ $parameters = [
+ SortParser::param() => $sort->format(Direction::getRandomInstance()),
+ ];
+
+ $query = new SongReadQuery($parameters);
+
+ Song::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.song.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ $query->collection($query->index())
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Index Endpoint shall support filtering by created_at.
+ *
+ * @return void
+ */
+ public function testCreatedAtFilter(): void
+ {
+ $this->withoutEvents();
+
+ $createdFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_CREATED_AT => $createdFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($createdFilter, function () {
+ Song::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Song::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $song = Song::query()->where(BaseModel::ATTRIBUTE_CREATED_AT, $createdFilter)->get();
+
+ $response = $this->get(route('api.song.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongCollection($song, new SongReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Index Endpoint shall support filtering by updated_at.
+ *
+ * @return void
+ */
+ public function testUpdatedAtFilter(): void
+ {
+ $this->withoutEvents();
+
+ $updatedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_UPDATED_AT => $updatedFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($updatedFilter, function () {
+ Song::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Song::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $song = Song::query()->where(BaseModel::ATTRIBUTE_UPDATED_AT, $updatedFilter)->get();
+
+ $response = $this->get(route('api.song.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongCollection($song, new SongReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithoutTrashedFilter(): void
+ {
+ $this->withoutEvents();
+
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITHOUT,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Song::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteSong = Song::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteSong->each(function (Song $song) {
+ $song->delete();
+ });
+
+ $song = Song::withoutTrashed()->get();
+
+ $response = $this->get(route('api.song.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongCollection($song, new SongReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithTrashedFilter(): void
+ {
+ $this->withoutEvents();
+
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Song::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteSong = Song::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteSong->each(function (Song $song) {
+ $song->delete();
+ });
+
+ $song = Song::withTrashed()->get();
+
+ $response = $this->get(route('api.song.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongCollection($song, new SongReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testOnlyTrashedFilter(): void
+ {
+ $this->withoutEvents();
+
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::ONLY,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Song::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteSong = Song::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteSong->each(function (Song $song) {
+ $song->delete();
+ });
+
+ $song = Song::onlyTrashed()->get();
+
+ $response = $this->get(route('api.song.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongCollection($song, new SongReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Index Endpoint shall support filtering by deleted_at.
+ *
+ * @return void
+ */
+ public function testDeletedAtFilter(): void
+ {
+ $this->withoutEvents();
+
+ $deletedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_DELETED_AT => $deletedFilter,
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($deletedFilter, function () {
+ $songs = Song::factory()->count($this->faker->randomDigitNotNull())->create();
+ $songs->each(function (Song $song) {
+ $song->delete();
+ });
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ $songs = Song::factory()->count($this->faker->randomDigitNotNull())->create();
+ $songs->each(function (Song $song) {
+ $song->delete();
+ });
+ });
+
+ $songs = Song::withTrashed()->where(BaseModel::ATTRIBUTE_DELETED_AT, $deletedFilter)->get();
+
+ $response = $this->get(route('api.song.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongCollection($songs, new SongReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Index Endpoint shall support constrained eager loading of themes by group.
+ *
+ * @return void
+ */
+ public function testThemesByGroup(): void
+ {
+ $groupFilter = $this->faker->word();
+ $excludedGroup = $this->faker->word();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_GROUP => $groupFilter,
+ ],
+ IncludeParser::param() => Song::RELATION_ANIMETHEMES,
+ ];
+
+ Song::factory()
+ ->has(
+ AnimeTheme::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(Anime::factory())
+ ->state(new Sequence(
+ [AnimeTheme::ATTRIBUTE_GROUP => $groupFilter],
+ [AnimeTheme::ATTRIBUTE_GROUP => $excludedGroup],
+ ))
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $songs = Song::with([
+ Song::RELATION_ANIMETHEMES => function (HasMany $query) use ($groupFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_GROUP, $groupFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.song.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongCollection($songs, new SongReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Index Endpoint shall support constrained eager loading of themes by sequence.
+ *
+ * @return void
+ */
+ public function testThemesBySequence(): void
+ {
+ $sequenceFilter = $this->faker->randomDigitNotNull();
+ $excludedSequence = $sequenceFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_SEQUENCE => $sequenceFilter,
+ ],
+ IncludeParser::param() => Song::RELATION_ANIMETHEMES,
+ ];
+
+ Song::factory()
+ ->has(
+ AnimeTheme::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(Anime::factory())
+ ->state(new Sequence(
+ [AnimeTheme::ATTRIBUTE_SEQUENCE => $sequenceFilter],
+ [AnimeTheme::ATTRIBUTE_SEQUENCE => $excludedSequence],
+ ))
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $songs = Song::with([
+ Song::RELATION_ANIMETHEMES => function (HasMany $query) use ($sequenceFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_SEQUENCE, $sequenceFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.song.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongCollection($songs, new SongReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Index Endpoint shall support constrained eager loading of themes by type.
+ *
+ * @return void
+ */
+ public function testThemesByType(): void
+ {
+ $typeFilter = ThemeType::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_TYPE => $typeFilter->description,
+ ],
+ IncludeParser::param() => Song::RELATION_ANIMETHEMES,
+ ];
+
+ Song::factory()
+ ->has(AnimeTheme::factory()->count($this->faker->randomDigitNotNull())->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $songs = Song::with([
+ Song::RELATION_ANIMETHEMES => function (HasMany $query) use ($typeFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_TYPE, $typeFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.song.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongCollection($songs, new SongReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Index Endpoint shall support constrained eager loading of anime by season.
+ *
+ * @return void
+ */
+ public function testAnimeBySeason(): void
+ {
+ $seasonFilter = AnimeSeason::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_SEASON => $seasonFilter->description,
+ ],
+ IncludeParser::param() => Song::RELATION_ANIME,
+ ];
+
+ Song::factory()
+ ->has(AnimeTheme::factory()->count($this->faker->randomDigitNotNull())->for(Anime::factory()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $songs = Song::with([
+ Song::RELATION_ANIME => function (BelongsTo $query) use ($seasonFilter) {
+ $query->where(Anime::ATTRIBUTE_SEASON, $seasonFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.song.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongCollection($songs, new SongReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Index Endpoint shall support constrained eager loading of anime by year.
+ *
+ * @return void
+ */
+ public function testAnimeByYear(): void
+ {
+ $yearFilter = intval($this->faker->year());
+ $excludedYear = $yearFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_YEAR => $yearFilter,
+ ],
+ IncludeParser::param() => Song::RELATION_ANIME,
+ ];
+
+ Song::factory()
+ ->has(
+ AnimeTheme::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(
+ Anime::factory()
+ ->state([
+ Anime::ATTRIBUTE_YEAR => $this->faker->boolean() ? $yearFilter : $excludedYear,
+ ])
+ )
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $songs = Song::with([
+ Song::RELATION_ANIME => function (BelongsTo $query) use ($yearFilter) {
+ $query->where(Anime::ATTRIBUTE_YEAR, $yearFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.song.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongCollection($songs, new SongReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Song/SongRestoreTest.php b/tests/Feature/Http/Api/Wiki/Song/SongRestoreTest.php
new file mode 100644
index 000000000..150e5aad5
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Song/SongRestoreTest.php
@@ -0,0 +1,56 @@
+createOne();
+
+ $song->delete();
+
+ $response = $this->patch(route('api.song.restore', ['song' => $song]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Song Restore Endpoint shall restore the song.
+ *
+ * @return void
+ */
+ public function testRestored(): void
+ {
+ $song = Song::factory()->createOne();
+
+ $song->delete();
+
+ $user = User::factory()->withPermission('restore song')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->patch(route('api.song.restore', ['song' => $song]));
+
+ $response->assertOk();
+ static::assertNotSoftDeleted($song);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Song/SongShowTest.php b/tests/Feature/Http/Api/Wiki/Song/SongShowTest.php
new file mode 100644
index 000000000..59ec5e0ab
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Song/SongShowTest.php
@@ -0,0 +1,390 @@
+withoutEvents();
+
+ $song = Song::factory()->create();
+
+ $response = $this->get(route('api.song.show', ['song' => $song]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongResource($song, new SongReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Show Endpoint shall return a Song Resource for soft deleted songs.
+ *
+ * @return void
+ */
+ public function testSoftDelete(): void
+ {
+ $this->withoutEvents();
+
+ $song = Song::factory()->createOne();
+
+ $song->delete();
+
+ $song->unsetRelations();
+
+ $response = $this->get(route('api.song.show', ['song' => $song]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongResource($song, new SongReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Show Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new SongSchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ $song = Song::factory()
+ ->has(AnimeTheme::factory()->count($this->faker->randomDigitNotNull())->for(Anime::factory()))
+ ->has(Artist::factory()->count($this->faker->randomDigitNotNull()))
+ ->createOne();
+
+ $response = $this->get(route('api.song.show', ['song' => $song] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongResource($song, new SongReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Show Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $this->withoutEvents();
+
+ $schema = new SongSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ SongResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $song = Song::factory()->create();
+
+ $response = $this->get(route('api.song.show', ['song' => $song] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongResource($song, new SongReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Show Endpoint shall support constrained eager loading of themes by group.
+ *
+ * @return void
+ */
+ public function testThemesByGroup(): void
+ {
+ $groupFilter = $this->faker->word();
+ $excludedGroup = $this->faker->word();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_GROUP => $groupFilter,
+ ],
+ IncludeParser::param() => Song::RELATION_ANIMETHEMES,
+ ];
+
+ $song = Song::factory()
+ ->has(
+ AnimeTheme::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(Anime::factory())
+ ->state(new Sequence(
+ [AnimeTheme::ATTRIBUTE_GROUP => $groupFilter],
+ [AnimeTheme::ATTRIBUTE_GROUP => $excludedGroup],
+ ))
+ )
+ ->createOne();
+
+ $song->unsetRelations()->load([
+ Song::RELATION_ANIMETHEMES => function (HasMany $query) use ($groupFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_GROUP, $groupFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.song.show', ['song' => $song] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongResource($song, new SongReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Show Endpoint shall support constrained eager loading of themes by sequence.
+ *
+ * @return void
+ */
+ public function testThemesBySequence(): void
+ {
+ $sequenceFilter = $this->faker->randomDigitNotNull();
+ $excludedSequence = $sequenceFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_SEQUENCE => $sequenceFilter,
+ ],
+ IncludeParser::param() => Song::RELATION_ANIMETHEMES,
+ ];
+
+ $song = Song::factory()
+ ->has(
+ AnimeTheme::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(Anime::factory())
+ ->state(new Sequence(
+ [AnimeTheme::ATTRIBUTE_SEQUENCE => $sequenceFilter],
+ [AnimeTheme::ATTRIBUTE_SEQUENCE => $excludedSequence],
+ ))
+ )
+ ->createOne();
+
+ $song->unsetRelations()->load([
+ Song::RELATION_ANIMETHEMES => function (HasMany $query) use ($sequenceFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_SEQUENCE, $sequenceFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.song.show', ['song' => $song] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongResource($song, new SongReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Show Endpoint shall support constrained eager loading of themes by type.
+ *
+ * @return void
+ */
+ public function testThemesByType(): void
+ {
+ $typeFilter = ThemeType::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_TYPE => $typeFilter->description,
+ ],
+ IncludeParser::param() => Song::RELATION_ANIMETHEMES,
+ ];
+
+ $song = Song::factory()
+ ->has(AnimeTheme::factory()->count($this->faker->randomDigitNotNull())->for(Anime::factory()))
+ ->createOne();
+
+ $song->unsetRelations()->load([
+ Song::RELATION_ANIMETHEMES => function (HasMany $query) use ($typeFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_TYPE, $typeFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.song.show', ['song' => $song] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongResource($song, new SongReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Show Endpoint shall support constrained eager loading of anime by season.
+ *
+ * @return void
+ */
+ public function testAnimeBySeason(): void
+ {
+ $seasonFilter = AnimeSeason::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_SEASON => $seasonFilter->description,
+ ],
+ IncludeParser::param() => Song::RELATION_ANIME,
+ ];
+
+ $song = Song::factory()
+ ->has(AnimeTheme::factory()->count($this->faker->randomDigitNotNull())->for(Anime::factory()))
+ ->createOne();
+
+ $song->unsetRelations()->load([
+ Song::RELATION_ANIME => function (BelongsTo $query) use ($seasonFilter) {
+ $query->where(Anime::ATTRIBUTE_SEASON, $seasonFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.song.show', ['song' => $song] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongResource($song, new SongReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Song Show Endpoint shall support constrained eager loading of anime by year.
+ *
+ * @return void
+ */
+ public function testAnimeByYear(): void
+ {
+ $yearFilter = intval($this->faker->year());
+ $excludedYear = $yearFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_YEAR => $yearFilter,
+ ],
+ IncludeParser::param() => Song::RELATION_ANIME,
+ ];
+
+ $song = Song::factory()
+ ->has(
+ AnimeTheme::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(
+ Anime::factory()
+ ->state([
+ Anime::ATTRIBUTE_YEAR => $this->faker->boolean() ? $yearFilter : $excludedYear,
+ ])
+ )
+ )
+ ->createOne();
+
+ $song->unsetRelations()->load([
+ Song::RELATION_ANIME => function (BelongsTo $query) use ($yearFilter) {
+ $query->where(Anime::ATTRIBUTE_YEAR, $yearFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.song.show', ['song' => $song] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new SongResource($song, new SongReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Song/SongStoreTest.php b/tests/Feature/Http/Api/Wiki/Song/SongStoreTest.php
new file mode 100644
index 000000000..c8ac1f7ab
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Song/SongStoreTest.php
@@ -0,0 +1,52 @@
+makeOne();
+
+ $response = $this->post(route('api.song.store', $song->toArray()));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Song Store Endpoint shall create a song.
+ *
+ * @return void
+ */
+ public function testCreate(): void
+ {
+ $parameters = Song::factory()->raw();
+
+ $user = User::factory()->withPermission('create song')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.song.store', $parameters));
+
+ $response->assertCreated();
+ static::assertDatabaseCount(Song::TABLE, 1);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Song/SongUpdateTest.php b/tests/Feature/Http/Api/Wiki/Song/SongUpdateTest.php
new file mode 100644
index 000000000..8ada75d78
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Song/SongUpdateTest.php
@@ -0,0 +1,55 @@
+createOne();
+
+ $parameters = Song::factory()->raw();
+
+ $response = $this->put(route('api.song.update', ['song' => $song] + $parameters));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Song Update Endpoint shall update a song.
+ *
+ * @return void
+ */
+ public function testUpdate(): void
+ {
+ $song = Song::factory()->createOne();
+
+ $parameters = Song::factory()->raw();
+
+ $user = User::factory()->withPermission('update song')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->put(route('api.song.update', ['song' => $song] + $parameters));
+
+ $response->assertOk();
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Studio/StudioDestroyTest.php b/tests/Feature/Http/Api/Wiki/Studio/StudioDestroyTest.php
new file mode 100644
index 000000000..cf1a74312
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Studio/StudioDestroyTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.studio.destroy', ['studio' => $studio]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Studio Destroy Endpoint shall delete the studio.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $studio = Studio::factory()->createOne();
+
+ $user = User::factory()->withPermission('delete studio')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.studio.destroy', ['studio' => $studio]));
+
+ $response->assertOk();
+ static::assertSoftDeleted($studio);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Studio/StudioForceDeleteTest.php b/tests/Feature/Http/Api/Wiki/Studio/StudioForceDeleteTest.php
new file mode 100644
index 000000000..d50d6a41a
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Studio/StudioForceDeleteTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.studio.forceDelete', ['studio' => $studio]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Studio Force Destroy Endpoint shall force delete the studio.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $studio = Studio::factory()->createOne();
+
+ $user = User::factory()->withPermission('force delete studio')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.studio.forceDelete', ['studio' => $studio]));
+
+ $response->assertOk();
+ static::assertModelMissing($studio);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Studio/StudioIndexTest.php b/tests/Feature/Http/Api/Wiki/Studio/StudioIndexTest.php
new file mode 100644
index 000000000..aaf7da492
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Studio/StudioIndexTest.php
@@ -0,0 +1,561 @@
+withoutEvents();
+
+ $studio = Studio::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.studio.index'));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new StudioCollection($studio, new StudioReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Studio Index Endpoint shall be paginated.
+ *
+ * @return void
+ */
+ public function testPaginated(): void
+ {
+ $this->withoutEvents();
+
+ Studio::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.studio.index'));
+
+ $response->assertJsonStructure([
+ StudioCollection::$wrap,
+ 'links',
+ 'meta',
+ ]);
+ }
+
+ /**
+ * The Studio Index Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new StudioSchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ Studio::factory()
+ ->has(Anime::factory()->count($this->faker->randomDigitNotNull()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $studio = Studio::with($includedPaths->all())->get();
+
+ $response = $this->get(route('api.studio.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new StudioCollection($studio, new StudioReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Studio Index Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $this->withoutEvents();
+
+ $schema = new StudioSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ StudioResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $studio = Studio::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.studio.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new StudioCollection($studio, new StudioReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Studio Index Endpoint shall support sorting resources.
+ *
+ * @return void
+ */
+ public function testSorts(): void
+ {
+ $this->withoutEvents();
+
+ $schema = new StudioSchema();
+
+ $sort = collect($schema->fields())
+ ->filter(fn (Field $field) => $field instanceof SortableField)
+ ->map(fn (SortableField $field) => $field->getSort())
+ ->random();
+
+ $parameters = [
+ SortParser::param() => $sort->format(Direction::getRandomInstance()),
+ ];
+
+ $query = new StudioReadQuery($parameters);
+
+ Studio::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('api.studio.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ $query->collection($query->index())
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Studio Index Endpoint shall support filtering by created_at.
+ *
+ * @return void
+ */
+ public function testCreatedAtFilter(): void
+ {
+ $this->withoutEvents();
+
+ $createdFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_CREATED_AT => $createdFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($createdFilter, function () {
+ Studio::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Studio::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $studio = Studio::query()->where(BaseModel::ATTRIBUTE_CREATED_AT, $createdFilter)->get();
+
+ $response = $this->get(route('api.studio.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new StudioCollection($studio, new StudioReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Studio Index Endpoint shall support filtering by updated_at.
+ *
+ * @return void
+ */
+ public function testUpdatedAtFilter(): void
+ {
+ $this->withoutEvents();
+
+ $updatedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_UPDATED_AT => $updatedFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($updatedFilter, function () {
+ Studio::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Studio::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $studio = Studio::query()->where(BaseModel::ATTRIBUTE_UPDATED_AT, $updatedFilter)->get();
+
+ $response = $this->get(route('api.studio.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new StudioCollection($studio, new StudioReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Studio Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithoutTrashedFilter(): void
+ {
+ $this->withoutEvents();
+
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITHOUT,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Studio::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteStudio = Studio::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteStudio->each(function (Studio $studio) {
+ $studio->delete();
+ });
+
+ $studio = Studio::withoutTrashed()->get();
+
+ $response = $this->get(route('api.studio.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new StudioCollection($studio, new StudioReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Studio Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithTrashedFilter(): void
+ {
+ $this->withoutEvents();
+
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Studio::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteStudio = Studio::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteStudio->each(function (Studio $studio) {
+ $studio->delete();
+ });
+
+ $studio = Studio::withTrashed()->get();
+
+ $response = $this->get(route('api.studio.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new StudioCollection($studio, new StudioReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Studio Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testOnlyTrashedFilter(): void
+ {
+ $this->withoutEvents();
+
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::ONLY,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Studio::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteStudio = Studio::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteStudio->each(function (Studio $studio) {
+ $studio->delete();
+ });
+
+ $studio = Studio::onlyTrashed()->get();
+
+ $response = $this->get(route('api.studio.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new StudioCollection($studio, new StudioReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Studio Index Endpoint shall support filtering by deleted_at.
+ *
+ * @return void
+ */
+ public function testDeletedAtFilter(): void
+ {
+ $this->withoutEvents();
+
+ $deletedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_DELETED_AT => $deletedFilter,
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($deletedFilter, function () {
+ $studio = Studio::factory()->count($this->faker->randomDigitNotNull())->create();
+ $studio->each(function (Studio $item) {
+ $item->delete();
+ });
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ $studio = Studio::factory()->count($this->faker->randomDigitNotNull())->create();
+ $studio->each(function (Studio $item) {
+ $item->delete();
+ });
+ });
+
+ $studio = Studio::withTrashed()->where(BaseModel::ATTRIBUTE_DELETED_AT, $deletedFilter)->get();
+
+ $response = $this->get(route('api.studio.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new StudioCollection($studio, new StudioReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Studio Index Endpoint shall support constrained eager loading of anime by season.
+ *
+ * @return void
+ */
+ public function testAnimeBySeason(): void
+ {
+ $this->withoutEvents();
+
+ $seasonFilter = AnimeSeason::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_SEASON => $seasonFilter->description,
+ ],
+ IncludeParser::param() => Studio::RELATION_ANIME,
+ ];
+
+ Studio::factory()
+ ->has(Anime::factory()->count($this->faker->randomDigitNotNull()))
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $studio = Studio::with([
+ Studio::RELATION_ANIME => function (BelongsToMany $query) use ($seasonFilter) {
+ $query->where(Anime::ATTRIBUTE_SEASON, $seasonFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.studio.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new StudioCollection($studio, new StudioReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Studio Index Endpoint shall support constrained eager loading of anime by year.
+ *
+ * @return void
+ */
+ public function testAnimeByYear(): void
+ {
+ $this->withoutEvents();
+
+ $yearFilter = $this->faker->numberBetween(2000, 2002);
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_YEAR => $yearFilter,
+ ],
+ IncludeParser::param() => Studio::RELATION_ANIME,
+ ];
+
+ Studio::factory()
+ ->has(
+ Anime::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->state(new Sequence(
+ [Anime::ATTRIBUTE_YEAR => 2000],
+ [Anime::ATTRIBUTE_YEAR => 2001],
+ [Anime::ATTRIBUTE_YEAR => 2002],
+ ))
+ )
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $studio = Studio::with([
+ Studio::RELATION_ANIME => function (BelongsToMany $query) use ($yearFilter) {
+ $query->where(Anime::ATTRIBUTE_YEAR, $yearFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.studio.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new StudioCollection($studio, new StudioReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Studio/StudioRestoreTest.php b/tests/Feature/Http/Api/Wiki/Studio/StudioRestoreTest.php
new file mode 100644
index 000000000..bcbd02a6b
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Studio/StudioRestoreTest.php
@@ -0,0 +1,56 @@
+createOne();
+
+ $studio->delete();
+
+ $response = $this->patch(route('api.studio.restore', ['studio' => $studio]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Studio Restore Endpoint shall restore the studio.
+ *
+ * @return void
+ */
+ public function testRestored(): void
+ {
+ $studio = Studio::factory()->createOne();
+
+ $studio->delete();
+
+ $user = User::factory()->withPermission('restore studio')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->patch(route('api.studio.restore', ['studio' => $studio]));
+
+ $response->assertOk();
+ static::assertNotSoftDeleted($studio);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Studio/StudioShowTest.php b/tests/Feature/Http/Api/Wiki/Studio/StudioShowTest.php
new file mode 100644
index 000000000..28bf7e0b0
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Studio/StudioShowTest.php
@@ -0,0 +1,249 @@
+withoutEvents();
+
+ $studio = Studio::factory()->create();
+
+ $response = $this->get(route('api.studio.show', ['studio' => $studio]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new StudioResource($studio, new StudioReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Studio Show Endpoint shall return a Studio Resource for soft deleted studios.
+ *
+ * @return void
+ */
+ public function testSoftDelete(): void
+ {
+ $this->withoutEvents();
+
+ $studio = Studio::factory()->createOne();
+
+ $studio->delete();
+
+ $studio->unsetRelations();
+
+ $response = $this->get(route('api.studio.show', ['studio' => $studio]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new StudioResource($studio, new StudioReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Studio Show Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new StudioSchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ $studio = Studio::factory()
+ ->has(Anime::factory()->count($this->faker->randomDigitNotNull()))
+ ->createOne();
+
+ $response = $this->get(route('api.studio.show', ['studio' => $studio] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new StudioResource($studio, new StudioReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Studio Show Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $this->withoutEvents();
+
+ $schema = new StudioSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ StudioResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $studio = Studio::factory()->create();
+
+ $response = $this->get(route('api.studio.show', ['studio' => $studio] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new StudioResource($studio, new StudioReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Studio Show Endpoint shall support constrained eager loading of anime by season.
+ *
+ * @return void
+ */
+ public function testAnimeBySeason(): void
+ {
+ $this->withoutEvents();
+
+ $seasonFilter = AnimeSeason::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_SEASON => $seasonFilter->description,
+ ],
+ IncludeParser::param() => Studio::RELATION_ANIME,
+ ];
+
+ $studio = Studio::factory()
+ ->has(Anime::factory()->count($this->faker->randomDigitNotNull()))
+ ->create();
+
+ $studio->unsetRelations()->load([
+ Studio::RELATION_ANIME => function (BelongsToMany $query) use ($seasonFilter) {
+ $query->where(Anime::ATTRIBUTE_SEASON, $seasonFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.studio.show', ['studio' => $studio] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new StudioResource($studio, new StudioReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Studio Index Endpoint shall support constrained eager loading of anime by year.
+ *
+ * @return void
+ */
+ public function testAnimeByYear(): void
+ {
+ $this->withoutEvents();
+
+ $yearFilter = $this->faker->numberBetween(2000, 2002);
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_YEAR => $yearFilter,
+ ],
+ IncludeParser::param() => Studio::RELATION_ANIME,
+ ];
+
+ $studio = Studio::factory()
+ ->has(
+ Anime::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->state(new Sequence(
+ [Anime::ATTRIBUTE_YEAR => 2000],
+ [Anime::ATTRIBUTE_YEAR => 2001],
+ [Anime::ATTRIBUTE_YEAR => 2002],
+ ))
+ )
+ ->createOne();
+
+ $studio->unsetRelations()->load([
+ Studio::RELATION_ANIME => function (BelongsToMany $query) use ($yearFilter) {
+ $query->where(Anime::ATTRIBUTE_YEAR, $yearFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.studio.show', ['studio' => $studio] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new StudioResource($studio, new StudioReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Studio/StudioStoreTest.php b/tests/Feature/Http/Api/Wiki/Studio/StudioStoreTest.php
new file mode 100644
index 000000000..c7d93a2cd
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Studio/StudioStoreTest.php
@@ -0,0 +1,71 @@
+makeOne();
+
+ $response = $this->post(route('api.studio.store', $studio->toArray()));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Studio Store Endpoint shall require name & slug fields.
+ *
+ * @return void
+ */
+ public function testRequiredFields(): void
+ {
+ $user = User::factory()->withPermission('create studio')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.studio.store'));
+
+ $response->assertJsonValidationErrors([
+ Studio::ATTRIBUTE_NAME,
+ Studio::ATTRIBUTE_SLUG,
+ ]);
+ }
+
+ /**
+ * The Studio Store Endpoint shall create a studio.
+ *
+ * @return void
+ */
+ public function testCreate(): void
+ {
+ $parameters = Studio::factory()->raw();
+
+ $user = User::factory()->withPermission('create studio')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.studio.store', $parameters));
+
+ $response->assertCreated();
+ static::assertDatabaseCount(Studio::TABLE, 1);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Studio/StudioUpdateTest.php b/tests/Feature/Http/Api/Wiki/Studio/StudioUpdateTest.php
new file mode 100644
index 000000000..645cce4a4
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Studio/StudioUpdateTest.php
@@ -0,0 +1,55 @@
+createOne();
+
+ $parameters = Studio::factory()->raw();
+
+ $response = $this->put(route('api.studio.update', ['studio' => $studio] + $parameters));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Studio Update Endpoint shall update a studio.
+ *
+ * @return void
+ */
+ public function testUpdate(): void
+ {
+ $studio = Studio::factory()->createOne();
+
+ $parameters = Studio::factory()->raw();
+
+ $user = User::factory()->withPermission('update studio')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->put(route('api.studio.update', ['studio' => $studio] + $parameters));
+
+ $response->assertOk();
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Video/VideoDestroyTest.php b/tests/Feature/Http/Api/Wiki/Video/VideoDestroyTest.php
new file mode 100644
index 000000000..28b19ae79
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Video/VideoDestroyTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.video.destroy', ['video' => $video]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Video Destroy Endpoint shall delete the video.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $video = Video::factory()->createOne();
+
+ $user = User::factory()->withPermission('delete video')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.video.destroy', ['video' => $video]));
+
+ $response->assertOk();
+ static::assertSoftDeleted($video);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Video/VideoForceDeleteTest.php b/tests/Feature/Http/Api/Wiki/Video/VideoForceDeleteTest.php
new file mode 100644
index 000000000..bfe79939b
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Video/VideoForceDeleteTest.php
@@ -0,0 +1,52 @@
+createOne();
+
+ $response = $this->delete(route('api.video.forceDelete', ['video' => $video]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Video Force Destroy Endpoint shall force delete the video.
+ *
+ * @return void
+ */
+ public function testDeleted(): void
+ {
+ $video = Video::factory()->createOne();
+
+ $user = User::factory()->withPermission('force delete video')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->delete(route('api.video.forceDelete', ['video' => $video]));
+
+ $response->assertOk();
+ static::assertModelMissing($video);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Video/VideoIndexTest.php b/tests/Feature/Http/Api/Wiki/Video/VideoIndexTest.php
new file mode 100644
index 000000000..e0ad6f332
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Video/VideoIndexTest.php
@@ -0,0 +1,1143 @@
+withoutEvents();
+
+ $videos = Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $response = $this->get(route('api.video.index'));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($videos, new VideoReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall be paginated.
+ *
+ * @return void
+ */
+ public function testPaginated(): void
+ {
+ $this->withoutEvents();
+
+ Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $response = $this->get(route('api.video.index'));
+
+ $response->assertJsonStructure([
+ VideoCollection::$wrap,
+ 'links',
+ 'meta',
+ ]);
+ }
+
+ /**
+ * The Video Index Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new VideoSchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ )
+ ->create();
+
+ $videos = Video::with($includedPaths->all())->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($videos, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $this->withoutEvents();
+
+ $schema = new VideoSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ VideoResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $videos = Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($videos, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Synonym Index Endpoint shall support sorting resources.
+ *
+ * @return void
+ */
+ public function testSorts(): void
+ {
+ $this->withoutEvents();
+
+ $schema = new VideoSchema();
+
+ $sort = collect($schema->fields())
+ ->filter(fn (Field $field) => $field instanceof SortableField)
+ ->map(fn (SortableField $field) => $field->getSort())
+ ->random();
+
+ $parameters = [
+ SortParser::param() => $sort->format(Direction::getRandomInstance()),
+ ];
+
+ $query = new VideoReadQuery($parameters);
+
+ Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ $query->collection($query->index())
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall support filtering by created_at.
+ *
+ * @return void
+ */
+ public function testCreatedAtFilter(): void
+ {
+ $this->withoutEvents();
+
+ $createdFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_CREATED_AT => $createdFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($createdFilter, function () {
+ Video::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Video::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $video = Video::query()->where(BaseModel::ATTRIBUTE_CREATED_AT, $createdFilter)->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($video, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall support filtering by updated_at.
+ *
+ * @return void
+ */
+ public function testUpdatedAtFilter(): void
+ {
+ $this->withoutEvents();
+
+ $updatedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_UPDATED_AT => $updatedFilter,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($updatedFilter, function () {
+ Video::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ Video::factory()->count($this->faker->randomDigitNotNull())->create();
+ });
+
+ $video = Video::query()->where(BaseModel::ATTRIBUTE_UPDATED_AT, $updatedFilter)->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($video, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithoutTrashedFilter(): void
+ {
+ $this->withoutEvents();
+
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITHOUT,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Video::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteVideo = Video::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteVideo->each(function (Video $video) {
+ $video->delete();
+ });
+
+ $video = Video::withoutTrashed()->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($video, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testWithTrashedFilter(): void
+ {
+ $this->withoutEvents();
+
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Video::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteVideo = Video::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteVideo->each(function (Video $video) {
+ $video->delete();
+ });
+
+ $video = Video::withTrashed()->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($video, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall support filtering by trashed.
+ *
+ * @return void
+ */
+ public function testOnlyTrashedFilter(): void
+ {
+ $this->withoutEvents();
+
+ $parameters = [
+ FilterParser::param() => [
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::ONLY,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Video::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $deleteVideo = Video::factory()->count($this->faker->randomDigitNotNull())->create();
+ $deleteVideo->each(function (Video $video) {
+ $video->delete();
+ });
+
+ $video = Video::onlyTrashed()->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($video, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall support filtering by deleted_at.
+ *
+ * @return void
+ */
+ public function testDeletedAtFilter(): void
+ {
+ $this->withoutEvents();
+
+ $deletedFilter = $this->faker->date();
+ $excludedDate = $this->faker->date();
+
+ $parameters = [
+ FilterParser::param() => [
+ BaseModel::ATTRIBUTE_DELETED_AT => $deletedFilter,
+ TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH,
+ ],
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS,
+ ],
+ ];
+
+ Carbon::withTestNow($deletedFilter, function () {
+ $videos = Video::factory()->count($this->faker->randomDigitNotNull())->create();
+ $videos->each(function (Video $video) {
+ $video->delete();
+ });
+ });
+
+ Carbon::withTestNow($excludedDate, function () {
+ $videos = Video::factory()->count($this->faker->randomDigitNotNull())->create();
+ $videos->each(function (Video $video) {
+ $video->delete();
+ });
+ });
+
+ $video = Video::withTrashed()->where(BaseModel::ATTRIBUTE_DELETED_AT, $deletedFilter)->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($video, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall support filtering by lyrics.
+ *
+ * @return void
+ */
+ public function testLyricsFilter(): void
+ {
+ $this->withoutEvents();
+
+ $lyricsFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_LYRICS => $lyricsFilter,
+ ],
+ ];
+
+ Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $videos = Video::query()->where(Video::ATTRIBUTE_LYRICS, $lyricsFilter)->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($videos, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall support filtering by nc.
+ *
+ * @return void
+ */
+ public function testNcFilter(): void
+ {
+ $this->withoutEvents();
+
+ $ncFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_NC => $ncFilter,
+ ],
+ ];
+
+ Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $videos = Video::query()->where(Video::ATTRIBUTE_NC, $ncFilter)->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($videos, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall support filtering by overlap.
+ *
+ * @return void
+ */
+ public function testOverlapFilter(): void
+ {
+ $this->withoutEvents();
+
+ $overlapFilter = VideoOverlap::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_OVERLAP => $overlapFilter->description,
+ ],
+ ];
+
+ Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $videos = Video::query()->where(Video::ATTRIBUTE_OVERLAP, $overlapFilter->value)->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($videos, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall support filtering by resolution.
+ *
+ * @return void
+ */
+ public function testResolutionFilter(): void
+ {
+ $this->withoutEvents();
+
+ $resolutionFilter = $this->faker->randomNumber();
+ $excludedResolution = $resolutionFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_RESOLUTION => $resolutionFilter,
+ ],
+ ];
+
+ Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->state(new Sequence(
+ [Video::ATTRIBUTE_RESOLUTION => $resolutionFilter],
+ [Video::ATTRIBUTE_RESOLUTION => $excludedResolution],
+ ))
+ ->create();
+
+ $videos = Video::query()->where(Video::ATTRIBUTE_RESOLUTION, $resolutionFilter)->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($videos, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall support filtering by source.
+ *
+ * @return void
+ */
+ public function testSourceFilter(): void
+ {
+ $this->withoutEvents();
+
+ $sourceFilter = VideoSource::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_SOURCE => $sourceFilter->description,
+ ],
+ ];
+
+ Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $videos = Video::query()->where(Video::ATTRIBUTE_SOURCE, $sourceFilter->value)->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($videos, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall support filtering by subbed.
+ *
+ * @return void
+ */
+ public function testSubbedFilter(): void
+ {
+ $this->withoutEvents();
+
+ $subbedFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_SUBBED => $subbedFilter,
+ ],
+ ];
+
+ Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $videos = Video::query()->where(Video::ATTRIBUTE_SUBBED, $subbedFilter)->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($videos, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall support filtering by uncen.
+ *
+ * @return void
+ */
+ public function testUncenFilter(): void
+ {
+ $this->withoutEvents();
+
+ $uncenFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ Video::ATTRIBUTE_UNCEN => $uncenFilter,
+ ],
+ ];
+
+ Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->create();
+
+ $videos = Video::query()->where(Video::ATTRIBUTE_UNCEN, $uncenFilter)->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($videos, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall support constrained eager loading of entries by nsfw.
+ *
+ * @return void
+ */
+ public function testEntriesByNsfw(): void
+ {
+ $nsfwFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeThemeEntry::ATTRIBUTE_NSFW => $nsfwFilter,
+ ],
+ IncludeParser::param() => Video::RELATION_ANIMETHEMEENTRIES,
+ ];
+
+ Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ )
+ ->create();
+
+ $videos = Video::with([
+ Video::RELATION_ANIMETHEMEENTRIES => function (BelongsToMany $query) use ($nsfwFilter) {
+ $query->where(AnimeThemeEntry::ATTRIBUTE_NSFW, $nsfwFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($videos, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall support constrained eager loading of entries by spoiler.
+ *
+ * @return void
+ */
+ public function testEntriesBySpoiler(): void
+ {
+ $spoilerFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeThemeEntry::ATTRIBUTE_SPOILER => $spoilerFilter,
+ ],
+ IncludeParser::param() => Video::RELATION_ANIMETHEMEENTRIES,
+ ];
+
+ Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ )
+ ->create();
+
+ $videos = Video::with([
+ Video::RELATION_ANIMETHEMEENTRIES => function (BelongsToMany $query) use ($spoilerFilter) {
+ $query->where(AnimeThemeEntry::ATTRIBUTE_SPOILER, $spoilerFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($videos, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall support constrained eager loading of entries by version.
+ *
+ * @return void
+ */
+ public function testEntriesByVersion(): void
+ {
+ $versionFilter = $this->faker->randomDigitNotNull();
+ $excludedVersion = $versionFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeThemeEntry::ATTRIBUTE_VERSION => $versionFilter,
+ ],
+ IncludeParser::param() => Video::RELATION_ANIMETHEMEENTRIES,
+ ];
+
+ Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->state(new Sequence(
+ [AnimeThemeEntry::ATTRIBUTE_VERSION => $versionFilter],
+ [AnimeThemeEntry::ATTRIBUTE_VERSION => $excludedVersion],
+ ))
+ )
+ ->create();
+
+ $videos = Video::with([
+ Video::RELATION_ANIMETHEMEENTRIES => function (BelongsToMany $query) use ($versionFilter) {
+ $query->where(AnimeThemeEntry::ATTRIBUTE_VERSION, $versionFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($videos, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall support constrained eager loading of themes by group.
+ *
+ * @return void
+ */
+ public function testThemesByGroup(): void
+ {
+ $groupFilter = $this->faker->word();
+ $excludedGroup = $this->faker->word();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_GROUP => $groupFilter,
+ ],
+ IncludeParser::param() => Video::RELATION_ANIMETHEME,
+ ];
+
+ Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->state([
+ AnimeTheme::ATTRIBUTE_GROUP => $this->faker->boolean() ? $groupFilter : $excludedGroup,
+ ])
+ )
+ )
+ ->create();
+
+ $videos = Video::with([
+ Video::RELATION_ANIMETHEME => function (BelongsTo $query) use ($groupFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_GROUP, $groupFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($videos, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall support constrained eager loading of themes by sequence.
+ *
+ * @return void
+ */
+ public function testThemesBySequence(): void
+ {
+ $sequenceFilter = $this->faker->randomDigitNotNull();
+ $excludedSequence = $sequenceFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_SEQUENCE => $sequenceFilter,
+ ],
+ IncludeParser::param() => Video::RELATION_ANIMETHEME,
+ ];
+
+ Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->state([
+ AnimeTheme::ATTRIBUTE_SEQUENCE => $this->faker->boolean() ? $sequenceFilter : $excludedSequence,
+ ])
+ )
+ )
+ ->create();
+
+ $videos = Video::with([
+ Video::RELATION_ANIMETHEME => function (BelongsTo $query) use ($sequenceFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_SEQUENCE, $sequenceFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($videos, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall support constrained eager loading of themes by type.
+ *
+ * @return void
+ */
+ public function testThemesByType(): void
+ {
+ $typeFilter = ThemeType::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_TYPE => $typeFilter->description,
+ ],
+ IncludeParser::param() => Video::RELATION_ANIMETHEME,
+ ];
+
+ Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ )
+ ->create();
+
+ $videos = Video::with([
+ Video::RELATION_ANIMETHEME => function (BelongsTo $query) use ($typeFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_TYPE, $typeFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($videos, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall support constrained eager loading of anime by season.
+ *
+ * @return void
+ */
+ public function testAnimeBySeason(): void
+ {
+ $seasonFilter = AnimeSeason::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_SEASON => $seasonFilter->description,
+ ],
+ IncludeParser::param() => Video::RELATION_ANIME,
+ ];
+
+ Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ )
+ ->create();
+
+ $videos = Video::with([
+ Video::RELATION_ANIME => function (BelongsTo $query) use ($seasonFilter) {
+ $query->where(Anime::ATTRIBUTE_SEASON, $seasonFilter->value);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($videos, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Index Endpoint shall support constrained eager loading of anime by year.
+ *
+ * @return void
+ */
+ public function testAnimeByYear(): void
+ {
+ $yearFilter = intval($this->faker->year());
+ $excludedYear = $yearFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_YEAR => $yearFilter,
+ ],
+ IncludeParser::param() => Video::RELATION_ANIME,
+ ];
+
+ Video::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(
+ AnimeTheme::factory()
+ ->for(
+ Anime::factory()
+ ->state([
+ Anime::ATTRIBUTE_YEAR => $this->faker->boolean() ? $yearFilter : $excludedYear,
+ ])
+ )
+ )
+ )
+ ->create();
+
+ $videos = Video::with([
+ Video::RELATION_ANIME => function (BelongsTo $query) use ($yearFilter) {
+ $query->where(Anime::ATTRIBUTE_YEAR, $yearFilter);
+ },
+ ])
+ ->get();
+
+ $response = $this->get(route('api.video.index', $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoCollection($videos, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Video/VideoRestoreTest.php b/tests/Feature/Http/Api/Wiki/Video/VideoRestoreTest.php
new file mode 100644
index 000000000..6f9964a5c
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Video/VideoRestoreTest.php
@@ -0,0 +1,56 @@
+createOne();
+
+ $video->delete();
+
+ $response = $this->patch(route('api.video.restore', ['video' => $video]));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Video Restore Endpoint shall restore the video.
+ *
+ * @return void
+ */
+ public function testRestored(): void
+ {
+ $video = Video::factory()->createOne();
+
+ $video->delete();
+
+ $user = User::factory()->withPermission('restore video')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->patch(route('api.video.restore', ['video' => $video]));
+
+ $response->assertOk();
+ static::assertNotSoftDeleted($video);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Video/VideoShowTest.php b/tests/Feature/Http/Api/Wiki/Video/VideoShowTest.php
new file mode 100644
index 000000000..38db3f966
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Video/VideoShowTest.php
@@ -0,0 +1,543 @@
+withoutEvents();
+
+ $video = Video::factory()->create();
+
+ $response = $this->get(route('api.video.show', ['video' => $video]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoResource($video, new VideoReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Show Endpoint shall return a Video Resource for soft deleted videos.
+ *
+ * @return void
+ */
+ public function testSoftDelete(): void
+ {
+ $this->withoutEvents();
+
+ $video = Video::factory()->createOne();
+
+ $video->delete();
+
+ $response = $this->get(route('api.video.show', ['video' => $video]));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoResource($video, new VideoReadQuery()))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Show Endpoint shall allow inclusion of related resources.
+ *
+ * @return void
+ */
+ public function testAllowedIncludePaths(): void
+ {
+ $schema = new VideoSchema();
+
+ $allowedIncludes = collect($schema->allowedIncludes());
+
+ $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count()));
+
+ $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path());
+
+ $parameters = [
+ IncludeParser::param() => $includedPaths->join(','),
+ ];
+
+ $video = Video::factory()
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ )
+ ->createOne();
+
+ $response = $this->get(route('api.video.show', ['video' => $video] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoResource($video, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Show Endpoint shall implement sparse fieldsets.
+ *
+ * @return void
+ */
+ public function testSparseFieldsets(): void
+ {
+ $this->withoutEvents();
+
+ $schema = new VideoSchema();
+
+ $fields = collect($schema->fields());
+
+ $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count()));
+
+ $parameters = [
+ FieldParser::param() => [
+ VideoResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','),
+ ],
+ ];
+
+ $video = Video::factory()->create();
+
+ $response = $this->get(route('api.video.show', ['video' => $video] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoResource($video, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Show Endpoint shall support constrained eager loading of entries by nsfw.
+ *
+ * @return void
+ */
+ public function testEntriesByNsfw(): void
+ {
+ $nsfwFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeThemeEntry::ATTRIBUTE_NSFW => $nsfwFilter,
+ ],
+ IncludeParser::param() => Video::RELATION_ANIMETHEMEENTRIES,
+ ];
+
+ $video = Video::factory()
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ )
+ ->createOne();
+
+ $video->unsetRelations()->load([
+ Video::RELATION_ANIMETHEMEENTRIES => function (BelongsToMany $query) use ($nsfwFilter) {
+ $query->where(AnimeThemeEntry::ATTRIBUTE_NSFW, $nsfwFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.video.show', ['video' => $video] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoResource($video, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Show Endpoint shall support constrained eager loading of entries by spoiler.
+ *
+ * @return void
+ */
+ public function testEntriesBySpoiler(): void
+ {
+ $spoilerFilter = $this->faker->boolean();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeThemeEntry::ATTRIBUTE_SPOILER => $spoilerFilter,
+ ],
+ IncludeParser::param() => Video::RELATION_ANIMETHEMEENTRIES,
+ ];
+
+ $video = Video::factory()
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ )
+ ->createOne();
+
+ $video->unsetRelations()->load([
+ Video::RELATION_ANIMETHEMEENTRIES => function (BelongsToMany $query) use ($spoilerFilter) {
+ $query->where(AnimeThemeEntry::ATTRIBUTE_SPOILER, $spoilerFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.video.show', ['video' => $video] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoResource($video, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Show Endpoint shall support constrained eager loading of entries by version.
+ *
+ * @return void
+ */
+ public function testEntriesByVersion(): void
+ {
+ $versionFilter = $this->faker->randomDigitNotNull();
+ $excludedVersion = $versionFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeThemeEntry::ATTRIBUTE_VERSION => $versionFilter,
+ ],
+ IncludeParser::param() => Video::RELATION_ANIMETHEMEENTRIES,
+ ];
+
+ $video = Video::factory()
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->state(new Sequence(
+ [AnimeThemeEntry::ATTRIBUTE_VERSION => $versionFilter],
+ [AnimeThemeEntry::ATTRIBUTE_VERSION => $excludedVersion],
+ ))
+ )
+ ->createOne();
+
+ $video->unsetRelations()->load([
+ Video::RELATION_ANIMETHEMEENTRIES => function (BelongsToMany $query) use ($versionFilter) {
+ $query->where(AnimeThemeEntry::ATTRIBUTE_VERSION, $versionFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.video.show', ['video' => $video] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoResource($video, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Show Endpoint shall support constrained eager loading of themes by group.
+ *
+ * @return void
+ */
+ public function testThemesByGroup(): void
+ {
+ $groupFilter = $this->faker->word();
+ $excludedGroup = $this->faker->word();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_GROUP => $groupFilter,
+ ],
+ IncludeParser::param() => Video::RELATION_ANIMETHEME,
+ ];
+
+ $video = Video::factory()
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->state([
+ AnimeTheme::ATTRIBUTE_GROUP => $this->faker->boolean() ? $groupFilter : $excludedGroup,
+ ])
+ )
+ )
+ ->createOne();
+
+ $video->unsetRelations()->load([
+ Video::RELATION_ANIMETHEME => function (BelongsTo $query) use ($groupFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_GROUP, $groupFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.video.show', ['video' => $video] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoResource($video, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Show Endpoint shall support constrained eager loading of themes by sequence.
+ *
+ * @return void
+ */
+ public function testThemesBySequence(): void
+ {
+ $sequenceFilter = $this->faker->randomDigitNotNull();
+ $excludedSequence = $sequenceFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_SEQUENCE => $sequenceFilter,
+ ],
+ IncludeParser::param() => Video::RELATION_ANIMETHEME,
+ ];
+
+ $video = Video::factory()
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(
+ AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->state([
+ AnimeTheme::ATTRIBUTE_SEQUENCE => $this->faker->boolean() ? $sequenceFilter : $excludedSequence,
+ ])
+ )
+ )
+ ->createOne();
+
+ $video->unsetRelations()->load([
+ Video::RELATION_ANIMETHEME => function (BelongsTo $query) use ($sequenceFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_SEQUENCE, $sequenceFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.video.show', ['video' => $video] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoResource($video, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Show Endpoint shall support constrained eager loading of themes by type.
+ *
+ * @return void
+ */
+ public function testThemesByType(): void
+ {
+ $typeFilter = ThemeType::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ AnimeTheme::ATTRIBUTE_TYPE => $typeFilter->description,
+ ],
+ IncludeParser::param() => Video::RELATION_ANIMETHEME,
+ ];
+
+ $video = Video::factory()
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ )
+ ->createOne();
+
+ $video->unsetRelations()->load([
+ Video::RELATION_ANIMETHEME => function (BelongsTo $query) use ($typeFilter) {
+ $query->where(AnimeTheme::ATTRIBUTE_TYPE, $typeFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.video.show', ['video' => $video] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoResource($video, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Show Endpoint shall support constrained eager loading of anime by season.
+ *
+ * @return void
+ */
+ public function testAnimeBySeason(): void
+ {
+ $seasonFilter = AnimeSeason::getRandomInstance();
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_SEASON => $seasonFilter->description,
+ ],
+ IncludeParser::param() => Video::RELATION_ANIME,
+ ];
+
+ $video = Video::factory()
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ )
+ ->createOne();
+
+ $video->unsetRelations()->load([
+ Video::RELATION_ANIME => function (BelongsTo $query) use ($seasonFilter) {
+ $query->where(Anime::ATTRIBUTE_SEASON, $seasonFilter->value);
+ },
+ ]);
+
+ $response = $this->get(route('api.video.show', ['video' => $video] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoResource($video, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+
+ /**
+ * The Video Show Endpoint shall support constrained eager loading of anime by year.
+ *
+ * @return void
+ */
+ public function testAnimeByYear(): void
+ {
+ $yearFilter = intval($this->faker->year());
+ $excludedYear = $yearFilter + 1;
+
+ $parameters = [
+ FilterParser::param() => [
+ Anime::ATTRIBUTE_YEAR => $yearFilter,
+ ],
+ IncludeParser::param() => Video::RELATION_ANIME,
+ ];
+
+ $video = Video::factory()
+ ->has(
+ AnimeThemeEntry::factory()
+ ->count($this->faker->randomDigitNotNull())
+ ->for(
+ AnimeTheme::factory()
+ ->for(
+ Anime::factory()
+ ->state([
+ Anime::ATTRIBUTE_YEAR => $this->faker->boolean() ? $yearFilter : $excludedYear,
+ ])
+ )
+ )
+ )
+ ->createOne();
+
+ $video->unsetRelations()->load([
+ Video::RELATION_ANIME => function (BelongsTo $query) use ($yearFilter) {
+ $query->where(Anime::ATTRIBUTE_YEAR, $yearFilter);
+ },
+ ]);
+
+ $response = $this->get(route('api.video.show', ['video' => $video] + $parameters));
+
+ $response->assertJson(
+ json_decode(
+ json_encode(
+ (new VideoResource($video, new VideoReadQuery($parameters)))
+ ->response()
+ ->getData()
+ ),
+ true
+ )
+ );
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Video/VideoStoreTest.php b/tests/Feature/Http/Api/Wiki/Video/VideoStoreTest.php
new file mode 100644
index 000000000..54c93bb98
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Video/VideoStoreTest.php
@@ -0,0 +1,82 @@
+makeOne();
+
+ $response = $this->post(route('api.video.store', $video->toArray()));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Video Store Endpoint shall require basename, filename, mimetype, path & size fields.
+ *
+ * @return void
+ */
+ public function testRequiredFields(): void
+ {
+ $user = User::factory()->withPermission('create video')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.video.store'));
+
+ $response->assertJsonValidationErrors([
+ Video::ATTRIBUTE_BASENAME,
+ Video::ATTRIBUTE_FILENAME,
+ Video::ATTRIBUTE_MIMETYPE,
+ Video::ATTRIBUTE_PATH,
+ Video::ATTRIBUTE_SIZE,
+ ]);
+ }
+
+ /**
+ * The Video Store Endpoint shall create a video.
+ *
+ * @return void
+ */
+ public function testCreate(): void
+ {
+ $parameters = array_merge(
+ Video::factory()->raw(),
+ [
+ Video::ATTRIBUTE_OVERLAP => VideoOverlap::getRandomInstance()->description,
+ Video::ATTRIBUTE_SOURCE => VideoSource::getRandomInstance()->description,
+ ]
+ );
+
+ $user = User::factory()->withPermission('create video')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->post(route('api.video.store', $parameters));
+
+ $response->assertCreated();
+ static::assertDatabaseCount(Video::TABLE, 1);
+ }
+}
diff --git a/tests/Feature/Http/Api/Wiki/Video/VideoUpdateTest.php b/tests/Feature/Http/Api/Wiki/Video/VideoUpdateTest.php
new file mode 100644
index 000000000..180ca7136
--- /dev/null
+++ b/tests/Feature/Http/Api/Wiki/Video/VideoUpdateTest.php
@@ -0,0 +1,69 @@
+createOne();
+
+ $parameters = array_merge(
+ Video::factory()->raw(),
+ [
+ Video::ATTRIBUTE_OVERLAP => VideoOverlap::getRandomInstance()->description,
+ Video::ATTRIBUTE_SOURCE => VideoSource::getRandomInstance()->description,
+ ]
+ );
+
+ $response = $this->put(route('api.video.update', ['video' => $video] + $parameters));
+
+ $response->assertUnauthorized();
+ }
+
+ /**
+ * The Video Update Endpoint shall update a video.
+ *
+ * @return void
+ */
+ public function testUpdate(): void
+ {
+ $video = Video::factory()->createOne();
+
+ $parameters = array_merge(
+ Video::factory()->raw(),
+ [
+ Video::ATTRIBUTE_OVERLAP => VideoOverlap::getRandomInstance()->description,
+ Video::ATTRIBUTE_SOURCE => VideoSource::getRandomInstance()->description,
+ ]
+ );
+
+ $user = User::factory()->withPermission('update video')->createOne();
+
+ Sanctum::actingAs($user);
+
+ $response = $this->put(route('api.video.update', ['video' => $video] + $parameters));
+
+ $response->assertOk();
+ }
+}
diff --git a/tests/Feature/Http/Auth/RegistrationTest.php b/tests/Feature/Http/Auth/RegistrationTest.php
new file mode 100644
index 000000000..7b2b98511
--- /dev/null
+++ b/tests/Feature/Http/Auth/RegistrationTest.php
@@ -0,0 +1,335 @@
+create([
+ Invitation::ATTRIBUTE_STATUS => InvitationStatus::CLOSED,
+ ]);
+
+ $url = URL::temporarySignedRoute(
+ 'register',
+ now()->addMinutes(30),
+ ['invitation' => $invitation]
+ );
+
+ $response = $this->get($url);
+
+ $response->assertForbidden();
+ }
+
+ /**
+ * If the show registration form request uses a soft deleted invitation,
+ * the user shall receive a forbidden exception.
+ *
+ * @return void
+ */
+ public function testHasSoftDeletedInvitationForRegistrationForm(): void
+ {
+ $invitation = Invitation::factory()->create();
+
+ $invitation->delete();
+
+ $url = URL::temporarySignedRoute(
+ 'register',
+ now()->addMinutes(30),
+ ['invitation' => $invitation]
+ );
+
+ $response = $this->get($url);
+
+ $response->assertForbidden();
+ }
+
+ /**
+ * If the show registration form request uses an open invitation,
+ * the application shall display the registration form.
+ *
+ * @return void
+ */
+ public function testHasInvitationForRegistrationForm(): void
+ {
+ $invitation = Invitation::factory()->create();
+
+ $url = URL::temporarySignedRoute(
+ 'register',
+ now()->addMinutes(30),
+ ['invitation' => $invitation]
+ );
+
+ $response = $this->get($url);
+
+ $response->assertViewIs('auth.register');
+ }
+
+ /**
+ * If the registration request uses a closed invitation,
+ * the user shall receive a forbidden exception.
+ *
+ * @return void
+ */
+ public function testHasClosedInvitationForRegistration(): void
+ {
+ $invitation = Invitation::factory()->create([
+ Invitation::ATTRIBUTE_STATUS => InvitationStatus::CLOSED,
+ ]);
+
+ $url = URL::temporarySignedRoute(
+ 'register',
+ now()->addMinutes(30),
+ ['invitation' => $invitation]
+ );
+
+ $response = $this->post($url);
+
+ $response->assertForbidden();
+ }
+
+ /**
+ * If the registration request uses an open invitation,
+ * the application shall process the validation.
+ *
+ * @return void
+ */
+ public function testHasInvitationForRegistration(): void
+ {
+ $invitation = Invitation::factory()->create();
+
+ $url = URL::temporarySignedRoute(
+ 'register',
+ now()->addMinutes(30),
+ ['invitation' => $invitation]
+ );
+
+ $response = $this->post($url);
+
+ $response->assertInvalid(['password']);
+ }
+
+ /**
+ * The password field shall be required for the registration request.
+ *
+ * @return void
+ */
+ public function testPasswordRequiredValidationForRegistration(): void
+ {
+ $invitation = Invitation::factory()->create();
+
+ $url = URL::temporarySignedRoute(
+ 'register',
+ now()->addMinutes(30),
+ ['invitation' => $invitation]
+ );
+
+ $response = $this->post($url);
+
+ $response->assertInvalid(['password' => 'The password field is required.']);
+ }
+
+ /**
+ * The password field shall be at least 8 characters in length for the registration request.
+ *
+ * @return void
+ */
+ public function testPasswordLengthValidationForRegistration(): void
+ {
+ $invitation = Invitation::factory()->create();
+
+ $url = URL::temporarySignedRoute(
+ 'register',
+ now()->addMinutes(30),
+ ['invitation' => $invitation, 'password' => $this->faker->password(6, 7)]
+ );
+
+ $response = $this->post($url);
+
+ $response->assertInvalid(['password' => 'The password must be at least 8 characters.']);
+ }
+
+ /**
+ * The password field shall be confirmed for the registration request.
+ *
+ * @return void
+ */
+ public function testPasswordConfirmationValidationForRegistration(): void
+ {
+ $invitation = Invitation::factory()->create();
+
+ $url = URL::temporarySignedRoute(
+ 'register',
+ now()->addMinutes(30),
+ [
+ 'invitation' => $invitation,
+ 'password' => $this->faker->password(6, 7),
+ 'password_confirmation' => $this->faker->password(8, 9),
+ 'terms' => 'terms',
+ ]
+ );
+
+ $response = $this->post($url);
+
+ $response->assertInvalid(['password' => 'The password confirmation does not match.']);
+ }
+
+ /**
+ * The password field shall be scored at least a 3 by ZXCVBN for the registration request.
+ *
+ * @return void
+ */
+ public function testPasswordStrengthValidationForRegistration(): void
+ {
+ $invitation = Invitation::factory()->create();
+
+ $weakPassword = $this->faker->password(8, 8);
+
+ $url = URL::temporarySignedRoute(
+ 'register',
+ now()->addMinutes(30),
+ [
+ 'invitation' => $invitation,
+ 'password' => $weakPassword,
+ 'password_confirmation' => $weakPassword,
+ 'terms' => 'terms',
+ ]
+ );
+
+ $response = $this->post($url);
+
+ $response->assertInvalid(['password' => 'Your password is not secure enough.']);
+ }
+
+ /**
+ * The terms field shall be required.
+ *
+ * @return void
+ */
+ public function testTermAcceptanceRequiredForRegistration(): void
+ {
+ $invitation = Invitation::factory()->create();
+
+ $strongPassword = $this->faker->password(64, 128);
+
+ $url = URL::temporarySignedRoute(
+ 'register',
+ now()->addMinutes(30),
+ [
+ 'invitation' => $invitation,
+ 'password' => $strongPassword,
+ 'password_confirmation' => $strongPassword,
+ ]
+ );
+
+ $response = $this->post($url);
+
+ $response->assertInvalid(['terms' => 'The terms field is required.']);
+ }
+
+ /**
+ * When the registration request is valid, the user shall be directed to the dashboard.
+ *
+ * @return void
+ */
+ public function testRedirectToDashboardForValidRegistration(): void
+ {
+ $invitation = Invitation::factory()->create();
+
+ $strongPassword = $this->faker->password(64, 128);
+
+ $url = URL::temporarySignedRoute(
+ 'register',
+ now()->addMinutes(30),
+ [
+ 'invitation' => $invitation,
+ 'password' => $strongPassword,
+ 'password_confirmation' => $strongPassword,
+ 'terms' => 'terms',
+ ]
+ );
+
+ $response = $this->post($url);
+
+ $response->assertRedirect(route('dashboard'));
+ }
+
+ /**
+ * When the registration request is valid, the user shall be created.
+ *
+ * @return void
+ */
+ public function testUserCreatedForValidRegistration(): void
+ {
+ $invitation = Invitation::factory()->createOne();
+
+ $strongPassword = $this->faker->password(64, 128);
+
+ $url = URL::temporarySignedRoute(
+ 'register',
+ now()->addMinutes(30),
+ [
+ 'invitation' => $invitation,
+ 'password' => $strongPassword,
+ 'password_confirmation' => $strongPassword,
+ 'terms' => 'terms',
+ ]
+ );
+
+ $this->post($url);
+
+ $user = User::query()
+ ->where(Invitation::ATTRIBUTE_NAME, $invitation->name)
+ ->where(Invitation::ATTRIBUTE_EMAIL, $invitation->email)
+ ->first();
+
+ static::assertNotNull($user);
+ }
+
+ /**
+ * When the registration request is valid, the guest shall be authenticated as the newly created user.
+ *
+ * @return void
+ */
+ public function testUserAuthenticatedForValidRegistration(): void
+ {
+ $invitation = Invitation::factory()->createOne();
+
+ $strongPassword = $this->faker->password(64, 128);
+
+ $url = URL::temporarySignedRoute(
+ 'register',
+ now()->addMinutes(30),
+ [
+ 'invitation' => $invitation,
+ 'password' => $strongPassword,
+ 'password_confirmation' => $strongPassword,
+ 'terms' => 'terms',
+ ]
+ );
+
+ $this->post($url);
+
+ $this->assertAuthenticated();
+ }
+}
diff --git a/tests/Feature/Http/Billing/TransparencyTest.php b/tests/Feature/Http/Billing/TransparencyTest.php
new file mode 100644
index 000000000..cd10a5f7c
--- /dev/null
+++ b/tests/Feature/Http/Billing/TransparencyTest.php
@@ -0,0 +1,132 @@
+get(route('transparency.show'));
+
+ $response->assertViewIs('billing.transparency');
+ }
+
+ /**
+ * The transparency route shall contain the nullable rule on the date field.
+ *
+ * @return void
+ */
+ public function testNullable(): void
+ {
+ $response = $this->get(route('transparency.show'));
+
+ $response->assertSessionHasNoErrors();
+ }
+
+ /**
+ * The transparency route shall contain the date_format rule on the date field.
+ *
+ * @return void
+ */
+ public function testDateFormatRule(): void
+ {
+ $date = $this->faker->word();
+
+ $response = $this->get(route('transparency.show', ['date' => $date]));
+
+ $response->assertInvalid(['date' => 'The date does not match the format Y-m.']);
+ }
+
+ /**
+ * The transparency route shall contain the transparency date rule on the date field.
+ *
+ * @return void
+ */
+ public function testTransparencyDateRule(): void
+ {
+ Balance::factory()->create();
+
+ $date = Date::now()->subMonths($this->faker->randomDigitNotNull())->format(AllowedDateFormat::YM);
+
+ $response = $this->get(route('transparency.show', ['date' => $date]));
+
+ $response->assertInvalid(['date' => 'The selected month is not valid.']);
+ }
+
+ /**
+ * The transparency route shall bind the selected month's balances.
+ *
+ * @return void
+ */
+ public function testBalances(): void
+ {
+ Balance::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('transparency.show'));
+
+ $response->assertViewHas('balances');
+ }
+
+ /**
+ * The transparency route shall bind the selected month's transactions.
+ *
+ * @return void
+ */
+ public function testTransactions(): void
+ {
+ Transaction::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('transparency.show'));
+
+ $response->assertViewHas('transactions');
+ }
+
+ /**
+ * The transparency route shall bind the date field options.
+ *
+ * @return void
+ */
+ public function testFilterOptions(): void
+ {
+ Balance::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('transparency.show'));
+
+ $response->assertViewHas('filterOptions');
+ }
+
+ /**
+ * The transparency route shall bind the date field selected value.
+ *
+ * @return void
+ */
+ public function testSelectedDate(): void
+ {
+ Balance::factory()->count($this->faker->randomDigitNotNull())->create();
+
+ $response = $this->get(route('transparency.show'));
+
+ $response->assertViewHas('selectedDate');
+ }
+}
diff --git a/tests/Feature/Http/Wiki/VideoTest.php b/tests/Feature/Http/Wiki/VideoTest.php
new file mode 100644
index 000000000..e4f5b161d
--- /dev/null
+++ b/tests/Feature/Http/Wiki/VideoTest.php
@@ -0,0 +1,110 @@
+createOne();
+
+ $response = $this->get(route('video.show', ['video' => $video]));
+
+ $response->assertForbidden();
+ }
+
+ /**
+ * If the video is soft-deleted, the user shall receive a forbidden exception.
+ *
+ * @return void
+ */
+ public function testSoftDeleteVideoStreamingForbidden(): void
+ {
+ Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true);
+
+ $video = Video::factory()->createOne();
+
+ $video->delete();
+
+ $response = $this->get(route('video.show', ['video' => $video]));
+
+ $response->assertForbidden();
+ }
+
+ /**
+ * If view recording is disabled, the video show route shall not record a view for the video.
+ *
+ * @return void
+ */
+ public function testViewRecordingNotAllowed(): void
+ {
+ Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true);
+ Config::set(FlagConstants::ALLOW_VIEW_RECORDING_FLAG_QUALIFIED, false);
+
+ $video = Video::factory()->createOne();
+
+ $this->get(route('video.show', ['video' => $video]));
+
+ static::assertEquals(0, $video->views()->count());
+ }
+
+ /**
+ * If view recording is enabled, the video show route shall record a view for the video.
+ *
+ * @return void
+ */
+ public function testViewRecordingIsAllowed(): void
+ {
+ Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true);
+ Config::set(FlagConstants::ALLOW_VIEW_RECORDING_FLAG_QUALIFIED, true);
+
+ $video = Video::factory()->createOne();
+
+ $this->get(route('video.show', ['video' => $video]));
+
+ static::assertEquals(1, $video->views()->count());
+ }
+
+ /**
+ * If view recording is enabled, the video show route shall record a view for the video.
+ *
+ * @return void
+ */
+ public function testViewRecordingCooldown(): void
+ {
+ Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true);
+ Config::set(FlagConstants::ALLOW_VIEW_RECORDING_FLAG_QUALIFIED, true);
+
+ $video = Video::factory()->createOne();
+
+ Collection::times($this->faker->randomDigitNotNull(), function () use ($video) {
+ $this->get(route('video.show', ['video' => $video]));
+ });
+
+ static::assertEquals(1, $video->views()->count());
+ }
+}
diff --git a/tests/Feature/Jobs/Admin/AnnouncementTest.php b/tests/Feature/Jobs/Admin/AnnouncementTest.php
new file mode 100644
index 000000000..ba57a16aa
--- /dev/null
+++ b/tests/Feature/Jobs/Admin/AnnouncementTest.php
@@ -0,0 +1,87 @@
+createOne();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an announcement is deleted, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnnouncementDeletedSendsDiscordNotification(): void
+ {
+ $announcement = Announcement::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $announcement->delete();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an announcement is restored, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnnouncementRestoredSendsDiscordNotification(): void
+ {
+ $announcement = Announcement::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $announcement->restore();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an announcement is updated, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnnouncementUpdatedSendsDiscordNotification(): void
+ {
+ $announcement = Announcement::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $changes = Announcement::factory()->makeOne();
+
+ $announcement->fill($changes->getAttributes());
+ $announcement->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Auth/InvitationTest.php b/tests/Feature/Jobs/Auth/InvitationTest.php
new file mode 100644
index 000000000..9319742cc
--- /dev/null
+++ b/tests/Feature/Jobs/Auth/InvitationTest.php
@@ -0,0 +1,87 @@
+createOne();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an invitation is deleted, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testInvitationDeletedSendsDiscordNotification(): void
+ {
+ $invitation = Invitation::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $invitation->delete();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an invitation is restored, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testInvitationRestoredSendsDiscordNotification(): void
+ {
+ $invitation = Invitation::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $invitation->restore();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an invitation is updated, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testInvitationUpdatedSendsDiscordNotification(): void
+ {
+ $invitation = Invitation::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $changes = Invitation::factory()->makeOne();
+
+ $invitation->fill($changes->getAttributes());
+ $invitation->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Auth/UserTest.php b/tests/Feature/Jobs/Auth/UserTest.php
new file mode 100644
index 000000000..7667e22fb
--- /dev/null
+++ b/tests/Feature/Jobs/Auth/UserTest.php
@@ -0,0 +1,87 @@
+createOne();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a user is deleted, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testUserDeletedSendsDiscordNotification(): void
+ {
+ $user = User::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $user->delete();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a user is restored, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testUserRestoredSendsDiscordNotification(): void
+ {
+ $user = User::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $user->restore();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a user is updated, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testUserUpdatedSendsDiscordNotification(): void
+ {
+ $user = User::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $changes = User::factory()->makeOne();
+
+ $user->fill($changes->getAttributes());
+ $user->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Billing/BalanceTest.php b/tests/Feature/Jobs/Billing/BalanceTest.php
new file mode 100644
index 000000000..b70c9a46c
--- /dev/null
+++ b/tests/Feature/Jobs/Billing/BalanceTest.php
@@ -0,0 +1,87 @@
+createOne();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a balance is deleted, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testBalanceDeletedSendsDiscordNotification(): void
+ {
+ $balance = Balance::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $balance->delete();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a balance is restored, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testBalanceRestoredSendsDiscordNotification(): void
+ {
+ $balance = Balance::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $balance->restore();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a balance is updated, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testBalanceUpdatedSendsDiscordNotification(): void
+ {
+ $balance = Balance::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $changes = Balance::factory()->makeOne();
+
+ $balance->fill($changes->getAttributes());
+ $balance->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Billing/TransactionTest.php b/tests/Feature/Jobs/Billing/TransactionTest.php
new file mode 100644
index 000000000..0500c2a94
--- /dev/null
+++ b/tests/Feature/Jobs/Billing/TransactionTest.php
@@ -0,0 +1,87 @@
+createOne();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a transaction is deleted, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testTransactionDeletedSendsDiscordNotification(): void
+ {
+ $transaction = Transaction::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $transaction->delete();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a transaction is restored, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testTransactionRestoredSendsDiscordNotification(): void
+ {
+ $transaction = Transaction::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $transaction->restore();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a transaction is updated, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testTransactionUpdatedSendsDiscordNotification(): void
+ {
+ $transaction = Transaction::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $changes = Transaction::factory()->makeOne();
+
+ $transaction->fill($changes->getAttributes());
+ $transaction->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Document/PageTest.php b/tests/Feature/Jobs/Document/PageTest.php
new file mode 100644
index 000000000..9dfd65c67
--- /dev/null
+++ b/tests/Feature/Jobs/Document/PageTest.php
@@ -0,0 +1,87 @@
+createOne();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a page is deleted, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testPageDeletedSendsDiscordNotification(): void
+ {
+ $page = Page::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $page->delete();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a page is restored, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testPageRestoredSendsDiscordNotification(): void
+ {
+ $page = Page::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $page->restore();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a page is updated, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testPageUpdatedSendsDiscordNotification(): void
+ {
+ $page = Page::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $changes = Page::factory()->makeOne();
+
+ $page->fill($changes->getAttributes());
+ $page->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Pivot/AnimeImageTest.php b/tests/Feature/Jobs/Pivot/AnimeImageTest.php
new file mode 100644
index 000000000..f4a94211d
--- /dev/null
+++ b/tests/Feature/Jobs/Pivot/AnimeImageTest.php
@@ -0,0 +1,57 @@
+createOne();
+ $image = Image::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $anime->images()->attach($image);
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an Anime is detached from an Image or vice versa, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnimeImageDeletedSendsDiscordNotification(): void
+ {
+ $anime = Anime::factory()->createOne();
+ $image = Image::factory()->createOne();
+
+ $anime->images()->attach($image);
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $anime->images()->detach($image);
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Pivot/AnimeResourceTest.php b/tests/Feature/Jobs/Pivot/AnimeResourceTest.php
new file mode 100644
index 000000000..2c0794c65
--- /dev/null
+++ b/tests/Feature/Jobs/Pivot/AnimeResourceTest.php
@@ -0,0 +1,87 @@
+createOne();
+ $resource = ExternalResource::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $anime->resources()->attach($resource);
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an Anime is detached from a Resource or vice versa, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnimeResourceDeletedSendsDiscordNotification(): void
+ {
+ $anime = Anime::factory()->createOne();
+ $resource = ExternalResource::factory()->createOne();
+
+ $anime->resources()->attach($resource);
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $anime->resources()->detach($resource);
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an Anime Resource pivot is updated, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnimeResourceUpdatedSendsDiscordNotification(): void
+ {
+ $anime = Anime::factory()->createOne();
+ $resource = ExternalResource::factory()->createOne();
+
+ $animeResource = AnimeResource::factory()
+ ->for($anime, 'anime')
+ ->for($resource, 'resource')
+ ->createOne();
+
+ $changes = AnimeResource::factory()
+ ->for($anime, 'anime')
+ ->for($resource, 'resource')
+ ->makeOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $animeResource->fill($changes->getAttributes());
+ $animeResource->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Pivot/AnimeSeriesTest.php b/tests/Feature/Jobs/Pivot/AnimeSeriesTest.php
new file mode 100644
index 000000000..a524a1e2a
--- /dev/null
+++ b/tests/Feature/Jobs/Pivot/AnimeSeriesTest.php
@@ -0,0 +1,57 @@
+createOne();
+ $series = Series::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $anime->series()->attach($series);
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an Anime is detached from a Series or vice versa, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnimeSeriesDeletedSendsDiscordNotification(): void
+ {
+ $anime = Anime::factory()->createOne();
+ $series = Series::factory()->createOne();
+
+ $anime->series()->attach($series);
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $anime->series()->detach($series);
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Pivot/AnimeStudioTest.php b/tests/Feature/Jobs/Pivot/AnimeStudioTest.php
new file mode 100644
index 000000000..1f6884028
--- /dev/null
+++ b/tests/Feature/Jobs/Pivot/AnimeStudioTest.php
@@ -0,0 +1,57 @@
+createOne();
+ $studio = Studio::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $anime->studios()->attach($studio);
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an Anime is detached from a Studio or vice versa, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnimeStudioDeletedSendsDiscordNotification(): void
+ {
+ $anime = Anime::factory()->createOne();
+ $studio = Studio::factory()->createOne();
+
+ $anime->studios()->attach($studio);
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $anime->studios()->detach($studio);
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Pivot/AnimeThemeEntryVideoTest.php b/tests/Feature/Jobs/Pivot/AnimeThemeEntryVideoTest.php
new file mode 100644
index 000000000..20c4c6543
--- /dev/null
+++ b/tests/Feature/Jobs/Pivot/AnimeThemeEntryVideoTest.php
@@ -0,0 +1,63 @@
+createOne();
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $video->animethemeentries()->attach($entry);
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a Video is detached from an AnimeThemeEntry or vice versa, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnimeThemeEntryVideoDeletedSendsDiscordNotification(): void
+ {
+ $video = Video::factory()->createOne();
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ $video->animethemeentries()->attach($entry);
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $video->animethemeentries()->detach($entry);
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Pivot/ArtistImageTest.php b/tests/Feature/Jobs/Pivot/ArtistImageTest.php
new file mode 100644
index 000000000..fd2903199
--- /dev/null
+++ b/tests/Feature/Jobs/Pivot/ArtistImageTest.php
@@ -0,0 +1,57 @@
+createOne();
+ $image = Image::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $artist->images()->attach($image);
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an Artist is detached from an Image or vice versa, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testArtistImageDeletedSendsDiscordNotification(): void
+ {
+ $artist = Artist::factory()->createOne();
+ $image = Image::factory()->createOne();
+
+ $artist->images()->attach($image);
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $artist->images()->detach($image);
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Pivot/ArtistMemberTest.php b/tests/Feature/Jobs/Pivot/ArtistMemberTest.php
new file mode 100644
index 000000000..b43dc4942
--- /dev/null
+++ b/tests/Feature/Jobs/Pivot/ArtistMemberTest.php
@@ -0,0 +1,86 @@
+createOne();
+ $member = Artist::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $artist->members()->attach($member);
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an Artist is detached from a Member or vice versa, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testArtistMemberDeletedSendsDiscordNotification(): void
+ {
+ $artist = Artist::factory()->createOne();
+ $member = Artist::factory()->createOne();
+
+ $artist->members()->attach($member);
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $artist->members()->detach($member);
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an Artist Member pivot is updated, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testArtistMemberUpdatedSendsDiscordNotification(): void
+ {
+ $artist = Artist::factory()->createOne();
+ $member = Artist::factory()->createOne();
+
+ $artistMember = ArtistMember::factory()
+ ->for($artist, 'artist')
+ ->for($member, 'member')
+ ->createOne();
+
+ $changes = ArtistMember::factory()
+ ->for($artist, 'artist')
+ ->for($member, 'member')
+ ->makeOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $artistMember->fill($changes->getAttributes());
+ $artistMember->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Pivot/ArtistResourceTest.php b/tests/Feature/Jobs/Pivot/ArtistResourceTest.php
new file mode 100644
index 000000000..0bde13f49
--- /dev/null
+++ b/tests/Feature/Jobs/Pivot/ArtistResourceTest.php
@@ -0,0 +1,87 @@
+createOne();
+ $resource = ExternalResource::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $artist->resources()->attach($resource);
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an Artist is detached from a Resource or vice versa, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testArtistResourceDeletedSendsDiscordNotification(): void
+ {
+ $artist = Artist::factory()->createOne();
+ $resource = ExternalResource::factory()->createOne();
+
+ $artist->resources()->attach($resource);
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $artist->resources()->detach($resource);
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an Artist Resource pivot is updated, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testArtistResourceUpdatedSendsDiscordNotification(): void
+ {
+ $artist = Artist::factory()->createOne();
+ $resource = ExternalResource::factory()->createOne();
+
+ $artistResource = ArtistResource::factory()
+ ->for($artist, 'artist')
+ ->for($resource, 'resource')
+ ->createOne();
+
+ $changes = ArtistResource::factory()
+ ->for($artist, 'artist')
+ ->for($resource, 'resource')
+ ->makeOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $artistResource->fill($changes->getAttributes());
+ $artistResource->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Pivot/ArtistSongTest.php b/tests/Feature/Jobs/Pivot/ArtistSongTest.php
new file mode 100644
index 000000000..7bfcae85f
--- /dev/null
+++ b/tests/Feature/Jobs/Pivot/ArtistSongTest.php
@@ -0,0 +1,87 @@
+createOne();
+ $song = Song::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $artist->songs()->attach($song);
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an Artist is detached from a Song or vice versa, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testArtistSongDeletedSendsDiscordNotification(): void
+ {
+ $artist = Artist::factory()->createOne();
+ $song = Song::factory()->createOne();
+
+ $artist->songs()->attach($song);
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $artist->songs()->detach($song);
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an Artist Song pivot is updated, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testArtistSongUpdatedSendsDiscordNotification(): void
+ {
+ $artist = Artist::factory()->createOne();
+ $song = Song::factory()->createOne();
+
+ $artistSong = ArtistSong::factory()
+ ->for($artist, 'artist')
+ ->for($song, 'song')
+ ->createOne();
+
+ $changes = ArtistSong::factory()
+ ->for($artist, 'artist')
+ ->for($song, 'song')
+ ->makeOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $artistSong->fill($changes->getAttributes());
+ $artistSong->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Pivot/StudioResourceTest.php b/tests/Feature/Jobs/Pivot/StudioResourceTest.php
new file mode 100644
index 000000000..abcb3e537
--- /dev/null
+++ b/tests/Feature/Jobs/Pivot/StudioResourceTest.php
@@ -0,0 +1,87 @@
+createOne();
+ $resource = ExternalResource::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $studio->resources()->attach($resource);
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a Studio is detached from a Resource or vice versa, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testStudioResourceDeletedSendsDiscordNotification(): void
+ {
+ $studio = Studio::factory()->createOne();
+ $resource = ExternalResource::factory()->createOne();
+
+ $studio->resources()->attach($resource);
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $studio->resources()->detach($resource);
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a Studio Resource pivot is updated, a SendDiscordNotification job will be dispatched.
+ *
+ * @return void
+ */
+ public function testStudioResourceUpdatedSendsDiscordNotification(): void
+ {
+ $studio = Studio::factory()->createOne();
+ $resource = ExternalResource::factory()->createOne();
+
+ $studioResource = StudioResource::factory()
+ ->for($studio, 'studio')
+ ->for($resource, 'resource')
+ ->createOne();
+
+ $changes = StudioResource::factory()
+ ->for($studio, 'studio')
+ ->for($resource, 'resource')
+ ->makeOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $studioResource->fill($changes->getAttributes());
+ $studioResource->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/SendDiscordNotificationTest.php b/tests/Feature/Jobs/SendDiscordNotificationTest.php
new file mode 100644
index 000000000..68c1396a3
--- /dev/null
+++ b/tests/Feature/Jobs/SendDiscordNotificationTest.php
@@ -0,0 +1,107 @@
+handle();
+
+ Notification::assertSentTo(
+ new AnonymousNotifiable(),
+ DiscordNotification::class,
+ );
+ }
+
+ /**
+ * The Send Discord Notification Job shall use the RateLimited middleware.
+ *
+ * @return void
+ */
+ public function testRateLimited(): void
+ {
+ $event = new class implements DiscordMessageEvent
+ {
+ use Dispatchable;
+
+ /**
+ * Get Discord message payload.
+ *
+ * @return DiscordMessage
+ */
+ public function getDiscordMessage(): DiscordMessage
+ {
+ return DiscordMessage::create();
+ }
+
+ /**
+ * Get Discord channel the message will be sent to.
+ *
+ * @return string
+ */
+ public function getDiscordChannel(): string
+ {
+ return '';
+ }
+ };
+
+ $job = new SendDiscordNotificationJob($event);
+
+ $middleware = collect($job->middleware())->first();
+
+ static::assertInstanceOf(RateLimited::class, $middleware);
+ }
+}
diff --git a/tests/Feature/Jobs/Wiki/Anime/SynonymTest.php b/tests/Feature/Jobs/Wiki/Anime/SynonymTest.php
new file mode 100644
index 000000000..46aea72db
--- /dev/null
+++ b/tests/Feature/Jobs/Wiki/Anime/SynonymTest.php
@@ -0,0 +1,98 @@
+createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ AnimeSynonym::factory()->for($anime)->createOne();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a synonym is deleted, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testSynonymDeletedSendsDiscordNotification(): void
+ {
+ $synonym = AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $synonym->delete();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a synonym is restored, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testSynonymRestoredSendsDiscordNotification(): void
+ {
+ $synonym = AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $synonym->restore();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a synonym is updated, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testSynonymUpdatedSendsDiscordNotification(): void
+ {
+ $synonym = AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ $changes = AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->makeOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $synonym->fill($changes->getAttributes());
+ $synonym->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Wiki/Anime/Theme/EntryTest.php b/tests/Feature/Jobs/Wiki/Anime/Theme/EntryTest.php
new file mode 100644
index 000000000..7db870165
--- /dev/null
+++ b/tests/Feature/Jobs/Wiki/Anime/Theme/EntryTest.php
@@ -0,0 +1,101 @@
+for(Anime::factory())
+ ->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ AnimeThemeEntry::factory()->for($theme)->createOne();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an entry is deleted, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testEntryDeletedSendsDiscordNotification(): void
+ {
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $entry->delete();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an entry is restored, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testEntryRestoredSendsDiscordNotification(): void
+ {
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $entry->restore();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an entry is updated, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testEntryUpdatedSendsDiscordNotification(): void
+ {
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ $changes = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->makeOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $entry->fill($changes->getAttributes());
+ $entry->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Wiki/Anime/ThemeTest.php b/tests/Feature/Jobs/Wiki/Anime/ThemeTest.php
new file mode 100644
index 000000000..8b17bf35c
--- /dev/null
+++ b/tests/Feature/Jobs/Wiki/Anime/ThemeTest.php
@@ -0,0 +1,98 @@
+createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ AnimeTheme::factory()->for($anime)->createOne();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a theme is deleted, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testThemeDeletedSendsDiscordNotification(): void
+ {
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $theme->delete();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a theme is restored, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testThemeRestoredSendsDiscordNotification(): void
+ {
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $theme->restore();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a theme is updated, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testThemeUpdatedSendsDiscordNotification(): void
+ {
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ $changes = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->makeOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $theme->fill($changes->getAttributes());
+ $theme->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Wiki/AnimeTest.php b/tests/Feature/Jobs/Wiki/AnimeTest.php
new file mode 100644
index 000000000..7a7c04d9a
--- /dev/null
+++ b/tests/Feature/Jobs/Wiki/AnimeTest.php
@@ -0,0 +1,87 @@
+createOne();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an anime is deleted, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnimeDeletedSendsDiscordNotification(): void
+ {
+ $anime = Anime::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $anime->delete();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an anime is restored, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnimeRestoredSendsDiscordNotification(): void
+ {
+ $anime = Anime::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $anime->restore();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an anime is updated, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testAnimeUpdatedSendsDiscordNotification(): void
+ {
+ $anime = Anime::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $changes = Anime::factory()->makeOne();
+
+ $anime->fill($changes->getAttributes());
+ $anime->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Wiki/ArtistTest.php b/tests/Feature/Jobs/Wiki/ArtistTest.php
new file mode 100644
index 000000000..056e5ca9c
--- /dev/null
+++ b/tests/Feature/Jobs/Wiki/ArtistTest.php
@@ -0,0 +1,87 @@
+createOne();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an artist is deleted, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testArtistDeletedSendsDiscordNotification(): void
+ {
+ $artist = Artist::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $artist->delete();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an artist is restored, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testArtistRestoredSendsDiscordNotification(): void
+ {
+ $artist = Artist::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $artist->restore();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an artist is updated, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testArtistUpdatedSendsDiscordNotification(): void
+ {
+ $artist = Artist::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $changes = Artist::factory()->makeOne();
+
+ $artist->fill($changes->getAttributes());
+ $artist->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Wiki/ExternalResourceTest.php b/tests/Feature/Jobs/Wiki/ExternalResourceTest.php
new file mode 100644
index 000000000..32efeccf2
--- /dev/null
+++ b/tests/Feature/Jobs/Wiki/ExternalResourceTest.php
@@ -0,0 +1,87 @@
+createOne();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a resource is deleted, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testResourceDeletedSendsDiscordNotification(): void
+ {
+ $resource = ExternalResource::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $resource->delete();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a resource is restored, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testResourceRestoredSendsDiscordNotification(): void
+ {
+ $resource = ExternalResource::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $resource->restore();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a resource is updated, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testResourceUpdatedSendsDiscordNotification(): void
+ {
+ $resource = ExternalResource::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $changes = ExternalResource::factory()->makeOne();
+
+ $resource->fill($changes->getAttributes());
+ $resource->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Wiki/ImageTest.php b/tests/Feature/Jobs/Wiki/ImageTest.php
new file mode 100644
index 000000000..467b20b6a
--- /dev/null
+++ b/tests/Feature/Jobs/Wiki/ImageTest.php
@@ -0,0 +1,87 @@
+createOne();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an image is deleted, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testImageDeletedSendsDiscordNotification(): void
+ {
+ $image = Image::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $image->delete();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an image is restored, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testImageRestoredSendsDiscordNotification(): void
+ {
+ $image = Image::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $image->restore();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When an image is updated, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testImageUpdatedSendsDiscordNotification(): void
+ {
+ $image = Image::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $changes = Image::factory()->makeOne();
+
+ $image->fill($changes->getAttributes());
+ $image->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Wiki/SeriesTest.php b/tests/Feature/Jobs/Wiki/SeriesTest.php
new file mode 100644
index 000000000..dba3ae93b
--- /dev/null
+++ b/tests/Feature/Jobs/Wiki/SeriesTest.php
@@ -0,0 +1,87 @@
+createOne();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a series is deleted, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testSeriesDeletedSendsDiscordNotification(): void
+ {
+ $series = Series::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $series->delete();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a series is restored, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testSeriesRestoredSendsDiscordNotification(): void
+ {
+ $series = Series::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $series->restore();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a series is updated, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testSeriesUpdatedSendsDiscordNotification(): void
+ {
+ $series = Series::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $changes = Series::factory()->makeOne();
+
+ $series->fill($changes->getAttributes());
+ $series->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Wiki/SongTest.php b/tests/Feature/Jobs/Wiki/SongTest.php
new file mode 100644
index 000000000..ff5055a63
--- /dev/null
+++ b/tests/Feature/Jobs/Wiki/SongTest.php
@@ -0,0 +1,87 @@
+createOne();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a song is deleted, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testSongDeletedSendsDiscordNotification(): void
+ {
+ $song = Song::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $song->delete();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a song is restored, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testSongRestoredSendsDiscordNotification(): void
+ {
+ $song = Song::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $song->restore();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a song is updated, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testSongUpdatedSendsDiscordNotification(): void
+ {
+ $song = Song::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $changes = Song::factory()->makeOne();
+
+ $song->fill($changes->getAttributes());
+ $song->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Wiki/StudioTest.php b/tests/Feature/Jobs/Wiki/StudioTest.php
new file mode 100644
index 000000000..aaa3ead42
--- /dev/null
+++ b/tests/Feature/Jobs/Wiki/StudioTest.php
@@ -0,0 +1,87 @@
+createOne();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a studio is deleted, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testStudioDeletedSendsDiscordNotification(): void
+ {
+ $studio = Studio::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $studio->delete();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a studio is restored, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testStudioRestoredSendsDiscordNotification(): void
+ {
+ $studio = Studio::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $studio->restore();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a studio is updated, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testStudioUpdatedSendsDiscordNotification(): void
+ {
+ $studio = Studio::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $changes = Studio::factory()->makeOne();
+
+ $studio->fill($changes->getAttributes());
+ $studio->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Jobs/Wiki/VideoTest.php b/tests/Feature/Jobs/Wiki/VideoTest.php
new file mode 100644
index 000000000..531d38203
--- /dev/null
+++ b/tests/Feature/Jobs/Wiki/VideoTest.php
@@ -0,0 +1,87 @@
+createOne();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a video is deleted, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testVideoDeletedSendsDiscordNotification(): void
+ {
+ $video = Video::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $video->delete();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a video is restored, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testVideoRestoredSendsDiscordNotification(): void
+ {
+ $video = Video::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $video->restore();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * When a video is updated, a SendDiscordNotification job shall be dispatched.
+ *
+ * @return void
+ */
+ public function testVideoUpdatedSendsDiscordNotification(): void
+ {
+ $video = Video::factory()->createOne();
+
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $changes = Video::factory()->makeOne();
+
+ $video->fill($changes->getAttributes());
+ $video->save();
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Listeners/SendDiscordNotificationTest.php b/tests/Feature/Listeners/SendDiscordNotificationTest.php
new file mode 100644
index 000000000..2f08f2ba1
--- /dev/null
+++ b/tests/Feature/Listeners/SendDiscordNotificationTest.php
@@ -0,0 +1,107 @@
+handle($event);
+
+ Bus::assertNotDispatched(SendDiscordNotificationJob::class);
+ }
+
+ /**
+ * If discord notifications are enabled through the 'flags.allow_discord_notifications' property,
+ * discord notification jobs shall be dispatched.
+ *
+ * @return void
+ */
+ public function testDiscordNotificationsAllowed(): void
+ {
+ Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true);
+ Bus::fake(SendDiscordNotificationJob::class);
+
+ $event = new class implements DiscordMessageEvent
+ {
+ use Dispatchable;
+
+ /**
+ * Get Discord message payload.
+ *
+ * @return DiscordMessage
+ */
+ public function getDiscordMessage(): DiscordMessage
+ {
+ return DiscordMessage::create();
+ }
+
+ /**
+ * Get Discord channel the message will be sent to.
+ *
+ * @return string
+ */
+ public function getDiscordChannel(): string
+ {
+ return '';
+ }
+ };
+
+ $listener = new SendDiscordNotification();
+
+ $listener->handle($event);
+
+ Bus::assertDispatched(SendDiscordNotificationJob::class);
+ }
+}
diff --git a/tests/Feature/Listeners/Wiki/InitializeVideoTagsTest.php b/tests/Feature/Listeners/Wiki/InitializeVideoTagsTest.php
new file mode 100644
index 000000000..26b7dc13a
--- /dev/null
+++ b/tests/Feature/Listeners/Wiki/InitializeVideoTagsTest.php
@@ -0,0 +1,208 @@
+faker->randomDigitNotNull()))
+ ->append('-')
+ ->append($this->faker->boolean() ? 'OP' : 'ED')
+ ->__toString();
+
+ $video = Video::factory()->createOne([
+ Video::ATTRIBUTE_FILENAME => $filename,
+ Video::ATTRIBUTE_LYRICS => false,
+ Video::ATTRIBUTE_NC => false,
+ Video::ATTRIBUTE_RESOLUTION => null,
+ Video::ATTRIBUTE_SOURCE => null,
+ Video::ATTRIBUTE_SUBBED => false,
+ ]);
+
+ static::assertFalse($video->nc);
+ static::assertFalse($video->subbed);
+ static::assertFalse($video->lyrics);
+ static::assertNull($video->resolution);
+ static::assertNull($video->source);
+ }
+
+ /**
+ * When a video is created, set the NC tag attribute if contained in the filename.
+ *
+ * @return void
+ */
+ public function testNcTag(): void
+ {
+ $filename = Str::of(Str::random($this->faker->randomDigitNotNull()))
+ ->append('-')
+ ->append($this->faker->boolean() ? 'OP' : 'ED')
+ ->append('-')
+ ->append('NC')
+ ->__toString();
+
+ $video = Video::factory()->createOne([
+ Video::ATTRIBUTE_FILENAME => $filename,
+ Video::ATTRIBUTE_LYRICS => false,
+ Video::ATTRIBUTE_NC => false,
+ Video::ATTRIBUTE_RESOLUTION => null,
+ Video::ATTRIBUTE_SOURCE => null,
+ Video::ATTRIBUTE_SUBBED => false,
+ ]);
+
+ static::assertTrue($video->nc);
+ }
+
+ /**
+ * When a video is created, set the Subbed tag attribute if contained in the filename.
+ *
+ * @return void
+ */
+ public function testSubbedTag(): void
+ {
+ $filename = Str::of(Str::random($this->faker->randomDigitNotNull()))
+ ->append('-')
+ ->append($this->faker->boolean() ? 'OP' : 'ED')
+ ->append('-')
+ ->append('Subbed')
+ ->__toString();
+
+ $video = Video::factory()->createOne([
+ Video::ATTRIBUTE_FILENAME => $filename,
+ Video::ATTRIBUTE_LYRICS => false,
+ Video::ATTRIBUTE_NC => false,
+ Video::ATTRIBUTE_RESOLUTION => null,
+ Video::ATTRIBUTE_SOURCE => null,
+ Video::ATTRIBUTE_SUBBED => false,
+ ]);
+
+ static::assertTrue($video->subbed);
+ }
+
+ /**
+ * When a video is created, set the Lyrics tag attribute if contained in the filename.
+ *
+ * @return void
+ */
+ public function testLyricsTag(): void
+ {
+ $filename = Str::of(Str::random($this->faker->randomDigitNotNull()))
+ ->append('-')
+ ->append($this->faker->boolean() ? 'OP' : 'ED')
+ ->append('-')
+ ->append('Lyrics')
+ ->__toString();
+
+ $video = Video::factory()->createOne([
+ Video::ATTRIBUTE_FILENAME => $filename,
+ Video::ATTRIBUTE_LYRICS => false,
+ Video::ATTRIBUTE_NC => false,
+ Video::ATTRIBUTE_RESOLUTION => null,
+ Video::ATTRIBUTE_SOURCE => null,
+ Video::ATTRIBUTE_SUBBED => false,
+ ]);
+
+ static::assertTrue($video->lyrics);
+ }
+
+ /**
+ * When a video is created, set the Resolution tag attribute if contained in the filename.
+ *
+ * @return void
+ */
+ public function testResolutionTag(): void
+ {
+ $resolution = $this->faker->randomNumber();
+
+ $filename = Str::of(Str::random($this->faker->randomDigitNotNull()))
+ ->append('-')
+ ->append($this->faker->boolean() ? 'OP' : 'ED')
+ ->append('-')
+ ->append($resolution)
+ ->__toString();
+
+ $video = Video::factory()->createOne([
+ Video::ATTRIBUTE_FILENAME => $filename,
+ Video::ATTRIBUTE_LYRICS => false,
+ Video::ATTRIBUTE_NC => false,
+ Video::ATTRIBUTE_RESOLUTION => null,
+ Video::ATTRIBUTE_SOURCE => null,
+ Video::ATTRIBUTE_SUBBED => false,
+ ]);
+
+ static::assertEquals($resolution, $video->resolution);
+ }
+
+ /**
+ * When a video is created, set the Resolution tag attribute if implicitly contained in the filename.
+ *
+ * @return void
+ */
+ public function testImplicitResolutionTag(): void
+ {
+ $filename = Str::of(Str::random($this->faker->randomDigitNotNull()))
+ ->append('-')
+ ->append($this->faker->boolean() ? 'OP' : 'ED')
+ ->append('-')
+ ->append($this->faker->boolean() ? 'NCBD' : 'NCBDLyrics')
+ ->__toString();
+
+ $video = Video::factory()->createOne([
+ Video::ATTRIBUTE_FILENAME => $filename,
+ Video::ATTRIBUTE_LYRICS => false,
+ Video::ATTRIBUTE_NC => false,
+ Video::ATTRIBUTE_RESOLUTION => null,
+ Video::ATTRIBUTE_SOURCE => null,
+ Video::ATTRIBUTE_SUBBED => false,
+ ]);
+
+ static::assertEquals(720, $video->resolution);
+ }
+
+ /**
+ * When a video is created, set the Source tag attribute if contained in the filename.
+ *
+ * @return void
+ */
+ public function testSourceTag(): void
+ {
+ $source = VideoSource::getRandomInstance();
+
+ $filename = Str::of(Str::random($this->faker->randomDigitNotNull()))
+ ->append('-')
+ ->append($this->faker->boolean() ? 'OP' : 'ED')
+ ->append('-')
+ ->append($source->key)
+ ->__toString();
+
+ $video = Video::factory()->createOne([
+ Video::ATTRIBUTE_FILENAME => $filename,
+ Video::ATTRIBUTE_LYRICS => false,
+ Video::ATTRIBUTE_NC => false,
+ Video::ATTRIBUTE_RESOLUTION => null,
+ Video::ATTRIBUTE_SOURCE => null,
+ Video::ATTRIBUTE_SUBBED => false,
+ ]);
+
+ static::assertEquals($source, $video->source);
+ }
+}
diff --git a/tests/TestCase.php b/tests/TestCase.php
index 2932d4a69..2af82e9d1 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -1,10 +1,30 @@
faker->numberBetween(0, 2)) extends BaseEnum
+ {
+ public const ZERO = 0;
+ public const ONE = 1;
+ public const TWO = 2;
+ };
+
+ $fromDescription = $enum::fromDescription($enum->description);
+
+ static::assertInstanceOf(get_class($enum), $fromDescription);
+ }
+
+ /**
+ * If the provided description does not match an instance, an enum instance shall not be resolved.
+ *
+ * @return void
+ */
+ public function testFromDescriptionNullable(): void
+ {
+ $enum = new class($this->faker->numberBetween(0, 2)) extends BaseEnum
+ {
+ public const ZERO = 0;
+ public const ONE = 1;
+ public const TWO = 2;
+ };
+
+ $fromDescription = $enum::fromDescription(Str::random());
+
+ static::assertNull($fromDescription);
+ }
+}
diff --git a/tests/Unit/Events/CascadesDeletesTest.php b/tests/Unit/Events/CascadesDeletesTest.php
new file mode 100644
index 000000000..f27f2c653
--- /dev/null
+++ b/tests/Unit/Events/CascadesDeletesTest.php
@@ -0,0 +1,28 @@
+assertListening(CascadesDeletesEvent::class, CascadesDeletes::class);
+ }
+}
diff --git a/tests/Unit/Events/CascadesRestoresTest.php b/tests/Unit/Events/CascadesRestoresTest.php
new file mode 100644
index 000000000..d89dee901
--- /dev/null
+++ b/tests/Unit/Events/CascadesRestoresTest.php
@@ -0,0 +1,28 @@
+assertListening(CascadesRestoresEvent::class, CascadesRestores::class);
+ }
+}
diff --git a/tests/Unit/Events/NotifiesNovaUsersTest.php b/tests/Unit/Events/NotifiesNovaUsersTest.php
new file mode 100644
index 000000000..ea4c23b65
--- /dev/null
+++ b/tests/Unit/Events/NotifiesNovaUsersTest.php
@@ -0,0 +1,28 @@
+assertListening(NovaNotificationEvent::class, NotifiesNovaUsers::class);
+ }
+}
diff --git a/tests/Unit/Events/UpdateRelatedIndicesTest.php b/tests/Unit/Events/UpdateRelatedIndicesTest.php
new file mode 100644
index 000000000..b1c89f4ec
--- /dev/null
+++ b/tests/Unit/Events/UpdateRelatedIndicesTest.php
@@ -0,0 +1,28 @@
+assertListening(UpdateRelatedIndicesEvent::class, UpdateRelatedIndices::class);
+ }
+}
diff --git a/tests/Unit/Http/Api/Criteria/Field/CriteriaTest.php b/tests/Unit/Http/Api/Criteria/Field/CriteriaTest.php
new file mode 100644
index 000000000..84ef40055
--- /dev/null
+++ b/tests/Unit/Http/Api/Criteria/Field/CriteriaTest.php
@@ -0,0 +1,46 @@
+faker->words($this->faker->randomDigitNotNull()));
+
+ $criteria = new Criteria($this->faker->word(), $fields);
+
+ static::assertTrue($criteria->isAllowedField($fields->random()));
+ }
+
+ /**
+ * The field criteria shall return false if the field is not allowed.
+ *
+ * @return void
+ */
+ public function testIsNotAllowed(): void
+ {
+ $fields = collect($this->faker->words($this->faker->randomDigitNotNull()));
+
+ $criteria = new Criteria($this->faker->word(), $fields);
+
+ static::assertFalse($criteria->isAllowedField(Str::random()));
+ }
+}
diff --git a/tests/Unit/Http/Api/Criteria/Filter/CriteriaTest.php b/tests/Unit/Http/Api/Criteria/Filter/CriteriaTest.php
new file mode 100644
index 000000000..e7f653506
--- /dev/null
+++ b/tests/Unit/Http/Api/Criteria/Filter/CriteriaTest.php
@@ -0,0 +1,322 @@
+faker->word());
+ $predicate = new Predicate($this->faker->word(), ComparisonOperator::getRandomInstance(), $expression);
+ $scope = new GlobalScope();
+
+ $criteria = new FakeCriteria($predicate, BinaryLogicalOperator::getRandomInstance(), $scope);
+
+ $filter = new class($this->faker->word()) extends Filter
+ {
+ /**
+ * Convert filter values to integers.
+ *
+ * @param array $filterValues
+ * @return array
+ */
+ protected function convertFilterValues(array $filterValues): array
+ {
+ return $filterValues;
+ }
+
+ /**
+ * Get only filter values that are integers.
+ *
+ * @param array $filterValues
+ * @return array
+ */
+ protected function getValidFilterValues(array $filterValues): array
+ {
+ return $filterValues;
+ }
+
+ /**
+ * Determine if all valid filter values have been specified.
+ * By default, this is false as we assume an unrestricted amount of valid values.
+ *
+ * @param array $filterValues
+ * @return bool
+ */
+ public function isAllFilterValues(array $filterValues): bool
+ {
+ return false;
+ }
+
+ /**
+ * Get the validation rules for the filter.
+ *
+ * @return array
+ */
+ public function getRules(): array
+ {
+ return [];
+ }
+
+ /**
+ * Get the allowed comparison operators for the filter.
+ *
+ * @return ComparisonOperator[]
+ */
+ public function getAllowedComparisonOperators(): array
+ {
+ return [];
+ }
+ };
+
+ static::assertFalse($criteria->shouldFilter($filter, $criteria->getScope()));
+ }
+
+ /**
+ * If the criteria and filter keys match, the filter should be applied.
+ *
+ * @return void
+ */
+ public function testShouldFilterIfKeyMatch(): void
+ {
+ $key = $this->faker->word();
+
+ $expression = new Expression($this->faker->word());
+ $predicate = new Predicate($key, ComparisonOperator::getRandomInstance(), $expression);
+ $scope = new GlobalScope();
+
+ $criteria = new FakeCriteria($predicate, BinaryLogicalOperator::getRandomInstance(), $scope);
+
+ $filter = new class($key) extends Filter
+ {
+ /**
+ * Convert filter values to integers.
+ *
+ * @param array $filterValues
+ * @return array
+ */
+ protected function convertFilterValues(array $filterValues): array
+ {
+ return $filterValues;
+ }
+
+ /**
+ * Get only filter values that are integers.
+ *
+ * @param array $filterValues
+ * @return array
+ */
+ protected function getValidFilterValues(array $filterValues): array
+ {
+ return $filterValues;
+ }
+
+ /**
+ * Determine if all valid filter values have been specified.
+ * By default, this is false as we assume an unrestricted amount of valid values.
+ *
+ * @param array $filterValues
+ * @return bool
+ */
+ public function isAllFilterValues(array $filterValues): bool
+ {
+ return false;
+ }
+
+ /**
+ * Get the validation rules for the filter.
+ *
+ * @return array
+ */
+ public function getRules(): array
+ {
+ return [];
+ }
+
+ /**
+ * Get the allowed comparison operators for the filter.
+ *
+ * @return ComparisonOperator[]
+ */
+ public function getAllowedComparisonOperators(): array
+ {
+ return [];
+ }
+ };
+
+ static::assertTrue($criteria->shouldFilter($filter, $criteria->getScope()));
+ }
+
+ /**
+ * If the criteria and filter keys match, the filter should be applied.
+ *
+ * @return void
+ */
+ public function testShouldNotFilterIfNotWithinScope(): void
+ {
+ $key = $this->faker->word();
+
+ $expression = new Expression($this->faker->word());
+ $predicate = new Predicate($key, ComparisonOperator::getRandomInstance(), $expression);
+ $scope = new TypeScope($this->faker->word());
+
+ $criteria = new FakeCriteria($predicate, BinaryLogicalOperator::getRandomInstance(), $scope);
+
+ $filter = new class($key) extends Filter
+ {
+ /**
+ * Convert filter values to integers.
+ *
+ * @param array $filterValues
+ * @return array
+ */
+ protected function convertFilterValues(array $filterValues): array
+ {
+ return $filterValues;
+ }
+
+ /**
+ * Get only filter values that are integers.
+ *
+ * @param array $filterValues
+ * @return array
+ */
+ protected function getValidFilterValues(array $filterValues): array
+ {
+ return $filterValues;
+ }
+
+ /**
+ * Determine if all valid filter values have been specified.
+ * By default, this is false as we assume an unrestricted amount of valid values.
+ *
+ * @param array $filterValues
+ * @return bool
+ */
+ public function isAllFilterValues(array $filterValues): bool
+ {
+ return false;
+ }
+
+ /**
+ * Get the validation rules for the filter.
+ *
+ * @return array
+ */
+ public function getRules(): array
+ {
+ return [];
+ }
+
+ /**
+ * Get the allowed comparison operators for the filter.
+ *
+ * @return ComparisonOperator[]
+ */
+ public function getAllowedComparisonOperators(): array
+ {
+ return [];
+ }
+ };
+
+ static::assertFalse($criteria->shouldFilter($filter, new GlobalScope()));
+ }
+
+ /**
+ * If the criteria and filter keys match, the filter should be applied.
+ *
+ * @return void
+ */
+ public function testShouldFilterIfWithinScope(): void
+ {
+ $key = $this->faker->word();
+
+ $expression = new Expression($this->faker->word());
+ $predicate = new Predicate($key, ComparisonOperator::getRandomInstance(), $expression);
+ $scope = new TypeScope(Str::of(Str::random())->lower()->singular()->__toString());
+
+ $criteria = new FakeCriteria($predicate, BinaryLogicalOperator::getRandomInstance(), $scope);
+
+ $filter = new class($key) extends Filter
+ {
+ /**
+ * Convert filter values to integers.
+ *
+ * @param array $filterValues
+ * @return array
+ */
+ protected function convertFilterValues(array $filterValues): array
+ {
+ return $filterValues;
+ }
+
+ /**
+ * Get only filter values that are integers.
+ *
+ * @param array $filterValues
+ * @return array
+ */
+ protected function getValidFilterValues(array $filterValues): array
+ {
+ return $filterValues;
+ }
+
+ /**
+ * Determine if all valid filter values have been specified.
+ * By default, this is false as we assume an unrestricted amount of valid values.
+ *
+ * @param array $filterValues
+ * @return bool
+ */
+ public function isAllFilterValues(array $filterValues): bool
+ {
+ return false;
+ }
+
+ /**
+ * Get the validation rules for the filter.
+ *
+ * @return array
+ */
+ public function getRules(): array
+ {
+ return [];
+ }
+
+ /**
+ * Get the allowed comparison operators for the filter.
+ *
+ * @return ComparisonOperator[]
+ */
+ public function getAllowedComparisonOperators(): array
+ {
+ return [];
+ }
+ };
+
+ static::assertTrue($criteria->shouldFilter($filter, $scope));
+ }
+}
diff --git a/tests/Unit/Http/Api/Criteria/Filter/FakeCriteria.php b/tests/Unit/Http/Api/Criteria/Filter/FakeCriteria.php
new file mode 100644
index 000000000..f0ce87cb4
--- /dev/null
+++ b/tests/Unit/Http/Api/Criteria/Filter/FakeCriteria.php
@@ -0,0 +1,67 @@
+faker->word());
+
+ static::assertEquals(HasCriteria::PARAM_VALUE, $criteria->getField());
+ }
+
+ /**
+ * By default, the Has Criteria shall use the GTE comparison operator.
+ *
+ * @return void
+ */
+ public function testDefaultComparisonOperator(): void
+ {
+ $criteria = HasCriteria::make(new GlobalScope(), HasCriteria::PARAM_VALUE, $this->faker->word());
+
+ static::assertEquals(ComparisonOperator::GTE(), $criteria->getComparisonOperator());
+ }
+
+ /**
+ * The Has Criteria shall parse the comparison operator.
+ *
+ * @return void
+ */
+ public function testComparisonOperator(): void
+ {
+ $operator = ComparisonOperator::getRandomInstance();
+
+ $filterParam = Str::of(HasCriteria::PARAM_VALUE)->append(Criteria::PARAM_SEPARATOR)->append($operator->key)->__toString();
+
+ $criteria = HasCriteria::make(new GlobalScope(), $filterParam, $this->faker->word());
+
+ static::assertEquals($operator, $criteria->getComparisonOperator());
+ }
+
+ /**
+ * By default, the Has Criteria shall have a count of 1.
+ *
+ * @return void
+ */
+ public function testDefaultCount(): void
+ {
+ $criteria = HasCriteria::make(new GlobalScope(), HasCriteria::PARAM_VALUE, $this->faker->word());
+
+ static::assertEquals(1, $criteria->getCount());
+ }
+
+ /**
+ * The Has Criteria shall parse count.
+ *
+ * @return void
+ */
+ public function testCount(): void
+ {
+ $count = $this->faker->randomDigitNotNull();
+
+ $filterParam = Str::of(HasCriteria::PARAM_VALUE)->append(Criteria::PARAM_SEPARATOR)->append($count)->__toString();
+
+ $criteria = HasCriteria::make(new GlobalScope(), $filterParam, $this->faker->word());
+
+ static::assertEquals($count, $criteria->getCount());
+ }
+
+ /**
+ * By default, the Has Criteria shall use the AND logical operator.
+ *
+ * @return void
+ */
+ public function testDefaultLogicalOperator(): void
+ {
+ $criteria = HasCriteria::make(new GlobalScope(), $this->faker->word(), $this->faker->word());
+
+ static::assertEquals(BinaryLogicalOperator::AND(), $criteria->getLogicalOperator());
+ }
+
+ /**
+ * The Has Criteria shall parse the logical operator.
+ *
+ * @return void
+ */
+ public function testLogicalOperator(): void
+ {
+ $operator = BinaryLogicalOperator::getRandomInstance();
+
+ $filterParam = Str::of(HasCriteria::PARAM_VALUE)->append(Criteria::PARAM_SEPARATOR)->append($operator->key)->__toString();
+
+ $criteria = HasCriteria::make(new GlobalScope(), $filterParam, $this->faker->word());
+
+ static::assertEquals($operator, $criteria->getLogicalOperator());
+ }
+}
diff --git a/tests/Unit/Http/Api/Criteria/Filter/TrashedCriteriaTest.php b/tests/Unit/Http/Api/Criteria/Filter/TrashedCriteriaTest.php
new file mode 100644
index 000000000..205dcdaaa
--- /dev/null
+++ b/tests/Unit/Http/Api/Criteria/Filter/TrashedCriteriaTest.php
@@ -0,0 +1,30 @@
+faker->word());
+
+ static::assertEquals(TrashedCriteria::PARAM_VALUE, $criteria->getField());
+ }
+}
diff --git a/tests/Unit/Http/Api/Criteria/Filter/WhereCriteriaTest.php b/tests/Unit/Http/Api/Criteria/Filter/WhereCriteriaTest.php
new file mode 100644
index 000000000..971a7b399
--- /dev/null
+++ b/tests/Unit/Http/Api/Criteria/Filter/WhereCriteriaTest.php
@@ -0,0 +1,92 @@
+faker->word();
+
+ $criteria = WhereCriteria::make(new GlobalScope(), $field, $this->faker->word());
+
+ static::assertEquals($field, $criteria->getField());
+ }
+
+ /**
+ * By default, the Where Criteria shall use the EQ comparison operator.
+ *
+ * @return void
+ */
+ public function testDefaultComparisonOperator(): void
+ {
+ $criteria = WhereCriteria::make(new GlobalScope(), $this->faker->word(), $this->faker->word());
+
+ static::assertEquals(ComparisonOperator::EQ(), $criteria->getComparisonOperator());
+ }
+
+ /**
+ * The Where Criteria shall parse the comparison operator.
+ *
+ * @return void
+ */
+ public function testComparisonOperator(): void
+ {
+ $operator = ComparisonOperator::getRandomInstance();
+
+ $filterParam = Str::of($this->faker->word())->append(Criteria::PARAM_SEPARATOR)->append($operator->key)->__toString();
+
+ $criteria = WhereCriteria::make(new GlobalScope(), $filterParam, $this->faker->word());
+
+ static::assertEquals($operator, $criteria->getComparisonOperator());
+ }
+
+ /**
+ * By default, the Where Criteria shall use the AND logical operator.
+ *
+ * @return void
+ */
+ public function testDefaultLogicalOperator(): void
+ {
+ $criteria = WhereCriteria::make(new GlobalScope(), $this->faker->word(), $this->faker->word());
+
+ static::assertEquals(BinaryLogicalOperator::AND(), $criteria->getLogicalOperator());
+ }
+
+ /**
+ * The Where Criteria shall parse the logical operator.
+ *
+ * @return void
+ */
+ public function testLogicalOperator(): void
+ {
+ $operator = BinaryLogicalOperator::getRandomInstance();
+
+ $filterParam = Str::of($this->faker->word())->append(Criteria::PARAM_SEPARATOR)->append($operator->key)->__toString();
+
+ $criteria = WhereCriteria::make(new GlobalScope(), $filterParam, $this->faker->word());
+
+ static::assertEquals($operator, $criteria->getLogicalOperator());
+ }
+}
diff --git a/tests/Unit/Http/Api/Criteria/Filter/WhereInCriteriaTest.php b/tests/Unit/Http/Api/Criteria/Filter/WhereInCriteriaTest.php
new file mode 100644
index 000000000..d11ba7c0a
--- /dev/null
+++ b/tests/Unit/Http/Api/Criteria/Filter/WhereInCriteriaTest.php
@@ -0,0 +1,117 @@
+faker->word();
+
+ $criteria = WhereInCriteria::make(new GlobalScope(), $field, $this->faker->word());
+
+ static::assertEquals($field, $criteria->getField());
+ }
+
+ /**
+ * The Where In Criteria shall not parse the comparison operator.
+ *
+ * @return void
+ */
+ public function testComparisonOperator(): void
+ {
+ $operator = ComparisonOperator::getRandomInstance();
+
+ $filterParam = Str::of($this->faker->word())
+ ->append('.')
+ ->append($this->faker->word())
+ ->append('.')
+ ->append($operator->key)
+ ->__toString();
+
+ $criteria = WhereInCriteria::make(new GlobalScope(), $filterParam, $this->faker->word());
+
+ static::assertNull($criteria->getComparisonOperator());
+ }
+
+ /**
+ * By default, the Where In Criteria shall use the AND logical operator.
+ *
+ * @return void
+ */
+ public function testDefaultLogicalOperator(): void
+ {
+ $criteria = WhereInCriteria::make(new GlobalScope(), $this->faker->word(), $this->faker->word());
+
+ static::assertEquals(BinaryLogicalOperator::AND(), $criteria->getLogicalOperator());
+ }
+
+ /**
+ * The Where In Criteria shall parse the logical operator.
+ *
+ * @return void
+ */
+ public function testLogicalOperator(): void
+ {
+ $operator = BinaryLogicalOperator::getRandomInstance();
+
+ $filterParam = Str::of($this->faker->word())->append(Criteria::PARAM_SEPARATOR)->append($operator->key)->__toString();
+
+ $criteria = WhereInCriteria::make(new GlobalScope(), $filterParam, $this->faker->word());
+
+ static::assertEquals($operator, $criteria->getLogicalOperator());
+ }
+
+ /**
+ * By default, the Where In Criteria shall not use the NOT operator.
+ *
+ * @return void
+ */
+ public function testDefaultUnaryOperator(): void
+ {
+ $criteria = WhereInCriteria::make(new GlobalScope(), $this->faker->word(), $this->faker->word());
+
+ static::assertFalse($criteria->not());
+ }
+
+ /**
+ * The Where In Criteria shall parse the NOT operator.
+ *
+ * @return void
+ */
+ public function testUnaryOperator(): void
+ {
+ $filterParam = Str::of($this->faker->word())
+ ->append(Criteria::PARAM_SEPARATOR)
+ ->append($this->faker->word())
+ ->append(Criteria::PARAM_SEPARATOR)
+ ->append(UnaryLogicalOperator::NOT)
+ ->__toString();
+
+ $criteria = WhereInCriteria::make(new GlobalScope(), $filterParam, $this->faker->word());
+
+ static::assertTrue($criteria->not());
+ }
+}
diff --git a/tests/Unit/Http/Api/Criteria/Paging/CriteriaTest.php b/tests/Unit/Http/Api/Criteria/Paging/CriteriaTest.php
new file mode 100644
index 000000000..f9fc9ef29
--- /dev/null
+++ b/tests/Unit/Http/Api/Criteria/Paging/CriteriaTest.php
@@ -0,0 +1,129 @@
+faker->numberBetween(1, Criteria::MAX_RESULTS);
+
+ $criteria = new class($resultSize) extends Criteria
+ {
+ /**
+ * Get the intended pagination strategy.
+ *
+ * @return PaginationStrategy
+ */
+ public function getStrategy(): PaginationStrategy
+ {
+ return PaginationStrategy::getRandomInstance();
+ }
+
+ /**
+ * Paginate the query.
+ *
+ * @param Builder $builder
+ * @return Collection|Paginator
+ */
+ public function paginate(Builder $builder): Collection|Paginator
+ {
+ return $builder->paginate();
+ }
+ };
+
+ static::assertEquals($resultSize, $criteria->getResultSize());
+ }
+
+ /**
+ * If the size is greater than the default, the paging Criteria shall return the default.
+ *
+ * @return void
+ */
+ public function testUpperBoundSize(): void
+ {
+ $resultSize = Criteria::MAX_RESULTS + $this->faker->randomDigitNotNull();
+
+ $criteria = new class($resultSize) extends Criteria
+ {
+ /**
+ * Get the intended pagination strategy.
+ *
+ * @return PaginationStrategy
+ */
+ public function getStrategy(): PaginationStrategy
+ {
+ return PaginationStrategy::getRandomInstance();
+ }
+
+ /**
+ * Paginate the query.
+ *
+ * @param Builder $builder
+ * @return Collection|Paginator
+ */
+ public function paginate(Builder $builder): Collection|Paginator
+ {
+ return $builder->paginate();
+ }
+ };
+
+ static::assertEquals(Criteria::DEFAULT_SIZE, $criteria->getResultSize());
+ }
+
+ /**
+ * If the size is lte to zero, the paging Criteria shall return the default size.
+ *
+ * @return void
+ */
+ public function testLowerBoundSize(): void
+ {
+ $resultSize = $this->faker->randomDigit() * -1;
+
+ $criteria = new class($resultSize) extends Criteria
+ {
+ /**
+ * Get the intended pagination strategy.
+ *
+ * @return PaginationStrategy
+ */
+ public function getStrategy(): PaginationStrategy
+ {
+ return PaginationStrategy::getRandomInstance();
+ }
+
+ /**
+ * Paginate the query.
+ *
+ * @param Builder $builder
+ * @return Collection|Paginator
+ */
+ public function paginate(Builder $builder): Collection|Paginator
+ {
+ return $builder->paginate();
+ }
+ };
+
+ static::assertEquals(Criteria::DEFAULT_SIZE, $criteria->getResultSize());
+ }
+}
diff --git a/tests/Unit/Http/Api/Criteria/Sort/CriteriaTest.php b/tests/Unit/Http/Api/Criteria/Sort/CriteriaTest.php
new file mode 100644
index 000000000..a654a5c7d
--- /dev/null
+++ b/tests/Unit/Http/Api/Criteria/Sort/CriteriaTest.php
@@ -0,0 +1,140 @@
+faker->word()) extends Criteria
+ {
+ /**
+ * Apply criteria to builder.
+ *
+ * @param Builder $builder
+ * @param Sort $sort
+ * @return Builder
+ */
+ public function sort(Builder $builder, Sort $sort): Builder
+ {
+ return $builder;
+ }
+ };
+
+ $sort = new Sort($this->faker->word());
+
+ static::assertFalse($criteria->shouldSort($sort, $criteria->getScope()));
+ }
+
+ /**
+ * If the criteria and sort keys match, the sort should be applied.
+ *
+ * @return void
+ */
+ public function testShouldSortIfKeyMatch(): void
+ {
+ $key = $this->faker->word();
+
+ $criteria = new class(new GlobalScope(), $key) extends Criteria
+ {
+ /**
+ * Apply criteria to builder.
+ *
+ * @param Builder $builder
+ * @param Sort $sort
+ * @return Builder
+ */
+ public function sort(Builder $builder, Sort $sort): Builder
+ {
+ return $builder;
+ }
+ };
+
+ $sort = new Sort($key);
+
+ static::assertTrue($criteria->shouldSort($sort, $criteria->getScope()));
+ }
+
+ /**
+ * If the criteria and sort keys match, the sort should be applied.
+ *
+ * @return void
+ */
+ public function testShouldNotSortIfNotWithinScope(): void
+ {
+ $key = $this->faker->word();
+
+ $scope = new TypeScope($this->faker->word());
+
+ $criteria = new class($scope, $key) extends Criteria
+ {
+ /**
+ * Apply criteria to builder.
+ *
+ * @param Builder $builder
+ * @param Sort $sort
+ * @return Builder
+ */
+ public function sort(Builder $builder, Sort $sort): Builder
+ {
+ return $builder;
+ }
+ };
+
+ $sort = new Sort($key);
+
+ static::assertFalse($criteria->shouldSort($sort, new GlobalScope()));
+ }
+
+ /**
+ * If the criteria and sort keys match, the sort should be applied.
+ *
+ * @return void
+ */
+ public function testShouldSortIfWithinScope(): void
+ {
+ $key = $this->faker->word();
+
+ $scope = new TypeScope(Str::of(Str::random())->lower()->singular()->__toString());
+
+ $criteria = new class($scope, $key) extends Criteria
+ {
+ /**
+ * Apply criteria to builder.
+ *
+ * @param Builder $builder
+ * @param Sort $sort
+ * @return Builder
+ */
+ public function sort(Builder $builder, Sort $sort): Builder
+ {
+ return $builder;
+ }
+ };
+
+ $sort = new Sort($key);
+
+ static::assertTrue($criteria->shouldSort($sort, $scope));
+ }
+}
diff --git a/tests/Unit/Http/Api/Filter/BooleanFilterTest.php b/tests/Unit/Http/Api/Filter/BooleanFilterTest.php
new file mode 100644
index 000000000..b096736b7
--- /dev/null
+++ b/tests/Unit/Http/Api/Filter/BooleanFilterTest.php
@@ -0,0 +1,69 @@
+faker->word();
+
+ $criteria = FakeCriteria::make(new GlobalScope(), $filterField, Str::random());
+
+ $filter = new BooleanFilter($filterField);
+
+ static::assertFalse($criteria->shouldFilter($filter, $criteria->getScope()));
+ }
+
+ /**
+ * If both boolean values are specified for the key, don't apply the filter.
+ *
+ * @return void
+ */
+ public function testShouldNotApplyIfAllBooleans(): void
+ {
+ $filterField = $this->faker->word();
+
+ $criteria = FakeCriteria::make(new GlobalScope(), $filterField, 'true,false');
+
+ $filter = new BooleanFilter($filterField);
+
+ static::assertFalse($criteria->shouldFilter($filter, $criteria->getScope()));
+ }
+
+ /**
+ * The boolean filter shall convert validated boolean options to boolean values.
+ *
+ * @return void
+ */
+ public function testConvertsValidatedBoolean(): void
+ {
+ $booleanValue = $this->faker->boolean();
+
+ $filter = new BooleanFilter($this->faker->word());
+
+ $filterValues = $filter->getFilterValues(Arr::wrap($booleanValue ? 'true' : 'false'));
+
+ static::assertEquals($booleanValue, $filterValues[0]);
+ }
+}
diff --git a/tests/Unit/Http/Api/Filter/DateFilterTest.php b/tests/Unit/Http/Api/Filter/DateFilterTest.php
new file mode 100644
index 000000000..a15089df0
--- /dev/null
+++ b/tests/Unit/Http/Api/Filter/DateFilterTest.php
@@ -0,0 +1,96 @@
+faker->word();
+
+ $criteria = FakeCriteria::make(new GlobalScope(), $filterField, $this->faker->words($this->faker->randomDigitNotNull()));
+
+ $filter = new DateFilter($filterField);
+
+ static::assertFalse($criteria->shouldFilter($filter, $criteria->getScope()));
+ }
+
+ /**
+ * If values do not use allowed date formats, don't apply the filter.
+ *
+ * @return void
+ */
+ public function testShouldNotApplyIfWrongFormat(): void
+ {
+ $filterField = $this->faker->word();
+
+ $criteria = FakeCriteria::make(new GlobalScope(), $filterField, Date::now()->format(DateTimeInterface::RFC1036));
+
+ $filter = new DateFilter($filterField);
+
+ static::assertFalse($criteria->shouldFilter($filter, $criteria->getScope()));
+ }
+
+ /**
+ * If values do use allowed date formats, apply the filter.
+ *
+ * @return void
+ */
+ public function testShouldApplyIfAcceptedFormat(): void
+ {
+ $filterField = $this->faker->word();
+
+ $criteria = FakeCriteria::make(new GlobalScope(), $filterField, Date::now()->format(AllowedDateFormat::getRandomValue()));
+
+ $filter = new DateFilter($filterField);
+
+ static::assertTrue($criteria->shouldFilter($filter, $criteria->getScope()));
+ }
+
+ /**
+ * The date filter shall convert validated boolean options to boolean values.
+ *
+ * @return void
+ */
+ public function testConvertsDatesToCanonicalFormat(): void
+ {
+ $filterField = $this->faker->word();
+
+ $dateFormat = AllowedDateFormat::getRandomValue();
+
+ $dateFilter = Date::now()->format($dateFormat);
+
+ $criteria = FakeCriteria::make(new GlobalScope(), $filterField, $dateFilter);
+
+ $filter = new DateFilter($filterField);
+
+ $filterValues = $filter->getFilterValues($criteria->getFilterValues());
+
+ static::assertEquals(
+ DateTime::createFromFormat('!'.$dateFormat, $dateFilter)->format(AllowedDateFormat::YMDHISU),
+ $filterValues[0]
+ );
+ }
+}
diff --git a/tests/Unit/Http/Api/Filter/EnumFilterTest.php b/tests/Unit/Http/Api/Filter/EnumFilterTest.php
new file mode 100644
index 000000000..a411c5c60
--- /dev/null
+++ b/tests/Unit/Http/Api/Filter/EnumFilterTest.php
@@ -0,0 +1,91 @@
+faker->word();
+
+ $enum = new class($this->faker->numberBetween(0, 2)) extends BaseEnum
+ {
+ public const ZERO = 0;
+ public const ONE = 1;
+ public const TWO = 2;
+ };
+
+ $criteria = FakeCriteria::make(new GlobalScope(), $filterField, $this->faker->words($this->faker->randomDigitNotNull()));
+
+ $filter = new EnumFilter($filterField, get_class($enum));
+
+ static::assertFalse($criteria->shouldFilter($filter, $criteria->getScope()));
+ }
+
+ /**
+ * If all enum values are specified for the key, don't apply the filter.
+ *
+ * @return void
+ */
+ public function testShouldNotApplyIfAllEnums(): void
+ {
+ $filterField = $this->faker->word();
+
+ $enum = new class($this->faker->numberBetween(0, 2)) extends BaseEnum
+ {
+ public const ZERO = 0;
+ public const ONE = 1;
+ public const TWO = 2;
+ };
+
+ $criteria = FakeCriteria::make(new GlobalScope(), $filterField, $enum::getValues());
+
+ $filter = new EnumFilter($filterField, get_class($enum));
+
+ static::assertFalse($criteria->shouldFilter($filter, $criteria->getScope()));
+ }
+
+ /**
+ * The enum filter shall convert enum keys to enum values.
+ *
+ * @return void
+ */
+ public function testEnumKeyConvertedToValue(): void
+ {
+ $filterField = $this->faker->word();
+
+ $enum = new class($this->faker->numberBetween(0, 2)) extends BaseEnum
+ {
+ public const ZERO = 0;
+ public const ONE = 1;
+ public const TWO = 2;
+ };
+
+ $criteria = FakeCriteria::make(new GlobalScope(), $filterField, $enum->description);
+
+ $filter = new EnumFilter($filterField, get_class($enum));
+
+ $filterValues = $filter->getFilterValues($criteria->getFilterValues());
+
+ static::assertEquals($enum->value, $filterValues[0]);
+ }
+}
diff --git a/tests/Unit/Http/Api/Filter/FilterTest.php b/tests/Unit/Http/Api/Filter/FilterTest.php
new file mode 100644
index 000000000..b77e5443d
--- /dev/null
+++ b/tests/Unit/Http/Api/Filter/FilterTest.php
@@ -0,0 +1,85 @@
+faker->word()) extends Filter
+ {
+ /**
+ * Convert filter values to integers.
+ *
+ * @param array $filterValues
+ * @return array
+ */
+ protected function convertFilterValues(array $filterValues): array
+ {
+ return $filterValues;
+ }
+
+ /**
+ * Get only filter values that are integers.
+ *
+ * @param array $filterValues
+ * @return array
+ */
+ protected function getValidFilterValues(array $filterValues): array
+ {
+ return $filterValues;
+ }
+
+ /**
+ * Determine if all valid filter values have been specified.
+ * By default, this is false as we assume an unrestricted amount of valid values.
+ *
+ * @param array $filterValues
+ * @return bool
+ */
+ public function isAllFilterValues(array $filterValues): bool
+ {
+ return false;
+ }
+
+ /**
+ * Get the validation rules for the filter.
+ *
+ * @return array
+ */
+ public function getRules(): array
+ {
+ return [];
+ }
+
+ /**
+ * Get the allowed comparison operators for the filter.
+ *
+ * @return ComparisonOperator[]
+ */
+ public function getAllowedComparisonOperators(): array
+ {
+ return [];
+ }
+ };
+
+ static::assertEquals($filter->getKey(), $filter->getColumn());
+ }
+}
diff --git a/tests/Unit/Http/Api/Filter/FloatFilterTest.php b/tests/Unit/Http/Api/Filter/FloatFilterTest.php
new file mode 100644
index 000000000..34200755d
--- /dev/null
+++ b/tests/Unit/Http/Api/Filter/FloatFilterTest.php
@@ -0,0 +1,55 @@
+faker->word();
+
+ $criteria = FakeCriteria::make(new GlobalScope(), $filterField, $this->faker->words($this->faker->randomDigitNotNull()));
+
+ $filter = new FloatFilter($filterField);
+
+ static::assertFalse($criteria->shouldFilter($filter, $criteria->getScope()));
+ }
+
+ /**
+ * The float filter shall convert validated float inputs to float values.
+ *
+ * @return void
+ */
+ public function testConvertsValidatedFloats(): void
+ {
+ $filterField = $this->faker->word();
+
+ $floatValue = $this->faker->randomFloat();
+
+ $criteria = FakeCriteria::make(new GlobalScope(), $filterField, $floatValue);
+
+ $filter = new FloatFilter($filterField);
+
+ $filterValues = $filter->getFilterValues($criteria->getFilterValues());
+
+ static::assertEqualsWithDelta($floatValue, $filterValues[0], 0.0001);
+ }
+}
diff --git a/tests/Unit/Http/Api/Filter/HasFilterTest.php b/tests/Unit/Http/Api/Filter/HasFilterTest.php
new file mode 100644
index 000000000..0d95eff73
--- /dev/null
+++ b/tests/Unit/Http/Api/Filter/HasFilterTest.php
@@ -0,0 +1,133 @@
+faker->randomDigitNotNull(),
+ fn () => new AllowedInclude($schema, $this->faker->word())
+ );
+
+ $filter = new HasFilter($allowedIncludes->all());
+
+ static::assertFalse($criteria->shouldFilter($filter, $criteria->getScope()));
+ }
+
+ /**
+ * If values that are allowed paths are specified for the key, apply the filter.
+ *
+ * @return void
+ */
+ public function testShouldApplyIfAllowedPaths(): void
+ {
+ $schema = new class() extends Schema
+ {
+ /**
+ * Get the type of the resource.
+ *
+ * @return string
+ */
+ public function type(): string
+ {
+ return Str::random();
+ }
+
+ /**
+ * Get the allowed includes.
+ *
+ * @return AllowedInclude[]
+ */
+ public function allowedIncludes(): array
+ {
+ return [];
+ }
+
+ /**
+ * Get the direct fields of the resource.
+ *
+ * @return Field[]
+ *
+ * @noinspection PhpMissingParentCallCommonInspection
+ */
+ public function fields(): array
+ {
+ return [];
+ }
+ };
+
+ $allowedIncludes = Collection::times(
+ $this->faker->randomDigitNotNull(),
+ fn () => new AllowedInclude($schema, $this->faker->word())
+ );
+
+ $criteria = FakeCriteria::make(new GlobalScope(), HasCriteria::PARAM_VALUE, $allowedIncludes->random()->path());
+
+ $filter = new HasFilter($allowedIncludes->all());
+
+ static::assertTrue($criteria->shouldFilter($filter, $criteria->getScope()));
+ }
+}
diff --git a/tests/Unit/Http/Api/Filter/IntFilterTest.php b/tests/Unit/Http/Api/Filter/IntFilterTest.php
new file mode 100644
index 000000000..8a14a7426
--- /dev/null
+++ b/tests/Unit/Http/Api/Filter/IntFilterTest.php
@@ -0,0 +1,55 @@
+faker->word();
+
+ $criteria = FakeCriteria::make(new GlobalScope(), $filterField, $this->faker->words($this->faker->randomDigitNotNull()));
+
+ $filter = new IntFilter($filterField);
+
+ static::assertFalse($criteria->shouldFilter($filter, $criteria->getScope()));
+ }
+
+ /**
+ * The int filter shall convert validated integer inputs to integer values.
+ *
+ * @return void
+ */
+ public function testConvertsValidatedIntegers(): void
+ {
+ $filterField = $this->faker->word();
+
+ $intValue = $this->faker->year();
+
+ $criteria = FakeCriteria::make(new GlobalScope(), $filterField, $intValue);
+
+ $filter = new IntFilter($filterField);
+
+ $filterValues = $filter->getFilterValues($criteria->getFilterValues());
+
+ static::assertEquals(intval($intValue), $filterValues[0]);
+ }
+}
diff --git a/tests/Unit/Http/Api/Parser/FieldParserTest.php b/tests/Unit/Http/Api/Parser/FieldParserTest.php
new file mode 100644
index 000000000..9e30e3ca0
--- /dev/null
+++ b/tests/Unit/Http/Api/Parser/FieldParserTest.php
@@ -0,0 +1,92 @@
+faker()->words($this->faker->randomDigitNotNull()));
+
+ $parameters = [
+ FieldParser::param() => [
+ $this->faker->word() => $fields->join(','),
+ ],
+ ];
+
+ $criteria = FieldParser::parse($parameters)[0];
+
+ static::assertInstanceOf(Criteria::class, $criteria);
+ }
+
+ /**
+ * The Field Parser shall parse criteria type.
+ *
+ * @return void
+ */
+ public function testParseType(): void
+ {
+ $type = $this->faker->word();
+
+ $fields = collect($this->faker()->words($this->faker->randomDigitNotNull()));
+
+ $parameters = [
+ FieldParser::param() => [
+ $type => $fields->join(','),
+ ],
+ ];
+
+ $criteria = FieldParser::parse($parameters)[0];
+
+ static::assertEquals($type, $criteria->getType());
+ }
+
+ /**
+ * The Field Parser shall parse criteria fields.
+ *
+ * @return void
+ */
+ public function testParseFields(): void
+ {
+ $fields = $this->faker()->words($this->faker->randomDigitNotNull());
+
+ $parameters = [
+ FieldParser::param() => [
+ $this->faker->word() => collect($fields)->join(','),
+ ],
+ ];
+
+ $criteria = FieldParser::parse($parameters)[0];
+
+ static::assertEquals($fields, $criteria->getFields()->all());
+ }
+}
diff --git a/tests/Unit/Http/Api/Parser/FilterParserTest.php b/tests/Unit/Http/Api/Parser/FilterParserTest.php
new file mode 100644
index 000000000..5efad7026
--- /dev/null
+++ b/tests/Unit/Http/Api/Parser/FilterParserTest.php
@@ -0,0 +1,152 @@
+ [
+ TrashedCriteria::PARAM_VALUE => $this->faker->word(),
+ ],
+ ];
+
+ $criteria = FilterParser::parse($parameters)[0];
+
+ static::assertInstanceOf(TrashedCriteria::class, $criteria);
+ }
+
+ /**
+ * The Filter Parser shall parse Where In criteria.
+ *
+ * @return void
+ */
+ public function testParseWhereInCriteria(): void
+ {
+ $fields = collect($this->faker()->words());
+
+ $parameters = [
+ FilterParser::param() => [
+ $this->faker->word() => $fields->join(','),
+ ],
+ ];
+
+ $criteria = FilterParser::parse($parameters)[0];
+
+ static::assertInstanceOf(WhereInCriteria::class, $criteria);
+ }
+
+ /**
+ * The Filter Parser shall parse Has Criteria.
+ *
+ * @return void
+ */
+ public function testParseHasCriteria(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ HasCriteria::PARAM_VALUE => $this->faker->word(),
+ ],
+ ];
+
+ $criteria = FilterParser::parse($parameters)[0];
+
+ static::assertInstanceOf(HasCriteria::class, $criteria);
+ }
+
+ /**
+ * The Filter Parser shall parse Where criteria.
+ *
+ * @return void
+ */
+ public function testParseWhereCriteria(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ $this->faker->word() => $this->faker->word(),
+ ],
+ ];
+
+ $criteria = FilterParser::parse($parameters)[0];
+
+ static::assertInstanceOf(WhereCriteria::class, $criteria);
+ }
+
+ /**
+ * The Filter Parser shall parse a global scope if scope is not provided.
+ *
+ * @return void
+ */
+ public function testParseGlobalScope(): void
+ {
+ $parameters = [
+ FilterParser::param() => [
+ $this->faker->word() => $this->faker->word(),
+ ],
+ ];
+
+ $criteria = FilterParser::parse($parameters)[0];
+
+ static::assertInstanceOf(GlobalScope::class, $criteria->getScope());
+ }
+
+ /**
+ * The Filter Parser shall parse a scope if provided.
+ *
+ * @return void
+ */
+ public function testParseTypeScope(): void
+ {
+ $type = Str::singular($this->faker->word());
+
+ $parameters = [
+ FilterParser::param() => [
+ $type => [
+ $this->faker->word() => $this->faker->word(),
+ ],
+ ],
+ ];
+
+ $criteria = FilterParser::parse($parameters)[0];
+
+ $scope = $criteria->getScope();
+
+ static::assertTrue($scope instanceof TypeScope && $scope->getType() === $type);
+ }
+}
diff --git a/tests/Unit/Http/Api/Parser/IncludeParserTest.php b/tests/Unit/Http/Api/Parser/IncludeParserTest.php
new file mode 100644
index 000000000..1d812203a
--- /dev/null
+++ b/tests/Unit/Http/Api/Parser/IncludeParserTest.php
@@ -0,0 +1,132 @@
+faker()->words($this->faker->randomDigitNotNull()));
+
+ $parameters = [
+ IncludeParser::param() => $fields->join(','),
+ ];
+
+ $criteria = IncludeParser::parse($parameters)[0];
+
+ static::assertInstanceOf(Criteria::class, $criteria);
+ }
+
+ /**
+ * The Include Parser shall parse Include Criteria paths.
+ *
+ * @return void
+ */
+ public function testParseCriteriaPaths(): void
+ {
+ $fields = $this->faker()->words($this->faker->randomDigitNotNull());
+
+ $parameters = [
+ IncludeParser::param() => collect($fields)->join(','),
+ ];
+
+ $criteria = IncludeParser::parse($parameters)[0];
+
+ static::assertEquals($fields, $criteria->getPaths()->all());
+ }
+
+ /**
+ * The Include Parser shall parse Resource Criteria.
+ *
+ * @return void
+ */
+ public function testParseResourceCriteria(): void
+ {
+ $fields = collect($this->faker()->words($this->faker->randomDigitNotNull()));
+
+ $parameters = [
+ IncludeParser::param() => [
+ $this->faker->word() => $fields->join(','),
+ ],
+ ];
+
+ $criteria = IncludeParser::parse($parameters)[0];
+
+ static::assertInstanceOf(ResourceCriteria::class, $criteria);
+ }
+
+ /**
+ * The Include Parser shall parse Resource Criteria type.
+ *
+ * @return void
+ */
+ public function testParseResourceCriteriaType(): void
+ {
+ $type = $this->faker->word();
+
+ $fields = collect($this->faker()->words($this->faker->randomDigitNotNull()));
+
+ $parameters = [
+ IncludeParser::param() => [
+ $type => $fields->join(','),
+ ],
+ ];
+
+ $criteria = IncludeParser::parse($parameters)[0];
+
+ static::assertTrue(
+ $criteria instanceof ResourceCriteria
+ && $criteria->getType() === $type
+ );
+ }
+
+ /**
+ * The Include Parser shall parse Include Resource Criteria paths.
+ *
+ * @return void
+ */
+ public function testParseResourceCriteriaPaths(): void
+ {
+ $fields = $this->faker()->words($this->faker->randomDigitNotNull());
+
+ $parameters = [
+ IncludeParser::param() => [
+ $this->faker->word() => collect($fields)->join(','),
+ ],
+ ];
+
+ $criteria = IncludeParser::parse($parameters)[0];
+
+ static::assertEquals($fields, $criteria->getPaths()->all());
+ }
+}
diff --git a/tests/Unit/Http/Api/Parser/PagingParserTest.php b/tests/Unit/Http/Api/Parser/PagingParserTest.php
new file mode 100644
index 000000000..88bd6017b
--- /dev/null
+++ b/tests/Unit/Http/Api/Parser/PagingParserTest.php
@@ -0,0 +1,153 @@
+first(function (Criteria $criteria) {
+ return $criteria->getStrategy()->is(PaginationStrategy::LIMIT());
+ });
+
+ static::assertInstanceOf(LimitCriteria::class, $criteria);
+ }
+
+ /**
+ * If the provided limit is invalid, the Limit Parser shall use the default.
+ *
+ * @return void
+ */
+ public function testParseInvalidLimitCriteria(): void
+ {
+ $limit = $this->faker->word();
+
+ $parameters = [
+ PagingParser::param() => [
+ LimitCriteria::PARAM => $limit,
+ ],
+ ];
+
+ $criteria = collect(PagingParser::parse($parameters))->first(function (Criteria $criteria) {
+ return $criteria->getStrategy()->is(PaginationStrategy::LIMIT());
+ });
+
+ static::assertTrue(
+ $criteria instanceof LimitCriteria
+ && $criteria->getResultSize() === Criteria::DEFAULT_SIZE
+ );
+ }
+
+ /**
+ * The Paging Parser shall parse valid limits.
+ *
+ * @return void
+ */
+ public function testParseValidLimitCriteria(): void
+ {
+ $limit = $this->faker->numberBetween(1, Criteria::DEFAULT_SIZE);
+
+ $parameters = [
+ PagingParser::param() => [
+ LimitCriteria::PARAM => $limit,
+ ],
+ ];
+
+ $criteria = collect(PagingParser::parse($parameters))->first(function (Criteria $criteria) {
+ return $criteria->getStrategy()->is(PaginationStrategy::LIMIT());
+ });
+
+ static::assertTrue(
+ $criteria instanceof LimitCriteria
+ && $criteria->getResultSize() === $limit
+ );
+ }
+
+ /**
+ * By default, the Paging Parser shall return Offset Criteria.
+ *
+ * @return void
+ */
+ public function testParseOffsetCriteriaByDefault(): void
+ {
+ $parameters = [];
+
+ $criteria = collect(PagingParser::parse($parameters))->first(function (Criteria $criteria) {
+ return $criteria->getStrategy()->is(PaginationStrategy::OFFSET());
+ });
+
+ static::assertInstanceOf(OffsetCriteria::class, $criteria);
+ }
+
+ /**
+ * If the provided size is invalid, the Limit Parser shall use the default.
+ *
+ * @return void
+ */
+ public function testParseInvalidOffsetCriteria(): void
+ {
+ $size = $this->faker->word();
+
+ $parameters = [
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => $size,
+ ],
+ ];
+
+ $criteria = collect(PagingParser::parse($parameters))->first(function (Criteria $criteria) {
+ return $criteria->getStrategy()->is(PaginationStrategy::OFFSET());
+ });
+
+ static::assertTrue(
+ $criteria instanceof OffsetCriteria
+ && $criteria->getResultSize() === Criteria::DEFAULT_SIZE
+ );
+ }
+
+ /**
+ * The Paging Parser shall parse valid sizes.
+ *
+ * @return void
+ */
+ public function testParseValidOffsetCriteria(): void
+ {
+ $size = $this->faker->numberBetween(1, Criteria::MAX_RESULTS);
+
+ $parameters = [
+ PagingParser::param() => [
+ OffsetCriteria::SIZE_PARAM => $size,
+ ],
+ ];
+
+ $criteria = collect(PagingParser::parse($parameters))->first(function (Criteria $criteria) {
+ return $criteria->getStrategy()->is(PaginationStrategy::OFFSET());
+ });
+
+ static::assertTrue(
+ $criteria instanceof OffsetCriteria
+ && $criteria->getResultSize() === $size
+ );
+ }
+}
diff --git a/tests/Unit/Http/Api/Parser/SearchParserTest.php b/tests/Unit/Http/Api/Parser/SearchParserTest.php
new file mode 100644
index 000000000..053ddf893
--- /dev/null
+++ b/tests/Unit/Http/Api/Parser/SearchParserTest.php
@@ -0,0 +1,64 @@
+ $this->faker->word(),
+ ];
+
+ $criteria = SearchParser::parse($parameters)[0];
+
+ static::assertInstanceOf(Criteria::class, $criteria);
+ }
+
+ /**
+ * The Search parser shall parse the search term.
+ *
+ * @return void
+ */
+ public function testParseSearchCriteriaTerm(): void
+ {
+ $term = $this->faker->word();
+
+ $parameters = [
+ SearchParser::param() => $term,
+ ];
+
+ $criteria = SearchParser::parse($parameters)[0];
+
+ static::assertEquals($term, $criteria->getTerm());
+ }
+}
diff --git a/tests/Unit/Http/Api/Parser/SortParserTest.php b/tests/Unit/Http/Api/Parser/SortParserTest.php
new file mode 100644
index 000000000..e7efbc1ab
--- /dev/null
+++ b/tests/Unit/Http/Api/Parser/SortParserTest.php
@@ -0,0 +1,180 @@
+ RandomCriteria::PARAM_VALUE,
+ ];
+
+ $criteria = SortParser::parse($parameters)[0];
+
+ static::assertInstanceOf(RandomCriteria::class, $criteria);
+ }
+
+ /**
+ * The Sort Parser shall parse Relation Criteria.
+ *
+ * @return void
+ */
+ public function testParseRelationCriteria(): void
+ {
+ $parameters = [
+ SortParser::param() => collect($this->faker->words())->join('.'),
+ ];
+
+ $criteria = SortParser::parse($parameters)[0];
+
+ static::assertInstanceOf(RelationCriteria::class, $criteria);
+ }
+
+ /**
+ * The Sort Parser shall parse Field Criteria.
+ *
+ * @return void
+ */
+ public function testParseFieldCriteria(): void
+ {
+ $parameters = [
+ SortParser::param() => $this->faker->word(),
+ ];
+
+ $criteria = SortParser::parse($parameters)[0];
+
+ static::assertInstanceOf(FieldCriteria::class, $criteria);
+ }
+
+ /**
+ * The Sort Parser shall parse the sort field.
+ *
+ * @return void
+ */
+ public function testParseCriteriaField(): void
+ {
+ $field = $this->faker->word();
+
+ $parameters = [
+ SortParser::param() => $field,
+ ];
+
+ $criteria = SortParser::parse($parameters)[0];
+
+ static::assertEquals($field, $criteria->getField());
+ }
+
+ /**
+ * By default, the Sort Parser shall parse an ascending sort direction.
+ *
+ * @return void
+ */
+ public function testParseDefaultDirection(): void
+ {
+ $parameters = [
+ SortParser::param() => $this->faker->word(),
+ ];
+
+ $criteria = SortParser::parse($parameters)[0];
+
+ static::assertTrue(
+ $criteria instanceof FieldCriteria
+ && Direction::ASCENDING()->is($criteria->getDirection())
+ );
+ }
+
+ /**
+ * The Sort Parser shall parse a descending direction on fields that prepend a '-'.
+ *
+ * @return void
+ */
+ public function testParseDescendingDirection(): void
+ {
+ $field = Str::of('-')->append($this->faker->word())->__toString();
+
+ $parameters = [
+ SortParser::param() => $field,
+ ];
+
+ $criteria = SortParser::parse($parameters)[0];
+
+ static::assertTrue(
+ $criteria instanceof FieldCriteria
+ && Direction::DESCENDING()->is($criteria->getDirection())
+ );
+ }
+
+ /**
+ * The Sort Parser shall parse a global scope if scope is not provided.
+ *
+ * @return void
+ */
+ public function testParseGlobalScope(): void
+ {
+ $parameters = [
+ SortParser::param() => $this->faker->word(),
+ ];
+
+ $criteria = SortParser::parse($parameters)[0];
+
+ static::assertInstanceOf(GlobalScope::class, $criteria->getScope());
+ }
+
+ /**
+ * The Sort Parser shall parse a scope if provided.
+ *
+ * @return void
+ */
+ public function testParseTypeScope(): void
+ {
+ $type = Str::singular($this->faker->word());
+
+ $parameters = [
+ SortParser::param() => [
+ $type => $this->faker->word(),
+ ],
+ ];
+
+ $criteria = SortParser::parse($parameters)[0];
+
+ $scope = $criteria->getScope();
+
+ static::assertTrue($scope instanceof TypeScope && $scope->getType() === $type);
+ }
+}
diff --git a/tests/Unit/Http/Api/Query/FakeReadQuery.php b/tests/Unit/Http/Api/Query/FakeReadQuery.php
new file mode 100644
index 000000000..1087daf17
--- /dev/null
+++ b/tests/Unit/Http/Api/Query/FakeReadQuery.php
@@ -0,0 +1,60 @@
+faker->word();
+
+ $parameters = [
+ FieldParser::param() => [
+ $type => $this->faker->word(),
+ ],
+ ];
+
+ $query = new FakeReadQuery($parameters);
+
+ static::assertInstanceOf(FieldCriteria::class, $query->getFieldCriteria($type));
+ }
+
+ /**
+ * The Query shall retrieve include criteria by type.
+ *
+ * @return void
+ */
+ public function testGetIncludeCriteria(): void
+ {
+ $parameters = [
+ IncludeParser::param() => $this->faker->word(),
+ ];
+
+ $query = new FakeReadQuery($parameters);
+
+ static::assertInstanceOf(IncludeCriteria::class, $query->getIncludeCriteria($this->faker->word()));
+ }
+
+ /**
+ * The Query shall retrieve include resource criteria by type.
+ *
+ * @return void
+ */
+ public function testGetIncludeResourceCriteria(): void
+ {
+ $type = $this->faker->word();
+
+ $parameters = [
+ IncludeParser::param() => [
+ $type => $this->faker->word(),
+ ],
+ ];
+
+ $query = new FakeReadQuery($parameters);
+
+ static::assertInstanceOf(ResourceCriteria::class, $query->getIncludeCriteria($type));
+ }
+
+ /**
+ * The query shall retrieve all sort criteria.
+ *
+ * @return void
+ */
+ public function testGetSortCriteria(): void
+ {
+ $fields = collect($this->faker()->words($this->faker->randomDigitNotNull()));
+
+ $parameters = [
+ SortParser::param() => $fields->join(','),
+ ];
+
+ $query = new FakeReadQuery($parameters);
+
+ static::assertCount($fields->count(), $query->getSortCriteria());
+ }
+
+ /**
+ * The query shall retrieve all filter criteria.
+ *
+ * @return void
+ */
+ public function testGetFilterCriteria(): void
+ {
+ $filterCount = $this->faker->randomDigitNotNull();
+
+ $parameters = Collection::times($filterCount, fn () => FilterParser::param().'.'.Str::random())
+ ->combine(Collection::times($filterCount, fn () => Str::random()))
+ ->undot()
+ ->all();
+
+ $query = new FakeReadQuery($parameters);
+
+ static::assertCount($filterCount, $query->getFilterCriteria());
+ }
+
+ /**
+ * By default, the query shall not have a search term.
+ *
+ * @return void
+ */
+ public function testDoesNotHaveSearch(): void
+ {
+ $parameters = [];
+
+ $query = new FakeReadQuery($parameters);
+
+ static::assertFalse($query->hasSearchCriteria());
+ }
+
+ /**
+ * The query shall have a search if a term is provided.
+ *
+ * @return void
+ */
+ public function testHasSearch(): void
+ {
+ $parameters = [
+ SearchParser::param() => $this->faker->word(),
+ ];
+
+ $query = new FakeReadQuery($parameters);
+
+ static::assertTrue($query->hasSearchCriteria());
+ }
+
+ /**
+ * By default, the query shall return null search criteria.
+ *
+ * @return void
+ */
+ public function testNullSearch(): void
+ {
+ $parameters = [];
+
+ $query = new FakeReadQuery($parameters);
+
+ static::assertNull($query->getSearchCriteria());
+ }
+
+ /**
+ * The query shall return search criteria if a term is provided.
+ *
+ * @return void
+ */
+ public function testGetSearch(): void
+ {
+ $parameters = [
+ SearchParser::param() => $this->faker->word(),
+ ];
+
+ $query = new FakeReadQuery($parameters);
+
+ static::assertInstanceOf(SearchCriteria::class, $query->getSearchCriteria());
+ }
+
+ /**
+ * The query shall return limit criteria.
+ *
+ * @return void
+ */
+ public function testGetLimitCriteria(): void
+ {
+ $parameters = [];
+
+ $query = new FakeReadQuery($parameters);
+
+ static::assertInstanceOf(LimitCriteria::class, $query->getPagingCriteria(PaginationStrategy::LIMIT()));
+ }
+
+ /**
+ * The query shall return limit criteria.
+ *
+ * @return void
+ */
+ public function testGetOffsetCriteria(): void
+ {
+ $parameters = [];
+
+ $query = new FakeReadQuery($parameters);
+
+ static::assertInstanceOf(OffsetCriteria::class, $query->getPagingCriteria(PaginationStrategy::OFFSET()));
+ }
+}
diff --git a/tests/Unit/Http/Api/Scope/GlobalScopeTest.php b/tests/Unit/Http/Api/Scope/GlobalScopeTest.php
new file mode 100644
index 000000000..0361f2b14
--- /dev/null
+++ b/tests/Unit/Http/Api/Scope/GlobalScopeTest.php
@@ -0,0 +1,61 @@
+isWithinScope($otherScope));
+ }
+
+ /**
+ * A type scope shall be within the scope of a global scope.
+ *
+ * @return void
+ */
+ public function testTypeScopeIsWithinScope(): void
+ {
+ $scope = new GlobalScope();
+
+ $otherScope = new TypeScope($this->faker->word());
+
+ static::assertTrue($scope->isWithinScope($otherScope));
+ }
+
+ /**
+ * A relation scope shall be within the scope of a global scope.
+ *
+ * @return void
+ */
+ public function testRelationScopeIsWithinScope(): void
+ {
+ $scope = new GlobalScope();
+
+ $otherScope = new RelationScope($this->faker->word());
+
+ static::assertTrue($scope->isWithinScope($otherScope));
+ }
+}
diff --git a/tests/Unit/Http/Api/Scope/RelationScopeTest.php b/tests/Unit/Http/Api/Scope/RelationScopeTest.php
new file mode 100644
index 000000000..5eec9a2b3
--- /dev/null
+++ b/tests/Unit/Http/Api/Scope/RelationScopeTest.php
@@ -0,0 +1,77 @@
+faker->word());
+
+ $otherScope = new GlobalScope();
+
+ static::assertFalse($scope->isWithinScope($otherScope));
+ }
+
+ /**
+ * A type scope shall not be within the scope of a relation scope.
+ *
+ * @return void
+ */
+ public function testTypeScopeIsNotWithinScope(): void
+ {
+ $scope = new RelationScope($this->faker->word());
+
+ $otherScope = new TypeScope($this->faker->word());
+
+ static::assertFalse($scope->isWithinScope($otherScope));
+ }
+
+ /**
+ * A relation scope of an unequal value shall not be within the scope of a relation scope.
+ *
+ * @return void
+ */
+ public function testUnequalRelationIsNotWithinScope(): void
+ {
+ $scope = new RelationScope($this->faker->word());
+
+ $otherScope = new RelationScope($this->faker->word());
+
+ static::assertFalse($scope->isWithinScope($otherScope));
+ }
+
+ /**
+ * A relation scope of an equal value shall be within the scope of a relation scope.
+ *
+ * @return void
+ */
+ public function testRelationIsWithinScope(): void
+ {
+ $relation = $this->faker->word();
+
+ $scope = new RelationScope($relation);
+
+ $otherScope = new RelationScope($relation);
+
+ static::assertTrue($scope->isWithinScope($otherScope));
+ }
+}
diff --git a/tests/Unit/Http/Api/Scope/ScopeParserTest.php b/tests/Unit/Http/Api/Scope/ScopeParserTest.php
new file mode 100644
index 000000000..e5c225919
--- /dev/null
+++ b/tests/Unit/Http/Api/Scope/ScopeParserTest.php
@@ -0,0 +1,52 @@
+faker->word()));
+ }
+
+ /**
+ * The Scope Parser shall parse relation scopes.
+ *
+ * @return void
+ */
+ public function testParseRelationScope(): void
+ {
+ $relation = collect($this->faker->words())->join('.');
+
+ static::assertInstanceOf(RelationScope::class, ScopeParser::parse($relation));
+ }
+}
diff --git a/tests/Unit/Http/Api/Scope/TypeScopeTest.php b/tests/Unit/Http/Api/Scope/TypeScopeTest.php
new file mode 100644
index 000000000..a83551317
--- /dev/null
+++ b/tests/Unit/Http/Api/Scope/TypeScopeTest.php
@@ -0,0 +1,93 @@
+faker->word());
+
+ $otherScope = new GlobalScope();
+
+ static::assertFalse($scope->isWithinScope($otherScope));
+ }
+
+ /**
+ * A type scope of unequal value shall not be within the scope of a type scope.
+ *
+ * @return void
+ */
+ public function testTypeScopeIsNotWithinScope(): void
+ {
+ $scope = new TypeScope($this->faker->word());
+
+ $otherScope = new TypeScope($this->faker->word());
+
+ static::assertFalse($scope->isWithinScope($otherScope));
+ }
+
+ /**
+ * A type scope of equal value shall be within the scope of a type scope.
+ *
+ * @return void
+ */
+ public function testTypeScopeIsWithinScope(): void
+ {
+ $type = $this->faker->word();
+
+ $scope = new TypeScope($type);
+
+ $otherScope = new TypeScope($type);
+
+ static::assertTrue($scope->isWithinScope($otherScope));
+ }
+
+ /**
+ * A relation scope of unequal type shall not be within the scope of a type scope.
+ *
+ * @return void
+ */
+ public function testRelationScopeIsNotWithinScope(): void
+ {
+ $scope = new TypeScope($this->faker->word());
+
+ $otherScope = new RelationScope($this->faker->word());
+
+ static::assertFalse($scope->isWithinScope($otherScope));
+ }
+
+ /**
+ * A relation scope of equal value shall be within the scope of a type scope.
+ *
+ * @return void
+ */
+ public function testRelationScopeIsWithinScope(): void
+ {
+ $type = $this->faker->word();
+
+ $scope = new TypeScope($type);
+
+ $otherScope = new RelationScope($type);
+
+ static::assertTrue($scope->isWithinScope($otherScope));
+ }
+}
diff --git a/tests/Unit/Http/Api/Sort/RandomSortTest.php b/tests/Unit/Http/Api/Sort/RandomSortTest.php
new file mode 100644
index 000000000..e729b2b22
--- /dev/null
+++ b/tests/Unit/Http/Api/Sort/RandomSortTest.php
@@ -0,0 +1,31 @@
+format(Direction::getRandomInstance()));
+ }
+}
diff --git a/tests/Unit/Http/Api/Sort/SortTest.php b/tests/Unit/Http/Api/Sort/SortTest.php
new file mode 100644
index 000000000..319013c4e
--- /dev/null
+++ b/tests/Unit/Http/Api/Sort/SortTest.php
@@ -0,0 +1,58 @@
+faker->word());
+
+ static::assertEquals($sort->getKey(), $sort->getColumn());
+ }
+
+ /**
+ * The Sort shall be formatted as "{key}" for the Ascending Direction.
+ *
+ * @return void
+ */
+ public function testFormatAsc(): void
+ {
+ $sortField = $this->faker->word();
+
+ $sort = new Sort($sortField);
+
+ static::assertEquals($sortField, $sort->format(Direction::ASCENDING()));
+ }
+
+ /**
+ * The Sort shall be formatted as "-{key}" for the Descending Direction.
+ *
+ * @return void
+ */
+ public function testFormatDesc(): void
+ {
+ $sortField = $this->faker->word();
+
+ $sort = new Sort($sortField);
+
+ static::assertEquals("-$sortField", $sort->format(Direction::DESCENDING()));
+ }
+}
diff --git a/tests/Unit/Models/Admin/AnnouncementTest.php b/tests/Unit/Models/Admin/AnnouncementTest.php
new file mode 100644
index 000000000..46057b5cc
--- /dev/null
+++ b/tests/Unit/Models/Admin/AnnouncementTest.php
@@ -0,0 +1,41 @@
+createOne();
+
+ static::assertEquals(1, $announcement->audits()->count());
+ }
+
+ /**
+ * Announcements shall be nameable.
+ *
+ * @return void
+ */
+ public function testNameable(): void
+ {
+ $announcement = Announcement::factory()->createOne();
+
+ static::assertIsString($announcement->getName());
+ }
+}
diff --git a/tests/Unit/Models/Auth/InvitationTest.php b/tests/Unit/Models/Auth/InvitationTest.php
new file mode 100644
index 000000000..d18950cf9
--- /dev/null
+++ b/tests/Unit/Models/Auth/InvitationTest.php
@@ -0,0 +1,108 @@
+withoutEvents();
+
+ $invitation = Invitation::factory()->createOne();
+
+ $status = $invitation->status;
+
+ static::assertInstanceOf(InvitationStatus::class, $status);
+ }
+
+ /**
+ * Invitations shall be auditable.
+ *
+ * @return void
+ */
+ public function testAuditable(): void
+ {
+ Config::set('audit.console', true);
+
+ $invitation = Invitation::factory()->createOne();
+
+ static::assertEquals(1, $invitation->audits()->count());
+ }
+
+ /**
+ * Invitations shall be nameable.
+ *
+ * @return void
+ */
+ public function testNameable(): void
+ {
+ $this->withoutEvents();
+
+ $invitation = Invitation::factory()->createOne();
+
+ static::assertIsString($invitation->getName());
+ }
+
+ /**
+ * The Invitation is open if the Invitation Status is Open.
+ *
+ * @return void
+ */
+ public function testInvitationIsOpen(): void
+ {
+ $this->withoutEvents();
+
+ $invitation = Invitation::factory()->createOne([
+ Invitation::ATTRIBUTE_STATUS => InvitationStatus::OPEN,
+ ]);
+
+ static::assertTrue($invitation->isOpen());
+ }
+
+ /**
+ * The Invitation is not open if the Invitation Status is not Open.
+ *
+ * @return void
+ */
+ public function testInvitationIsClosed(): void
+ {
+ $this->withoutEvents();
+
+ $invitation = Invitation::factory()->createOne([
+ Invitation::ATTRIBUTE_STATUS => InvitationStatus::CLOSED,
+ ]);
+
+ static::assertFalse($invitation->isOpen());
+ }
+
+ /**
+ * The Invitation shall send a InvitationMail after creation.
+ *
+ * @return void
+ */
+ public function testInvitationMailWasSent(): void
+ {
+ Mail::fake();
+
+ Invitation::factory()->create();
+
+ Mail::assertQueued(InvitationMail::class);
+ }
+}
diff --git a/tests/Unit/Models/Auth/UserTest.php b/tests/Unit/Models/Auth/UserTest.php
new file mode 100644
index 000000000..733266a1a
--- /dev/null
+++ b/tests/Unit/Models/Auth/UserTest.php
@@ -0,0 +1,67 @@
+createOne();
+
+ $user->createToken($this->faker->word());
+
+ static::assertInstanceOf(MorphMany::class, $user->tokens());
+ static::assertEquals(1, $user->tokens()->count());
+ static::assertInstanceOf(PersonalAccessToken::class, $user->tokens()->first());
+ }
+
+ /**
+ * Users shall verify email.
+ *
+ * @return void
+ */
+ public function testVerificationEmailNotification(): void
+ {
+ Notification::fake();
+
+ $user = User::factory()->createOne();
+
+ $user->sendEmailVerificationNotification();
+
+ Notification::assertSentTo($user, VerifyEmail::class);
+ }
+
+ /**
+ * Users shall be nameable.
+ *
+ * @return void
+ */
+ public function testNameable(): void
+ {
+ $user = User::factory()->createOne();
+
+ static::assertIsString($user->getName());
+ }
+}
diff --git a/tests/Unit/Models/Billing/BalanceTest.php b/tests/Unit/Models/Billing/BalanceTest.php
new file mode 100644
index 000000000..1875f7f06
--- /dev/null
+++ b/tests/Unit/Models/Billing/BalanceTest.php
@@ -0,0 +1,71 @@
+createOne();
+
+ $service = $balance->service;
+
+ static::assertInstanceOf(Service::class, $service);
+ }
+
+ /**
+ * The frequency attribute of a balance shall be cast to a Frequency enum instance.
+ *
+ * @return void
+ */
+ public function testCastsFrequencyToEnum(): void
+ {
+ $balance = Balance::factory()->createOne();
+
+ $frequency = $balance->frequency;
+
+ static::assertInstanceOf(BalanceFrequency::class, $frequency);
+ }
+
+ /**
+ * Balance shall be auditable.
+ *
+ * @return void
+ */
+ public function testAuditable(): void
+ {
+ Config::set('audit.console', true);
+
+ $balance = Balance::factory()->createOne();
+
+ static::assertEquals(1, $balance->audits()->count());
+ }
+
+ /**
+ * Balances shall be nameable.
+ *
+ * @return void
+ */
+ public function testNameable(): void
+ {
+ $balance = Balance::factory()->createOne();
+
+ static::assertIsString($balance->getName());
+ }
+}
diff --git a/tests/Unit/Models/Billing/TransactionTest.php b/tests/Unit/Models/Billing/TransactionTest.php
new file mode 100644
index 000000000..164114a49
--- /dev/null
+++ b/tests/Unit/Models/Billing/TransactionTest.php
@@ -0,0 +1,56 @@
+createOne();
+
+ $service = $transaction->service;
+
+ static::assertInstanceOf(Service::class, $service);
+ }
+
+ /**
+ * Transaction shall be auditable.
+ *
+ * @return void
+ */
+ public function testAuditable(): void
+ {
+ Config::set('audit.console', true);
+
+ $transaction = Transaction::factory()->createOne();
+
+ static::assertEquals(1, $transaction->audits()->count());
+ }
+
+ /**
+ * Transactions shall be nameable.
+ *
+ * @return void
+ */
+ public function testNameable(): void
+ {
+ $transaction = Transaction::factory()->createOne();
+
+ static::assertIsString($transaction->getName());
+ }
+}
diff --git a/tests/Unit/Models/Document/PageTest.php b/tests/Unit/Models/Document/PageTest.php
new file mode 100644
index 000000000..4fa58b511
--- /dev/null
+++ b/tests/Unit/Models/Document/PageTest.php
@@ -0,0 +1,41 @@
+createOne();
+
+ static::assertEquals(1, $page->audits()->count());
+ }
+
+ /**
+ * Pages shall be nameable.
+ *
+ * @return void
+ */
+ public function testNameable(): void
+ {
+ $page = Page::factory()->createOne();
+
+ static::assertIsString($page->getName());
+ }
+}
diff --git a/tests/Unit/Models/Wiki/Anime/AnimeSynonymTest.php b/tests/Unit/Models/Wiki/Anime/AnimeSynonymTest.php
new file mode 100644
index 000000000..c38e20751
--- /dev/null
+++ b/tests/Unit/Models/Wiki/Anime/AnimeSynonymTest.php
@@ -0,0 +1,93 @@
+for(Anime::factory())
+ ->createOne();
+
+ static::assertIsString($synonym->searchableAs());
+ }
+
+ /**
+ * Synonym shall be a searchable resource.
+ *
+ * @return void
+ */
+ public function testToSearchableArray(): void
+ {
+ $synonym = AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ static::assertIsArray($synonym->toSearchableArray());
+ }
+
+ /**
+ * Synonyms shall be auditable.
+ *
+ * @return void
+ */
+ public function testAuditable(): void
+ {
+ Config::set('audit.console', true);
+
+ $synonym = AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ static::assertEquals(1, $synonym->audits()->count());
+ }
+
+ /**
+ * Synonyms shall be nameable.
+ *
+ * @return void
+ */
+ public function testNameable(): void
+ {
+ $synonym = AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ static::assertIsString($synonym->getName());
+ }
+
+ /**
+ * Synonyms shall belong to an Anime.
+ *
+ * @return void
+ */
+ public function testAnime(): void
+ {
+ $synonym = AnimeSynonym::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $synonym->anime());
+ static::assertInstanceOf(Anime::class, $synonym->anime()->first());
+ }
+}
diff --git a/tests/Unit/Models/Wiki/Anime/AnimeThemeTest.php b/tests/Unit/Models/Wiki/Anime/AnimeThemeTest.php
new file mode 100644
index 000000000..a0c695147
--- /dev/null
+++ b/tests/Unit/Models/Wiki/Anime/AnimeThemeTest.php
@@ -0,0 +1,162 @@
+for(Anime::factory())
+ ->createOne();
+
+ $type = $theme->type;
+
+ static::assertInstanceOf(ThemeType::class, $type);
+ }
+
+ /**
+ * Theme shall be a searchable resource.
+ *
+ * @return void
+ */
+ public function testSearchableAs(): void
+ {
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ static::assertIsString($theme->searchableAs());
+ }
+
+ /**
+ * Theme shall be a searchable resource.
+ *
+ * @return void
+ */
+ public function testToSearchableArray(): void
+ {
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ static::assertIsArray($theme->toSearchableArray());
+ }
+
+ /**
+ * Themes shall be auditable.
+ *
+ * @return void
+ */
+ public function testAuditable(): void
+ {
+ Config::set('audit.console', true);
+
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ static::assertEquals(1, $theme->audits()->count());
+ }
+
+ /**
+ * Themes shall be nameable.
+ *
+ * @return void
+ */
+ public function testNameable(): void
+ {
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ static::assertIsString($theme->getName());
+ }
+
+ /**
+ * Themes shall belong to an Anime.
+ *
+ * @return void
+ */
+ public function testAnime(): void
+ {
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $theme->anime());
+ static::assertInstanceOf(Anime::class, $theme->anime()->first());
+ }
+
+ /**
+ * Themes shall belong to a Song.
+ *
+ * @return void
+ */
+ public function testSong(): void
+ {
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->for(Song::factory())
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $theme->song());
+ static::assertInstanceOf(Song::class, $theme->song()->first());
+ }
+
+ /**
+ * Theme shall have a one-to-many relationship with the type Entry.
+ *
+ * @return void
+ */
+ public function testEntries(): void
+ {
+ $entryCount = $this->faker->randomDigitNotNull();
+
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->has(AnimeThemeEntry::factory()->count($entryCount))
+ ->createOne();
+
+ static::assertInstanceOf(HasMany::class, $theme->animethemeentries());
+ static::assertEquals($entryCount, $theme->animethemeentries()->count());
+ static::assertInstanceOf(AnimeThemeEntry::class, $theme->animethemeentries()->first());
+ }
+
+ /**
+ * Themes shall have a generated slug on creation.
+ *
+ * @return void
+ */
+ public function testThemeCreatesSlug(): void
+ {
+ $theme = AnimeTheme::factory()
+ ->for(Anime::factory())
+ ->createOne();
+
+ static::assertArrayHasKey('slug', $theme);
+ }
+}
diff --git a/tests/Unit/Models/Wiki/Anime/Theme/AnimeThemeEntryTest.php b/tests/Unit/Models/Wiki/Anime/Theme/AnimeThemeEntryTest.php
new file mode 100644
index 000000000..ec7041dc7
--- /dev/null
+++ b/tests/Unit/Models/Wiki/Anime/Theme/AnimeThemeEntryTest.php
@@ -0,0 +1,133 @@
+for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ static::assertIsString($entry->searchableAs());
+ }
+
+ /**
+ * Entry shall be a searchable resource.
+ *
+ * @return void
+ */
+ public function testToSearchableArray(): void
+ {
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ static::assertIsArray($entry->toSearchableArray());
+ }
+
+ /**
+ * Entries shall be auditable.
+ *
+ * @return void
+ */
+ public function testAuditable(): void
+ {
+ Config::set('audit.console', true);
+
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ static::assertEquals(1, $entry->audits()->count());
+ }
+
+ /**
+ * Entries shall be nameable.
+ *
+ * @return void
+ */
+ public function testNameable(): void
+ {
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ static::assertIsString($entry->getName());
+ }
+
+ /**
+ * Entries shall belong to a Theme.
+ *
+ * @return void
+ */
+ public function testTheme(): void
+ {
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $entry->animetheme());
+ static::assertInstanceOf(AnimeTheme::class, $entry->animetheme()->first());
+ }
+
+ /**
+ * Entries shall have a many-to-many relationship with the type Video.
+ *
+ * @return void
+ */
+ public function testVideos(): void
+ {
+ $videoCount = $this->faker->randomDigitNotNull();
+
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->has(Video::factory()->count($videoCount))
+ ->createOne();
+
+ static::assertInstanceOf(BelongsToMany::class, $entry->videos());
+ static::assertEquals($videoCount, $entry->videos()->count());
+ static::assertInstanceOf(Video::class, $entry->videos()->first());
+ static::assertEquals(AnimeThemeEntryVideo::class, $entry->videos()->getPivotClass());
+ }
+
+ /**
+ * Entries shall belong to an Anime through a Theme.
+ *
+ * @return void
+ */
+ public function testAnime(): void
+ {
+ $entry = AnimeThemeEntry::factory()
+ ->for(AnimeTheme::factory()->for(Anime::factory()))
+ ->createOne();
+
+ static::assertInstanceOf(BelongsToThrough::class, $entry->anime());
+ static::assertInstanceOf(Anime::class, $entry->anime()->first());
+ }
+}
diff --git a/tests/Unit/Models/Wiki/AnimeTest.php b/tests/Unit/Models/Wiki/AnimeTest.php
new file mode 100644
index 000000000..8e9684df8
--- /dev/null
+++ b/tests/Unit/Models/Wiki/AnimeTest.php
@@ -0,0 +1,207 @@
+createOne();
+
+ $season = $anime->season;
+
+ static::assertInstanceOf(AnimeSeason::class, $season);
+ }
+
+ /**
+ * Anime shall be a searchable resource.
+ *
+ * @return void
+ */
+ public function testSearchableAs(): void
+ {
+ $anime = Anime::factory()->createOne();
+
+ static::assertIsString($anime->searchableAs());
+ }
+
+ /**
+ * Anime shall be a searchable resource.
+ *
+ * @return void
+ */
+ public function testToSearchableArray(): void
+ {
+ $anime = Anime::factory()->createOne();
+
+ static::assertIsArray($anime->toSearchableArray());
+ }
+
+ /**
+ * Anime shall be auditable.
+ *
+ * @return void
+ */
+ public function testAuditable(): void
+ {
+ Config::set('audit.console', true);
+
+ $anime = Anime::factory()->createOne();
+
+ static::assertEquals(1, $anime->audits()->count());
+ }
+
+ /**
+ * Anime shall be nameable.
+ *
+ * @return void
+ */
+ public function testNameable(): void
+ {
+ $anime = Anime::factory()->createOne();
+
+ static::assertIsString($anime->getName());
+ }
+
+ /**
+ * Anime shall have a one-to-many relationship with the type Synonym.
+ *
+ * @return void
+ */
+ public function testSynonyms(): void
+ {
+ $synonymCount = $this->faker->randomDigitNotNull();
+
+ $anime = Anime::factory()
+ ->has(AnimeSynonym::factory()->count($synonymCount))
+ ->createOne();
+
+ static::assertInstanceOf(HasMany::class, $anime->animesynonyms());
+ static::assertEquals($synonymCount, $anime->animesynonyms()->count());
+ static::assertInstanceOf(AnimeSynonym::class, $anime->animesynonyms()->first());
+ }
+
+ /**
+ * Anime shall have a many-to-many relationship with the type Series.
+ *
+ * @return void
+ */
+ public function testSeries(): void
+ {
+ $seriesCount = $this->faker->randomDigitNotNull();
+
+ $anime = Anime::factory()
+ ->has(Series::factory()->count($seriesCount))
+ ->createOne();
+
+ static::assertInstanceOf(BelongsToMany::class, $anime->series());
+ static::assertEquals($seriesCount, $anime->series()->count());
+ static::assertInstanceOf(Series::class, $anime->series()->first());
+ static::assertEquals(AnimeSeries::class, $anime->series()->getPivotClass());
+ }
+
+ /**
+ * Anime shall have a one-to-many relationship with the type Theme.
+ *
+ * @return void
+ */
+ public function testThemes(): void
+ {
+ $themeCount = $this->faker->randomDigitNotNull();
+
+ $anime = Anime::factory()
+ ->has(AnimeTheme::factory()->count($themeCount))
+ ->createOne();
+
+ static::assertInstanceOf(HasMany::class, $anime->animethemes());
+ static::assertEquals($themeCount, $anime->animethemes()->count());
+ static::assertInstanceOf(AnimeTheme::class, $anime->animethemes()->first());
+ }
+
+ /**
+ * Anime shall have a many-to-many relationship with the type ExternalResource.
+ *
+ * @return void
+ */
+ public function testExternalResources(): void
+ {
+ $resourceCount = $this->faker->randomDigitNotNull();
+
+ $anime = Anime::factory()
+ ->has(ExternalResource::factory()->count($resourceCount), 'resources')
+ ->createOne();
+
+ static::assertInstanceOf(BelongsToMany::class, $anime->resources());
+ static::assertEquals($resourceCount, $anime->resources()->count());
+ static::assertInstanceOf(ExternalResource::class, $anime->resources()->first());
+ static::assertEquals(AnimeResource::class, $anime->resources()->getPivotClass());
+ }
+
+ /**
+ * Anime shall have a many-to-many relationship with the type Image.
+ *
+ * @return void
+ */
+ public function testImages(): void
+ {
+ $imageCount = $this->faker->randomDigitNotNull();
+
+ $anime = Anime::factory()
+ ->has(Image::factory()->count($imageCount))
+ ->createOne();
+
+ static::assertInstanceOf(BelongsToMany::class, $anime->images());
+ static::assertEquals($imageCount, $anime->images()->count());
+ static::assertInstanceOf(Image::class, $anime->images()->first());
+ static::assertEquals(AnimeImage::class, $anime->images()->getPivotClass());
+ }
+
+ /**
+ * Anime shall have a many-to-many relationship with the type Studio.
+ *
+ * @return void
+ */
+ public function testStudios(): void
+ {
+ $studioCount = $this->faker->randomDigitNotNull();
+
+ $anime = Anime::factory()
+ ->has(Studio::factory()->count($studioCount))
+ ->createOne();
+
+ static::assertInstanceOf(BelongsToMany::class, $anime->studios());
+ static::assertEquals($studioCount, $anime->studios()->count());
+ static::assertInstanceOf(Studio::class, $anime->studios()->first());
+ static::assertEquals(AnimeStudio::class, $anime->studios()->getPivotClass());
+ }
+}
diff --git a/tests/Unit/Models/Wiki/ArtistTest.php b/tests/Unit/Models/Wiki/ArtistTest.php
new file mode 100644
index 000000000..4ad246ffe
--- /dev/null
+++ b/tests/Unit/Models/Wiki/ArtistTest.php
@@ -0,0 +1,171 @@
+createOne();
+
+ static::assertIsString($artist->searchableAs());
+ }
+
+ /**
+ * Artist shall be a searchable resource.
+ *
+ * @return void
+ */
+ public function testToSearchableArray(): void
+ {
+ $artist = Artist::factory()->createOne();
+
+ static::assertIsArray($artist->toSearchableArray());
+ }
+
+ /**
+ * Artists shall be auditable.
+ *
+ * @return void
+ */
+ public function testAuditable(): void
+ {
+ Config::set('audit.console', true);
+
+ $artist = Artist::factory()->createOne();
+
+ static::assertEquals(1, $artist->audits()->count());
+ }
+
+ /**
+ * Artists shall be nameable.
+ *
+ * @return void
+ */
+ public function testNameable(): void
+ {
+ $artist = Artist::factory()->createOne();
+
+ static::assertIsString($artist->getName());
+ }
+
+ /**
+ * Artist shall have a many-to-many relationship with the type Song.
+ *
+ * @return void
+ */
+ public function testSongs(): void
+ {
+ $songCount = $this->faker->randomDigitNotNull();
+
+ $artist = Artist::factory()
+ ->has(Song::factory()->count($songCount))
+ ->createOne();
+
+ static::assertInstanceOf(BelongsToMany::class, $artist->songs());
+ static::assertEquals($songCount, $artist->songs()->count());
+ static::assertInstanceOf(Song::class, $artist->songs()->first());
+ static::assertEquals(ArtistSong::class, $artist->songs()->getPivotClass());
+ }
+
+ /**
+ * Artist shall have a many-to-many relationship with the type ExternalResource.
+ *
+ * @return void
+ */
+ public function testExternalResources(): void
+ {
+ $resourceCount = $this->faker->randomDigitNotNull();
+
+ $artist = Artist::factory()
+ ->has(ExternalResource::factory()->count($resourceCount), 'resources')
+ ->createOne();
+
+ static::assertInstanceOf(BelongsToMany::class, $artist->resources());
+ static::assertEquals($resourceCount, $artist->resources()->count());
+ static::assertInstanceOf(ExternalResource::class, $artist->resources()->first());
+ static::assertEquals(ArtistResource::class, $artist->resources()->getPivotClass());
+ }
+
+ /**
+ * Artist shall have a many-to-many relationship to the type Artist as "members".
+ *
+ * @return void
+ */
+ public function testMembers(): void
+ {
+ $memberCount = $this->faker->randomDigitNotNull();
+
+ $artist = Artist::factory()
+ ->has(Artist::factory()->count($memberCount), 'members')
+ ->createOne();
+
+ static::assertInstanceOf(BelongsToMany::class, $artist->members());
+ static::assertEquals($memberCount, $artist->members()->count());
+ static::assertInstanceOf(Artist::class, $artist->members()->first());
+ static::assertEquals(ArtistMember::class, $artist->members()->getPivotClass());
+ }
+
+ /**
+ * Artist shall have a many-to-many relationship to the type Artist as "groups".
+ *
+ * @return void
+ */
+ public function testGroups(): void
+ {
+ $groupCount = $this->faker->randomDigitNotNull();
+
+ $artist = Artist::factory()
+ ->has(Artist::factory()->count($groupCount), 'groups')
+ ->createOne();
+
+ static::assertInstanceOf(BelongsToMany::class, $artist->groups());
+ static::assertEquals($groupCount, $artist->groups()->count());
+ static::assertInstanceOf(Artist::class, $artist->groups()->first());
+ static::assertEquals(ArtistMember::class, $artist->groups()->getPivotClass());
+ }
+
+ /**
+ * Artist shall have a many-to-many relationship with the type Image.
+ *
+ * @return void
+ */
+ public function testImages(): void
+ {
+ $imageCount = $this->faker->randomDigitNotNull();
+
+ $artist = Artist::factory()
+ ->has(Image::factory()->count($imageCount))
+ ->createOne();
+
+ static::assertInstanceOf(BelongsToMany::class, $artist->images());
+ static::assertEquals($imageCount, $artist->images()->count());
+ static::assertInstanceOf(Image::class, $artist->images()->first());
+ static::assertEquals(ArtistImage::class, $artist->images()->getPivotClass());
+ }
+}
diff --git a/tests/Unit/Models/Wiki/ExternalResourceTest.php b/tests/Unit/Models/Wiki/ExternalResourceTest.php
new file mode 100644
index 000000000..5ace9aab4
--- /dev/null
+++ b/tests/Unit/Models/Wiki/ExternalResourceTest.php
@@ -0,0 +1,123 @@
+createOne();
+
+ $site = $resource->site;
+
+ static::assertInstanceOf(ResourceSite::class, $site);
+ }
+
+ /**
+ * Resources shall be auditable.
+ *
+ * @return void
+ */
+ public function testAuditable(): void
+ {
+ Config::set('audit.console', true);
+
+ $resource = ExternalResource::factory()->createOne();
+
+ static::assertEquals(1, $resource->audits()->count());
+ }
+
+ /**
+ * Resources shall be nameable.
+ *
+ * @return void
+ */
+ public function testNameable(): void
+ {
+ $resource = ExternalResource::factory()->createOne();
+
+ static::assertIsString($resource->getName());
+ }
+
+ /**
+ * Resource shall have a many-to-many relationship with the type Anime.
+ *
+ * @return void
+ */
+ public function testAnime(): void
+ {
+ $animeCount = $this->faker->randomDigitNotNull();
+
+ $resource = ExternalResource::factory()
+ ->has(Anime::factory()->count($animeCount))
+ ->createOne();
+
+ static::assertInstanceOf(BelongsToMany::class, $resource->anime());
+ static::assertEquals($animeCount, $resource->anime()->count());
+ static::assertInstanceOf(Anime::class, $resource->anime()->first());
+ static::assertEquals(AnimeResource::class, $resource->anime()->getPivotClass());
+ }
+
+ /**
+ * Resource shall have a many-to-many relationship with the type Artist.
+ *
+ * @return void
+ */
+ public function testArtists(): void
+ {
+ $artistCount = $this->faker->randomDigitNotNull();
+
+ $resource = ExternalResource::factory()
+ ->has(Artist::factory()->count($artistCount))
+ ->createOne();
+
+ static::assertInstanceOf(BelongsToMany::class, $resource->artists());
+ static::assertEquals($artistCount, $resource->artists()->count());
+ static::assertInstanceOf(Artist::class, $resource->artists()->first());
+ static::assertEquals(ArtistResource::class, $resource->artists()->getPivotClass());
+ }
+
+ /**
+ * Resource shall have a many-to-many relationship with the type Studio.
+ *
+ * @return void
+ */
+ public function testStudio(): void
+ {
+ $studioCount = $this->faker->randomDigitNotNull();
+
+ $resource = ExternalResource::factory()
+ ->has(Studio::factory()->count($studioCount))
+ ->createOne();
+
+ static::assertInstanceOf(BelongsToMany::class, $resource->studios());
+ static::assertEquals($studioCount, $resource->studios()->count());
+ static::assertInstanceOf(Studio::class, $resource->studios()->first());
+ static::assertEquals(StudioResource::class, $resource->studios()->getPivotClass());
+ }
+}
diff --git a/tests/Unit/Models/Wiki/ImageTest.php b/tests/Unit/Models/Wiki/ImageTest.php
new file mode 100644
index 000000000..ca4d38ed8
--- /dev/null
+++ b/tests/Unit/Models/Wiki/ImageTest.php
@@ -0,0 +1,146 @@
+createOne();
+
+ $facet = $image->facet;
+
+ static::assertInstanceOf(ImageFacet::class, $facet);
+ }
+
+ /**
+ * Images shall be auditable.
+ *
+ * @return void
+ */
+ public function testAuditable(): void
+ {
+ Config::set('audit.console', true);
+
+ $image = Image::factory()->createOne();
+
+ static::assertEquals(1, $image->audits()->count());
+ }
+
+ /**
+ * Images shall be nameable.
+ *
+ * @return void
+ */
+ public function testNameable(): void
+ {
+ $image = Image::factory()->createOne();
+
+ static::assertIsString($image->getName());
+ }
+
+ /**
+ * Image shall have a many-to-many relationship with the type Anime.
+ *
+ * @return void
+ */
+ public function testAnime(): void
+ {
+ $animeCount = $this->faker->randomDigitNotNull();
+
+ $image = Image::factory()
+ ->has(Anime::factory()->count($animeCount))
+ ->createOne();
+
+ static::assertInstanceOf(BelongsToMany::class, $image->anime());
+ static::assertEquals($animeCount, $image->anime()->count());
+ static::assertInstanceOf(Anime::class, $image->anime()->first());
+ static::assertEquals(AnimeImage::class, $image->anime()->getPivotClass());
+ }
+
+ /**
+ * Image shall have a many-to-many relationship with the type Artist.
+ *
+ * @return void
+ */
+ public function testArtists(): void
+ {
+ $artistCount = $this->faker->randomDigitNotNull();
+
+ $image = Image::factory()
+ ->has(Artist::factory()->count($artistCount))
+ ->createOne();
+
+ static::assertInstanceOf(BelongsToMany::class, $image->artists());
+ static::assertEquals($artistCount, $image->artists()->count());
+ static::assertInstanceOf(Artist::class, $image->artists()->first());
+ static::assertEquals(ArtistImage::class, $image->artists()->getPivotClass());
+ }
+
+ /**
+ * The image shall not be deleted from storage when the Image is deleted.
+ *
+ * @return void
+ */
+ public function testImageStorageDeletion(): void
+ {
+ $fs = Storage::fake('images');
+ $file = File::fake()->image($this->faker->word().'.jpg');
+ $fsFile = $fs->putFile('', $file);
+
+ $image = Image::factory()->createOne([
+ Image::ATTRIBUTE_FACET => ImageFacet::getRandomValue(),
+ Image::ATTRIBUTE_PATH => $fsFile,
+ ]);
+
+ $image->delete();
+
+ static::assertTrue($fs->exists($image->path));
+ }
+
+ /**
+ * The image shall be deleted from storage when the Image is force deleted.
+ *
+ * @return void
+ */
+ public function testImageStorageForceDeletion(): void
+ {
+ $fs = Storage::fake('images');
+ $file = File::fake()->image($this->faker->word().'.jpg');
+ $fsFile = $fs->putFile('', $file);
+
+ $image = Image::factory()->createOne([
+ Image::ATTRIBUTE_FACET => ImageFacet::getRandomValue(),
+ Image::ATTRIBUTE_PATH => $fsFile,
+ ]);
+
+ $image->forceDelete();
+
+ static::assertFalse($fs->exists($image->path));
+ }
+}
diff --git a/tests/Unit/Models/Wiki/SeriesTest.php b/tests/Unit/Models/Wiki/SeriesTest.php
new file mode 100644
index 000000000..7a3e6513d
--- /dev/null
+++ b/tests/Unit/Models/Wiki/SeriesTest.php
@@ -0,0 +1,90 @@
+createOne();
+
+ static::assertIsString($series->searchableAs());
+ }
+
+ /**
+ * Series shall be a searchable resource.
+ *
+ * @return void
+ */
+ public function testToSearchableArray(): void
+ {
+ $series = Series::factory()->createOne();
+
+ static::assertIsArray($series->toSearchableArray());
+ }
+
+ /**
+ * Series shall be auditable.
+ *
+ * @return void
+ */
+ public function testAuditable(): void
+ {
+ Config::set('audit.console', true);
+
+ $series = Series::factory()->createOne();
+
+ static::assertEquals(1, $series->audits()->count());
+ }
+
+ /**
+ * Series shall be nameable.
+ *
+ * @return void
+ */
+ public function testNameable(): void
+ {
+ $series = Series::factory()->createOne();
+
+ static::assertIsString($series->getName());
+ }
+
+ /**
+ * Series shall have a many-to-many relationship with the type Anime.
+ *
+ * @return void
+ */
+ public function testAnime(): void
+ {
+ $animeCount = $this->faker->randomDigitNotNull();
+
+ $series = Series::factory()
+ ->has(Anime::factory()->count($animeCount))
+ ->createOne();
+
+ static::assertInstanceOf(BelongsToMany::class, $series->anime());
+ static::assertEquals($animeCount, $series->anime()->count());
+ static::assertInstanceOf(Anime::class, $series->anime()->first());
+ static::assertEquals(AnimeSeries::class, $series->anime()->getPivotClass());
+ }
+}
diff --git a/tests/Unit/Models/Wiki/SongTest.php b/tests/Unit/Models/Wiki/SongTest.php
new file mode 100644
index 000000000..99ff9f7e1
--- /dev/null
+++ b/tests/Unit/Models/Wiki/SongTest.php
@@ -0,0 +1,111 @@
+createOne();
+
+ static::assertIsString($song->searchableAs());
+ }
+
+ /**
+ * Song shall be a searchable resource.
+ *
+ * @return void
+ */
+ public function testToSearchableArray(): void
+ {
+ $song = Song::factory()->createOne();
+
+ static::assertIsArray($song->toSearchableArray());
+ }
+
+ /**
+ * Songs shall be auditable.
+ *
+ * @return void
+ */
+ public function testAuditable(): void
+ {
+ Config::set('audit.console', true);
+
+ $song = Song::factory()->createOne();
+
+ static::assertEquals(1, $song->audits()->count());
+ }
+
+ /**
+ * Songs shall be nameable.
+ *
+ * @return void
+ */
+ public function testNameable(): void
+ {
+ $song = Song::factory()->createOne();
+
+ static::assertIsString($song->getName());
+ }
+
+ /**
+ * Song shall have a one-to-many relationship with the type Theme.
+ *
+ * @return void
+ */
+ public function testThemes(): void
+ {
+ $themeCount = $this->faker->randomDigitNotNull();
+
+ $song = Song::factory()
+ ->has(AnimeTheme::factory()->for(Anime::factory())->count($themeCount))
+ ->createOne();
+
+ static::assertInstanceOf(HasMany::class, $song->animethemes());
+ static::assertEquals($themeCount, $song->animethemes()->count());
+ static::assertInstanceOf(AnimeTheme::class, $song->animethemes()->first());
+ }
+
+ /**
+ * Song shall have a many-to-many relationship with the type Artist.
+ *
+ * @return void
+ */
+ public function testArtists(): void
+ {
+ $artistCount = $this->faker->randomDigitNotNull();
+
+ $song = Song::factory()
+ ->has(Artist::factory()->count($artistCount))
+ ->createOne();
+
+ static::assertInstanceOf(BelongsToMany::class, $song->artists());
+ static::assertEquals($artistCount, $song->artists()->count());
+ static::assertInstanceOf(Artist::class, $song->artists()->first());
+ static::assertEquals(ArtistSong::class, $song->artists()->getPivotClass());
+ }
+}
diff --git a/tests/Unit/Models/Wiki/StudioTest.php b/tests/Unit/Models/Wiki/StudioTest.php
new file mode 100644
index 000000000..60b91db85
--- /dev/null
+++ b/tests/Unit/Models/Wiki/StudioTest.php
@@ -0,0 +1,111 @@
+createOne();
+
+ static::assertIsString($studio->searchableAs());
+ }
+
+ /**
+ * Studio shall be a searchable resource.
+ *
+ * @return void
+ */
+ public function testToSearchableArray(): void
+ {
+ $studio = Studio::factory()->createOne();
+
+ static::assertIsArray($studio->toSearchableArray());
+ }
+
+ /**
+ * Studio shall be auditable.
+ *
+ * @return void
+ */
+ public function testAuditable(): void
+ {
+ Config::set('audit.console', true);
+
+ $studio = Studio::factory()->createOne();
+
+ static::assertEquals(1, $studio->audits()->count());
+ }
+
+ /**
+ * Studio shall be nameable.
+ *
+ * @return void
+ */
+ public function testNameable(): void
+ {
+ $studio = Studio::factory()->createOne();
+
+ static::assertIsString($studio->getName());
+ }
+
+ /**
+ * Studio shall have a many-to-many relationship with the type Anime.
+ *
+ * @return void
+ */
+ public function testAnime(): void
+ {
+ $animeCount = $this->faker->randomDigitNotNull();
+
+ $studio = Studio::factory()
+ ->has(Anime::factory()->count($animeCount))
+ ->createOne();
+
+ static::assertInstanceOf(BelongsToMany::class, $studio->anime());
+ static::assertEquals($animeCount, $studio->anime()->count());
+ static::assertInstanceOf(Anime::class, $studio->anime()->first());
+ static::assertEquals(AnimeStudio::class, $studio->anime()->getPivotClass());
+ }
+
+ /**
+ * Studio shall have a many-to-many relationship with the type ExternalResource.
+ *
+ * @return void
+ */
+ public function testExternalResources(): void
+ {
+ $resourceCount = $this->faker->randomDigitNotNull();
+
+ $studio = Studio::factory()
+ ->has(ExternalResource::factory()->count($resourceCount), 'resources')
+ ->createOne();
+
+ static::assertInstanceOf(BelongsToMany::class, $studio->resources());
+ static::assertEquals($resourceCount, $studio->resources()->count());
+ static::assertInstanceOf(ExternalResource::class, $studio->resources()->first());
+ static::assertEquals(StudioResource::class, $studio->resources()->getPivotClass());
+ }
+}
diff --git a/tests/Unit/Models/Wiki/VideoTest.php b/tests/Unit/Models/Wiki/VideoTest.php
new file mode 100644
index 000000000..4b1d8cd83
--- /dev/null
+++ b/tests/Unit/Models/Wiki/VideoTest.php
@@ -0,0 +1,291 @@
+createOne();
+
+ $overlap = $video->overlap;
+
+ static::assertInstanceOf(VideoOverlap::class, $overlap);
+ }
+
+ /**
+ * The source attribute of a video shall be cast to a VideoSource enum instance.
+ *
+ * @return void
+ */
+ public function testCastsSourceToEnum(): void
+ {
+ $video = Video::factory()->createOne();
+
+ $source = $video->source;
+
+ static::assertInstanceOf(VideoSource::class, $source);
+ }
+
+ /**
+ * Video shall be a searchable resource.
+ *
+ * @return void
+ */
+ public function testSearchableAs(): void
+ {
+ $video = Video::factory()->createOne();
+
+ static::assertIsString($video->searchableAs());
+ }
+
+ /**
+ * Video shall be a searchable resource.
+ *
+ * @return void
+ */
+ public function testToSearchableArray(): void
+ {
+ $video = Video::factory()->createOne();
+
+ static::assertIsArray($video->toSearchableArray());
+ }
+
+ /**
+ * Videos shall be auditable.
+ *
+ * @return void
+ */
+ public function testAuditable(): void
+ {
+ Config::set('audit.console', true);
+
+ $video = Video::factory()->createOne();
+
+ static::assertEquals(1, $video->audits()->count());
+ }
+
+ /**
+ * Videos shall be nameable.
+ *
+ * @return void
+ */
+ public function testNameable(): void
+ {
+ $video = Video::factory()->createOne();
+
+ static::assertIsString($video->getName());
+ }
+
+ /**
+ * Videos shall have a one-to-many polymorphic relationship to View.
+ *
+ * @return void
+ */
+ public function testViews(): void
+ {
+ $video = Video::factory()->createOne();
+
+ views($video)->record();
+
+ static::assertInstanceOf(MorphMany::class, $video->views());
+ static::assertEquals(1, $video->views()->count());
+ static::assertInstanceOf(View::class, $video->views()->first());
+ }
+
+ /**
+ * Videos shall append a 'tags' attribute.
+ *
+ * @return void
+ */
+ public function testAppendsTags(): void
+ {
+ $video = Video::factory()->createOne();
+
+ static::assertArrayHasKey(Video::ATTRIBUTE_TAGS, $video);
+ }
+
+ /**
+ * The Tags attribute shall contain 'NC' if the NC attribute is true.
+ *
+ * @return void
+ */
+ public function testNcTag(): void
+ {
+ $video = Video::factory()->createOne([
+ Video::ATTRIBUTE_NC => true,
+ ]);
+
+ static::assertContains('NC', $video->tags);
+ }
+
+ /**
+ * The Tags attribute shall not contain 'NC' if the NC attribute is false.
+ *
+ * @return void
+ */
+ public function testNoNcTag(): void
+ {
+ $video = Video::factory()->createOne([
+ Video::ATTRIBUTE_NC => false,
+ ]);
+
+ static::assertNotContains('NC', $video->tags);
+ }
+
+ /**
+ * The Tags attribute shall contain 'DVD' if the source is DVD.
+ *
+ * @return void
+ */
+ public function testDvdTag(): void
+ {
+ $source = VideoSource::DVD;
+
+ $video = Video::factory()->createOne([
+ Video::ATTRIBUTE_SOURCE => $source,
+ ]);
+
+ static::assertContains(VideoSource::getDescription($source), $video->tags);
+ }
+
+ /**
+ * The Tags attribute shall contain 'BD' if the source is BD.
+ *
+ * @return void
+ */
+ public function testBdTag(): void
+ {
+ $source = VideoSource::BD;
+
+ $video = Video::factory()->createOne([
+ Video::ATTRIBUTE_SOURCE => $source,
+ ]);
+
+ static::assertContains(VideoSource::getDescription($source), $video->tags);
+ }
+
+ /**
+ * The Tags attribute shall not contain the description of the source if not DVD or BD.
+ *
+ * @return void
+ */
+ public function testOtherSourceTag(): void
+ {
+ $source = null;
+ while ($source === null) {
+ $sourceCandidate = VideoSource::getRandomInstance();
+ if (! $sourceCandidate->is(VideoSource::BD) && ! $sourceCandidate->is(VideoSource::DVD)) {
+ $source = $sourceCandidate->value;
+ }
+ }
+
+ $video = Video::factory()->createOne([
+ Video::ATTRIBUTE_SOURCE => $source,
+ ]);
+
+ static::assertNotContains(VideoSource::getDescription($source), $video->tags);
+ }
+
+ /**
+ * The Tags attribute shall contain resolution.
+ *
+ * @return void
+ */
+ public function testResolutionTag(): void
+ {
+ $video = Video::factory()->createOne();
+
+ static::assertContains(strval($video->resolution), $video->tags);
+ }
+
+ /**
+ * The Tags attribute shall exclude 720 resolution from tags.
+ *
+ * @return void
+ */
+ public function testNo720ResolutionTag(): void
+ {
+ $video = Video::factory()->createOne([
+ Video::ATTRIBUTE_RESOLUTION => 720,
+ ]);
+
+ static::assertNotContains(strval($video->resolution), $video->tags);
+ }
+
+ /**
+ * The Tags attribute shall contain 'Subbed' if the subbed attribute is true.
+ *
+ * @return void
+ */
+ public function testSubbedTag(): void
+ {
+ $video = Video::factory()->createOne([
+ Video::ATTRIBUTE_SUBBED => true,
+ ]);
+
+ static::assertContains('Subbed', $video->tags);
+ static::assertNotContains('Lyrics', $video->tags);
+ }
+
+ /**
+ * The Tags attribute shall contain 'Lyrics' if the lyrics attribute is true and subbed attribute is false.
+ *
+ * @return void
+ */
+ public function testLyricsTag(): void
+ {
+ $video = Video::factory()->createOne([
+ Video::ATTRIBUTE_SUBBED => false,
+ Video::ATTRIBUTE_LYRICS => true,
+ ]);
+
+ static::assertNotContains('Subbed', $video->tags);
+ static::assertContains('Lyrics', $video->tags);
+ }
+
+ /**
+ * Videos shall have a many-to-many relationship with the type Entry.
+ *
+ * @return void
+ */
+ public function testEntries(): void
+ {
+ $entryCount = $this->faker->randomDigitNotNull();
+
+ $video = Video::factory()
+ ->has(AnimeThemeEntry::factory()->for(AnimeTheme::factory()->for(Anime::factory()))->count($entryCount))
+ ->createOne();
+
+ static::assertInstanceOf(BelongsToMany::class, $video->animethemeentries());
+ static::assertEquals($entryCount, $video->animethemeentries()->count());
+ static::assertInstanceOf(AnimeThemeEntry::class, $video->animethemeentries()->first());
+ static::assertEquals(AnimeThemeEntryVideo::class, $video->animethemeentries()->getPivotClass());
+ }
+}
diff --git a/tests/Unit/Notifications/DiscordNotificationTest.php b/tests/Unit/Notifications/DiscordNotificationTest.php
new file mode 100644
index 000000000..5707bdf99
--- /dev/null
+++ b/tests/Unit/Notifications/DiscordNotificationTest.php
@@ -0,0 +1,45 @@
+via(new AnonymousNotifiable()));
+ }
+
+ /**
+ * A Discord Notification shall deliver a Discord Message.
+ *
+ * @return void
+ */
+ public function testToDiscordMessage(): void
+ {
+ $message = DiscordMessage::create();
+
+ $notification = new DiscordNotification($message);
+
+ static::assertInstanceOf(DiscordMessage::class, $notification->toDiscord(new AnonymousNotifiable()));
+ }
+}
diff --git a/tests/Unit/Pivots/AnimeImageTest.php b/tests/Unit/Pivots/AnimeImageTest.php
new file mode 100644
index 000000000..cb56cd14c
--- /dev/null
+++ b/tests/Unit/Pivots/AnimeImageTest.php
@@ -0,0 +1,52 @@
+for(Anime::factory())
+ ->for(Image::factory())
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $animeImage->anime());
+ static::assertInstanceOf(Anime::class, $animeImage->anime()->first());
+ }
+
+ /**
+ * An AnimeImage shall belong to an Image.
+ *
+ * @return void
+ */
+ public function testImage(): void
+ {
+ $animeImage = AnimeImage::factory()
+ ->for(Anime::factory())
+ ->for(Image::factory())
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $animeImage->image());
+ static::assertInstanceOf(Image::class, $animeImage->image()->first());
+ }
+}
diff --git a/tests/Unit/Pivots/AnimeResourceTest.php b/tests/Unit/Pivots/AnimeResourceTest.php
new file mode 100644
index 000000000..da4a7db38
--- /dev/null
+++ b/tests/Unit/Pivots/AnimeResourceTest.php
@@ -0,0 +1,52 @@
+for(Anime::factory())
+ ->for(ExternalResource::factory(), 'resource')
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $animeResource->anime());
+ static::assertInstanceOf(Anime::class, $animeResource->anime()->first());
+ }
+
+ /**
+ * An AnimeResource shall belong to an ExternalResource.
+ *
+ * @return void
+ */
+ public function testResource(): void
+ {
+ $animeResource = AnimeResource::factory()
+ ->for(Anime::factory())
+ ->for(ExternalResource::factory(), 'resource')
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $animeResource->resource());
+ static::assertInstanceOf(ExternalResource::class, $animeResource->resource()->first());
+ }
+}
diff --git a/tests/Unit/Pivots/AnimeSeriesTest.php b/tests/Unit/Pivots/AnimeSeriesTest.php
new file mode 100644
index 000000000..0ac139dce
--- /dev/null
+++ b/tests/Unit/Pivots/AnimeSeriesTest.php
@@ -0,0 +1,52 @@
+for(Anime::factory())
+ ->for(Series::factory())
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $animeSeries->anime());
+ static::assertInstanceOf(Anime::class, $animeSeries->anime()->first());
+ }
+
+ /**
+ * An AnimeSeries shall belong to a Series.
+ *
+ * @return void
+ */
+ public function testSeries(): void
+ {
+ $animeSeries = AnimeSeries::factory()
+ ->for(Anime::factory())
+ ->for(Series::factory())
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $animeSeries->series());
+ static::assertInstanceOf(Series::class, $animeSeries->series()->first());
+ }
+}
diff --git a/tests/Unit/Pivots/AnimeStudioTest.php b/tests/Unit/Pivots/AnimeStudioTest.php
new file mode 100644
index 000000000..0df890d55
--- /dev/null
+++ b/tests/Unit/Pivots/AnimeStudioTest.php
@@ -0,0 +1,52 @@
+for(Anime::factory())
+ ->for(Studio::factory())
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $animeStudio->anime());
+ static::assertInstanceOf(Anime::class, $animeStudio->anime()->first());
+ }
+
+ /**
+ * An AnimeStudio shall belong to a Studio.
+ *
+ * @return void
+ */
+ public function testStudio(): void
+ {
+ $animeStudio = AnimeStudio::factory()
+ ->for(Anime::factory())
+ ->for(Studio::factory())
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $animeStudio->studio());
+ static::assertInstanceOf(Studio::class, $animeStudio->studio()->first());
+ }
+}
diff --git a/tests/Unit/Pivots/AnimeThemeEntryVideoTest.php b/tests/Unit/Pivots/AnimeThemeEntryVideoTest.php
new file mode 100644
index 000000000..491100b8e
--- /dev/null
+++ b/tests/Unit/Pivots/AnimeThemeEntryVideoTest.php
@@ -0,0 +1,51 @@
+for(Video::factory())
+ ->for(AnimeThemeEntry::factory()->for(AnimeTheme::factory()->for(Anime::factory())))
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $animeThemeEntryVideo->video());
+ static::assertInstanceOf(Video::class, $animeThemeEntryVideo->video()->first());
+ }
+
+ /**
+ * An AnimeThemeEntryVideo shall belong to an Entry.
+ *
+ * @return void
+ */
+ public function testEntry(): void
+ {
+ $animeThemeEntryVideo = AnimeThemeEntryVideo::factory()
+ ->for(Video::factory())
+ ->for(AnimeThemeEntry::factory()->for(AnimeTheme::factory()->for(Anime::factory())))
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $animeThemeEntryVideo->animethemeentry());
+ static::assertInstanceOf(AnimeThemeEntry::class, $animeThemeEntryVideo->animethemeentry()->first());
+ }
+}
diff --git a/tests/Unit/Pivots/ArtistImageTest.php b/tests/Unit/Pivots/ArtistImageTest.php
new file mode 100644
index 000000000..f39d0ae7b
--- /dev/null
+++ b/tests/Unit/Pivots/ArtistImageTest.php
@@ -0,0 +1,52 @@
+for(Artist::factory())
+ ->for(Image::factory())
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $artistImage->artist());
+ static::assertInstanceOf(Artist::class, $artistImage->artist()->first());
+ }
+
+ /**
+ * An ArtistImage shall belong to an Image.
+ *
+ * @return void
+ */
+ public function testImage(): void
+ {
+ $artistImage = ArtistImage::factory()
+ ->for(Artist::factory())
+ ->for(Image::factory())
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $artistImage->image());
+ static::assertInstanceOf(Image::class, $artistImage->image()->first());
+ }
+}
diff --git a/tests/Unit/Pivots/ArtistMemberTest.php b/tests/Unit/Pivots/ArtistMemberTest.php
new file mode 100644
index 000000000..846ace316
--- /dev/null
+++ b/tests/Unit/Pivots/ArtistMemberTest.php
@@ -0,0 +1,51 @@
+for(Artist::factory(), 'artist')
+ ->for(Artist::factory(), 'member')
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $artistMember->artist());
+ static::assertInstanceOf(Artist::class, $artistMember->artist()->first());
+ }
+
+ /**
+ * An ArtistMember shall belong to a Member.
+ *
+ * @return void
+ */
+ public function testMember(): void
+ {
+ $artistMember = ArtistMember::factory()
+ ->for(Artist::factory(), 'artist')
+ ->for(Artist::factory(), 'member')
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $artistMember->member());
+ static::assertInstanceOf(Artist::class, $artistMember->member()->first());
+ }
+}
diff --git a/tests/Unit/Pivots/ArtistResourceTest.php b/tests/Unit/Pivots/ArtistResourceTest.php
new file mode 100644
index 000000000..9c15e5d55
--- /dev/null
+++ b/tests/Unit/Pivots/ArtistResourceTest.php
@@ -0,0 +1,52 @@
+for(Artist::factory())
+ ->for(ExternalResource::factory(), 'resource')
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $artistResource->artist());
+ static::assertInstanceOf(Artist::class, $artistResource->artist()->first());
+ }
+
+ /**
+ * An ArtistResource shall belong to an ExternalResource.
+ *
+ * @return void
+ */
+ public function testResource(): void
+ {
+ $artistResource = ArtistResource::factory()
+ ->for(Artist::factory())
+ ->for(ExternalResource::factory(), 'resource')
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $artistResource->resource());
+ static::assertInstanceOf(ExternalResource::class, $artistResource->resource()->first());
+ }
+}
diff --git a/tests/Unit/Pivots/ArtistSongTest.php b/tests/Unit/Pivots/ArtistSongTest.php
new file mode 100644
index 000000000..ca8b237ae
--- /dev/null
+++ b/tests/Unit/Pivots/ArtistSongTest.php
@@ -0,0 +1,52 @@
+for(Artist::factory())
+ ->for(Song::factory())
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $artistSong->artist());
+ static::assertInstanceOf(Artist::class, $artistSong->artist()->first());
+ }
+
+ /**
+ * An ArtistSong shall belong to a Song.
+ *
+ * @return void
+ */
+ public function testSong(): void
+ {
+ $artistSong = ArtistSong::factory()
+ ->for(Artist::factory())
+ ->for(Song::factory())
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $artistSong->song());
+ static::assertInstanceOf(Song::class, $artistSong->song()->first());
+ }
+}
diff --git a/tests/Unit/Pivots/StudioResourceTest.php b/tests/Unit/Pivots/StudioResourceTest.php
new file mode 100644
index 000000000..16985036f
--- /dev/null
+++ b/tests/Unit/Pivots/StudioResourceTest.php
@@ -0,0 +1,52 @@
+for(Studio::factory())
+ ->for(ExternalResource::factory(), 'resource')
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $studioResource->studio());
+ static::assertInstanceOf(Studio::class, $studioResource->studio()->first());
+ }
+
+ /**
+ * A StudioResource shall belong to an ExternalResource.
+ *
+ * @return void
+ */
+ public function testResource(): void
+ {
+ $studioResource = StudioResource::factory()
+ ->for(Studio::factory())
+ ->for(ExternalResource::factory(), 'resource')
+ ->createOne();
+
+ static::assertInstanceOf(BelongsTo::class, $studioResource->resource());
+ static::assertInstanceOf(ExternalResource::class, $studioResource->resource()->first());
+ }
+}
diff --git a/tests/Unit/Rules/Api/DistinctIgnoringDirectionTest.php b/tests/Unit/Rules/Api/DistinctIgnoringDirectionTest.php
new file mode 100644
index 000000000..8d53f7a58
--- /dev/null
+++ b/tests/Unit/Rules/Api/DistinctIgnoringDirectionTest.php
@@ -0,0 +1,71 @@
+faker->word();
+
+ $rule = new DistinctIgnoringDirectionRule();
+
+ $sorts = collect()->pad($this->faker->numberBetween(2, 9), $key);
+
+ static::assertFalse($rule->passes($this->faker->word(), $sorts->join(',')));
+ }
+
+ /**
+ * The Distinct Ignoring Direction Rule shall return false if there exists duplicate sort keys of differing directions.
+ *
+ * @return void
+ */
+ public function testFailsIfDuplicateSortDifferentDirection(): void
+ {
+ $key = $this->faker->word();
+
+ $rule = new DistinctIgnoringDirectionRule();
+
+ $sort = new Sort($key);
+
+ $sorts = [];
+
+ foreach (Direction::getInstances() as $direction) {
+ $sorts[] = $sort->format($direction);
+ }
+
+ static::assertFalse($rule->passes($this->faker->word(), implode(',', $sorts)));
+ }
+
+ /**
+ * The Distinct Ignoring Direction Rule shall return true if there exist no duplicate sort keys.
+ *
+ * @return void
+ */
+ public function testPassesIfNoDuplicates(): void
+ {
+ $rule = new DistinctIgnoringDirectionRule();
+
+ $sorts = collect($this->faker->words($this->faker->randomDigitNotNull()))->unique();
+
+ static::assertTrue($rule->passes($this->faker->word(), $sorts->join(',')));
+ }
+}
diff --git a/tests/Unit/Rules/Api/EnumDescriptionTest.php b/tests/Unit/Rules/Api/EnumDescriptionTest.php
new file mode 100644
index 000000000..f10bae792
--- /dev/null
+++ b/tests/Unit/Rules/Api/EnumDescriptionTest.php
@@ -0,0 +1,76 @@
+faker->numberBetween(0, 2)) extends BaseEnum
+ {
+ public const ZERO = 0;
+ public const ONE = 1;
+ public const TWO = 2;
+ };
+
+ $rule = new EnumDescriptionRule(get_class($enum));
+
+ static::assertTrue($rule->passes($this->faker->word(), $enum->description));
+ }
+
+ /**
+ * The Enum Description Rule shall return false if an enum value is provided.
+ *
+ * @return void
+ */
+ public function testFailsIfEnumValue(): void
+ {
+ $enum = new class($this->faker->numberBetween(0, 2)) extends BaseEnum
+ {
+ public const ZERO = 0;
+ public const ONE = 1;
+ public const TWO = 2;
+ };
+
+ $rule = new EnumDescriptionRule(get_class($enum));
+
+ static::assertFalse($rule->passes($this->faker->word(), $enum->value));
+ }
+
+ /**
+ * The Enum Description Rule shall return false if an enum description is not provided.
+ *
+ * @return void
+ */
+ public function testFailsIfString(): void
+ {
+ $enum = new class($this->faker->numberBetween(0, 2)) extends BaseEnum
+ {
+ public const ZERO = 0;
+ public const ONE = 1;
+ public const TWO = 2;
+ };
+
+ $rule = new EnumDescriptionRule(get_class($enum));
+
+ static::assertFalse($rule->passes($this->faker->word(), Str::random()));
+ }
+}
diff --git a/tests/Unit/Rules/Api/IsValidBooleanTest.php b/tests/Unit/Rules/Api/IsValidBooleanTest.php
new file mode 100644
index 000000000..2cf219f68
--- /dev/null
+++ b/tests/Unit/Rules/Api/IsValidBooleanTest.php
@@ -0,0 +1,100 @@
+faker->boolean();
+
+ $rule = new IsValidBoolean();
+
+ static::assertTrue($rule->passes($this->faker->word(), $boolean));
+ }
+
+ /**
+ * The Is Valid Boolean Rule shall return true if "true" or "false" is provided.
+ *
+ * @return void
+ */
+ public function testPassesIfBooleanString(): void
+ {
+ $booleanString = $this->faker->boolean() ? 'true' : 'false';
+
+ $rule = new IsValidBoolean();
+
+ static::assertTrue($rule->passes($this->faker->word(), $booleanString));
+ }
+
+ /**
+ * The Is Valid Boolean Rule shall return true if 1 or 0 is provided.
+ *
+ * @return void
+ */
+ public function testPassesIfBooleanInteger(): void
+ {
+ $booleanInteger = $this->faker->boolean() ? 1 : 0;
+
+ $rule = new IsValidBoolean();
+
+ static::assertTrue($rule->passes($this->faker->word(), $booleanInteger));
+ }
+
+ /**
+ * The Is Valid Boolean Rule shall return true if "on" or "off" is provided.
+ *
+ * @return void
+ */
+ public function testPassesIfBooleanCheckbox(): void
+ {
+ $booleanCheckbox = $this->faker->boolean() ? 'on' : 'off';
+
+ $rule = new IsValidBoolean();
+
+ static::assertTrue($rule->passes($this->faker->word(), $booleanCheckbox));
+ }
+
+ /**
+ * The Is Valid Boolean Rule shall return false a string is provided.
+ *
+ * @return void
+ */
+ public function testFailsIfString(): void
+ {
+ $rule = new IsValidBoolean();
+
+ static::assertFalse($rule->passes($this->faker->word(), Str::random()));
+ }
+
+ /**
+ * The Is Valid Boolean Rule shall return false a number is provided.
+ *
+ * @return void
+ */
+ public function testFailsIfNumber(): void
+ {
+ $number = $this->faker->numberBetween(2, 9);
+
+ $rule = new IsValidBoolean();
+
+ static::assertFalse($rule->passes($this->faker->word(), $number));
+ }
+}
diff --git a/tests/Unit/Rules/Api/RandomSoleTest.php b/tests/Unit/Rules/Api/RandomSoleTest.php
new file mode 100644
index 000000000..3a9bf61a3
--- /dev/null
+++ b/tests/Unit/Rules/Api/RandomSoleTest.php
@@ -0,0 +1,60 @@
+faker->words($this->faker->randomDigitNotNull());
+
+ $sorts[] = RandomCriteria::PARAM_VALUE;
+
+ $rule = new RandomSoleRule();
+
+ static::assertFalse($rule->passes($this->faker->word(), implode(',', $sorts)));
+ }
+
+ /**
+ * The Random Sole Rule shall return true if the random key is not provided.
+ *
+ * @return void
+ */
+ public function testPassesIfRandomIsNotIncluded(): void
+ {
+ $sorts = $this->faker->words($this->faker->randomDigitNotNull());
+
+ $rule = new RandomSoleRule();
+
+ static::assertTrue($rule->passes($this->faker->word(), implode(',', $sorts)));
+ }
+
+ /**
+ * The Random Sole Rule shall return true if the random key is the only sort provided.
+ *
+ * @return void
+ */
+ public function testPassesIfRandomIsSoleSort(): void
+ {
+ $rule = new RandomSoleRule();
+
+ static::assertTrue($rule->passes($this->faker->word(), RandomCriteria::PARAM_VALUE));
+ }
+}
diff --git a/tests/Unit/Rules/Billing/TransparencyDateTest.php b/tests/Unit/Rules/Billing/TransparencyDateTest.php
new file mode 100644
index 000000000..ee7c5256b
--- /dev/null
+++ b/tests/Unit/Rules/Billing/TransparencyDateTest.php
@@ -0,0 +1,96 @@
+passes($this->faker->word(), $this->faker->word()));
+ }
+
+ /**
+ * The Transparency Date Rule shall return false if the value is numeric.
+ *
+ * @return void
+ */
+ public function testIntInvalid(): void
+ {
+ $validDates = collect([Date::now()]);
+
+ $rule = new TransparencyDateRule($validDates);
+
+ static::assertFalse($rule->passes($this->faker->word(), $this->faker->numberBetween()));
+ }
+
+ /**
+ * The Transparency Date Rule shall return false if the date format is not 'Y-m'.
+ *
+ * @return void
+ */
+ public function testInvalidDateFormat(): void
+ {
+ $validDates = collect([Date::now()]);
+
+ $rule = new TransparencyDateRule($validDates);
+
+ $formattedDate = Date::now()->format(DateTimeInterface::RFC822);
+
+ static::assertFalse($rule->passes($this->faker->word(), $formattedDate));
+ }
+
+ /**
+ * The Transparency Date Rule shall return false if the date is not a valid option.
+ *
+ * @return void
+ */
+ public function testInvalidDateOption(): void
+ {
+ $validDates = collect([Date::now()]);
+
+ $rule = new TransparencyDateRule($validDates);
+
+ $formattedDate = Date::now()->subMonths($this->faker->randomDigitNotNull())->format(AllowedDateFormat::YM);
+
+ static::assertFalse($rule->passes($this->faker->word(), $formattedDate));
+ }
+
+ /**
+ * The Transparency Date Rule shall return true if the date is a valid option.
+ *
+ * @return void
+ */
+ public function testValidDateOption(): void
+ {
+ $validDates = collect([Date::now()]);
+
+ $rule = new TransparencyDateRule($validDates);
+
+ $formattedDate = Date::now()->format(AllowedDateFormat::YM);
+
+ static::assertTrue($rule->passes($this->faker->word(), $formattedDate));
+ }
+}
diff --git a/tests/Unit/Rules/Wiki/ResourceLinkMatchesSiteTest.php b/tests/Unit/Rules/Wiki/ResourceLinkMatchesSiteTest.php
new file mode 100644
index 000000000..c52a3431c
--- /dev/null
+++ b/tests/Unit/Rules/Wiki/ResourceLinkMatchesSiteTest.php
@@ -0,0 +1,73 @@
+value);
+ if ($domain !== null) {
+ $url = 'https://'.$domain;
+ }
+
+ $rule = new ResourceLinkMatchesSiteRule($site->value);
+
+ static::assertTrue($rule->passes($this->faker->word(), $url));
+ }
+
+ /**
+ * The Resource Link Matches Site Rule shall return true if the site does not have a domain.
+ *
+ * @return void
+ */
+ public function testPassesIfSiteIsOfficial(): void
+ {
+ $site = ResourceSite::OFFICIAL_SITE;
+
+ $rule = new ResourceLinkMatchesSiteRule($site);
+
+ static::assertTrue($rule->passes($this->faker->word(), $this->faker->url()));
+ }
+
+ /**
+ * The Resource Link Matches Site Rule shall return false if the link does not match the site.
+ *
+ * @return void
+ */
+ public function testFailsIfSiteDoesNotMatch(): void
+ {
+ $site = null;
+
+ while ($site === null) {
+ $siteCandidate = ResourceSite::getRandomInstance();
+ if (! $siteCandidate->is(ResourceSite::OFFICIAL_SITE)) {
+ $site = $siteCandidate;
+ }
+ }
+
+ $rule = new ResourceLinkMatchesSiteRule($site->value);
+
+ static::assertFalse($rule->passes($this->faker->word(), $this->faker->url()));
+ }
+}
diff --git a/tests/Unit/Rules/Wiki/ResourceSiteMatchesLinkTest.php b/tests/Unit/Rules/Wiki/ResourceSiteMatchesLinkTest.php
new file mode 100644
index 000000000..4bc79a7da
--- /dev/null
+++ b/tests/Unit/Rules/Wiki/ResourceSiteMatchesLinkTest.php
@@ -0,0 +1,78 @@
+is(ResourceSite::OFFICIAL_SITE)) {
+ $site = $siteCandidate;
+ }
+ }
+
+ $url = null;
+ $domain = ResourceSite::getDomain($site->value);
+ if ($domain !== null) {
+ $url = 'https://'.$domain;
+ }
+
+ $rule = new ResourceSiteMatchesLinkRule($url);
+
+ static::assertTrue($rule->passes($this->faker->word(), $site->value));
+ }
+
+ /**
+ * The Resource Site Matches Link Rule shall return true if the site does not have a domain.
+ *
+ * @return void
+ */
+ public function testResourceSiteDomainRuleOfficialPasses(): void
+ {
+ $rule = new ResourceSiteMatchesLinkRule($this->faker->url());
+
+ static::assertTrue($rule->passes($this->faker->word(), ResourceSite::OFFICIAL_SITE));
+ }
+
+ /**
+ * The Resource Site Matches Link Rule shall return false if the site does not match the link.
+ *
+ * @return void
+ */
+ public function testResourceSiteDomainRuleFails(): void
+ {
+ $site = null;
+
+ while ($site === null) {
+ $siteCandidate = ResourceSite::getRandomInstance();
+ if (! $siteCandidate->is(ResourceSite::OFFICIAL_SITE)) {
+ $site = $siteCandidate;
+ }
+ }
+
+ $rule = new ResourceSiteMatchesLinkRule($this->faker->url());
+
+ static::assertFalse($rule->passes($this->faker->word(), $site->value));
+ }
+}
diff --git a/tests/Unit/Services/Discord/DiscordEmbedFieldTest.php b/tests/Unit/Services/Discord/DiscordEmbedFieldTest.php
new file mode 100644
index 000000000..dc1a953cb
--- /dev/null
+++ b/tests/Unit/Services/Discord/DiscordEmbedFieldTest.php
@@ -0,0 +1,143 @@
+faker->numberBetween(0, 2)) extends BaseEnum
+ {
+ public const ZERO = 0;
+ public const ONE = 1;
+ public const TWO = 2;
+ };
+
+ $field = new DiscordEmbedField($this->faker->word(), $enum);
+
+ static::assertEquals($enum->description, Arr::get($field->toArray(), 'value'));
+ }
+
+ /**
+ * Discord Embed Fields shall format a boolean value by pretty printing.
+ *
+ * @return void
+ */
+ public function testDiscordEmbedFormatBoolean(): void
+ {
+ $boolean = $this->faker->boolean();
+
+ $field = new DiscordEmbedField($this->faker->word(), $boolean);
+
+ static::assertEquals($boolean ? 'true' : 'false', Arr::get($field->toArray(), 'value'));
+ }
+
+ /**
+ * Discord Embed Fields shall format numbers by their string value.
+ *
+ * @return void
+ */
+ public function testDiscordEmbedFormatNumber(): void
+ {
+ $number = $this->faker->randomNumber();
+
+ $field = new DiscordEmbedField($this->faker->word(), $number);
+
+ static::assertEquals(strval($number), Arr::get($field->toArray(), 'value'));
+ }
+
+ /**
+ * Discord Embed Fields shall format floats by their string value.
+ *
+ * @return void
+ */
+ public function testDiscordEmbedFormatFloat(): void
+ {
+ $float = $this->faker->randomFloat();
+
+ $field = new DiscordEmbedField($this->faker->word(), $float);
+
+ static::assertEquals(strval($float), Arr::get($field->toArray(), 'value'));
+ }
+
+ /**
+ * Discord Embed Fields shall format non-empty string by their string value.
+ *
+ * @return void
+ */
+ public function testDiscordEmbedFormatString(): void
+ {
+ $string = $this->faker->word();
+
+ $field = new DiscordEmbedField($this->faker->word(), $string);
+
+ static::assertEquals($string, Arr::get($field->toArray(), 'value'));
+ }
+
+ /**
+ * Discord Embed Fields shall use a default value for empty string values.
+ *
+ * @return void
+ */
+ public function testDiscordEmbedFormatEmptyString(): void
+ {
+ $field = new DiscordEmbedField($this->faker->word(), '');
+
+ static::assertEquals(DiscordEmbedField::DEFAULT_FIELD_VALUE, Arr::get($field->toArray(), 'value'));
+ }
+
+ /**
+ * Discord Embed Fields shall use a default value for null values.
+ *
+ * @return void
+ */
+ public function testDiscordEmbedFormatNull(): void
+ {
+ $field = new DiscordEmbedField($this->faker->word(), null);
+
+ static::assertEquals(DiscordEmbedField::DEFAULT_FIELD_VALUE, Arr::get($field->toArray(), 'value'));
+ }
+
+ /**
+ * Discord Embed Fields shall use a default value for arrays.
+ *
+ * @return void
+ */
+ public function testDiscordEmbedFormatArray(): void
+ {
+ $field = new DiscordEmbedField($this->faker->word(), []);
+
+ static::assertEquals(DiscordEmbedField::DEFAULT_FIELD_VALUE, Arr::get($field->toArray(), 'value'));
+ }
+
+ /**
+ * Discord Embed Fields shall use a default value for objects.
+ *
+ * @return void
+ */
+ public function testDiscordEmbedFormatObject(): void
+ {
+ $field = new DiscordEmbedField($this->faker->word(), new stdClass());
+
+ static::assertEquals(DiscordEmbedField::DEFAULT_FIELD_VALUE, Arr::get($field->toArray(), 'value'));
+ }
+}
diff --git a/tests/Unit/Services/Elasticsearch/Api/Parser/FilterParserTest.php b/tests/Unit/Services/Elasticsearch/Api/Parser/FilterParserTest.php
new file mode 100644
index 000000000..830ea93e9
--- /dev/null
+++ b/tests/Unit/Services/Elasticsearch/Api/Parser/FilterParserTest.php
@@ -0,0 +1,106 @@
+faker->word());
+
+ $predicate = new Predicate($this->faker->word(), ComparisonOperator::getRandomInstance(), $expression);
+
+ $criteria = new BaseWhereCriteria($predicate, BinaryLogicalOperator::getRandomInstance(), new GlobalScope());
+
+ static::assertInstanceOf(WhereCriteria::class, FilterParser::parse($criteria));
+ }
+
+ /**
+ * The Filter Parser shall parse Where In Criteria.
+ *
+ * @return void
+ */
+ public function testWhereInCriteria(): void
+ {
+ $expression = new Expression($this->faker->word());
+
+ $predicate = new Predicate($this->faker->word(), ComparisonOperator::getRandomInstance(), $expression);
+
+ $criteria = new BaseWhereInCriteria(
+ $predicate,
+ BinaryLogicalOperator::getRandomInstance(),
+ $this->faker->boolean(),
+ new GlobalScope()
+ );
+
+ static::assertInstanceOf(WhereInCriteria::class, FilterParser::parse($criteria));
+ }
+
+ /**
+ * The Filter Parser shall not parse Has Criteria.
+ *
+ * @return void
+ */
+ public function testHasCriteria(): void
+ {
+ $expression = new Expression($this->faker->word());
+
+ $predicate = new Predicate($this->faker->word(), ComparisonOperator::getRandomInstance(), $expression);
+
+ $criteria = new HasCriteria(
+ $predicate,
+ BinaryLogicalOperator::getRandomInstance(),
+ new GlobalScope(),
+ $this->faker->randomDigitNotNull()
+ );
+
+ static::assertNull(FilterParser::parse($criteria));
+ }
+
+ /**
+ * The Filter Parser shall not parse Trashed Criteria.
+ *
+ * @return void
+ */
+ public function testTrashedCriteria(): void
+ {
+ $expression = new Expression($this->faker->word());
+
+ $predicate = new Predicate($this->faker->word(), ComparisonOperator::getRandomInstance(), $expression);
+
+ $criteria = new TrashedCriteria(
+ $predicate,
+ BinaryLogicalOperator::getRandomInstance(),
+ new GlobalScope()
+ );
+
+ static::assertNull(FilterParser::parse($criteria));
+ }
+}
diff --git a/tests/Unit/Services/Elasticsearch/Api/Parser/PagingParserTest.php b/tests/Unit/Services/Elasticsearch/Api/Parser/PagingParserTest.php
new file mode 100644
index 000000000..39e420375
--- /dev/null
+++ b/tests/Unit/Services/Elasticsearch/Api/Parser/PagingParserTest.php
@@ -0,0 +1,45 @@
+faker->randomDigitNotNull());
+
+ static::assertInstanceOf(LimitCriteria::class, PagingParser::parse($criteria));
+ }
+
+ /**
+ * The Paging Parser shall parse Offset Criteria.
+ *
+ * @return void
+ */
+ public function testOffsetCriteria(): void
+ {
+ $criteria = new BaseOffsetCriteria($this->faker->randomDigitNotNull());
+
+ static::assertInstanceOf(OffsetCriteria::class, PagingParser::parse($criteria));
+ }
+}
diff --git a/tests/Unit/Services/Elasticsearch/Api/Parser/SortParserTest.php b/tests/Unit/Services/Elasticsearch/Api/Parser/SortParserTest.php
new file mode 100644
index 000000000..a25d7ac2f
--- /dev/null
+++ b/tests/Unit/Services/Elasticsearch/Api/Parser/SortParserTest.php
@@ -0,0 +1,60 @@
+faker->word(), Direction::getRandomInstance());
+
+ static::assertInstanceOf(RelationCriteria::class, SortParser::parse($criteria));
+ }
+
+ /**
+ * The Sort Parser shall parse Field Criteria.
+ *
+ * @return void
+ */
+ public function testFieldCriteria(): void
+ {
+ $criteria = new BaseFieldCriteria(new GlobalScope(), $this->faker->word(), Direction::getRandomInstance());
+
+ static::assertInstanceOf(FieldCriteria::class, SortParser::parse($criteria));
+ }
+
+ /**
+ * The Sort Parser shall not parse Random Criteria.
+ *
+ * @return void
+ */
+ public function testRandomCriteria(): void
+ {
+ $criteria = new RandomCriteria(new GlobalScope());
+
+ static::assertNull(SortParser::parse($criteria));
+ }
+}
diff --git a/webpack.mix.js b/webpack.mix.js
index 5b131c3da..6f3d41c67 100644
--- a/webpack.mix.js
+++ b/webpack.mix.js
@@ -1,22 +1,23 @@
-let mix = require('laravel-mix');
+const mix = require('laravel-mix');
-mix.copy('node_modules/jquery/dist/jquery.min.js', 'public/js/vendor/jquery.min.js');
+/*
+ |--------------------------------------------------------------------------
+ | Mix Asset Management
+ |--------------------------------------------------------------------------
+ |
+ | Mix provides a clean, fluent API for defining some Webpack build steps
+ | for your Laravel applications. By default, we are compiling the CSS
+ | file for the application as well as bundling up all the JS files.
+ |
+ */
-var modernizr = require("modernizr");
+mix
+ .js('resources/js/app.js', 'public/js')
+ .postCss('resources/css/app.css', 'public/css', [
+ require('postcss-import'),
+ require('tailwindcss'),
+ ]);
-fs = require('fs');
-
-modernizr.build({
- "options": [
- "setClasses"
- ],
- "feature-detects": [
- "video"
- ]
-}, function (result) {
- fs.writeFile('public/js/vendor/modernizr.min.js', result, function (err, data) {
- if (err) {
- return console.log(err);
- }
- });
-});
+if (mix.inProduction()) {
+ mix.version();
+}