diff --git a/app/Enums/MALImportBehavior.php b/app/Enums/MALImportBehavior.php new file mode 100644 index 000000000..6ba4f76ec --- /dev/null +++ b/app/Enums/MALImportBehavior.php @@ -0,0 +1,15 @@ +user = $user; $this->results = $results; diff --git a/app/Http/Controllers/LibraryController.php b/app/Http/Controllers/LibraryController.php index 1c4b94ffd..a3008a262 100644 --- a/app/Http/Controllers/LibraryController.php +++ b/app/Http/Controllers/LibraryController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Enums\MALImportBehavior; use App\Models\Anime; use App\Enums\UserLibraryStatus; use App\Helpers\JSONResult; @@ -153,8 +154,11 @@ function malImport(MALImportRequest $request): JsonResponse // Read XML file $xmlContent = File::get($data['file']->getRealPath()); + // Get import behavior + $behavior = MALImportBehavior::fromValue((int) $data['behavior']); + // Dispatch job - dispatch(new ProcessMALImport($user, $xmlContent, $data['behavior'])); + dispatch(new ProcessMALImport($user, $xmlContent, $behavior)); // Update last MAL import date for user $user->last_mal_import_at = now(); diff --git a/app/Http/Requests/MALImportRequest.php b/app/Http/Requests/MALImportRequest.php index 1af2881e1..07504a06d 100644 --- a/app/Http/Requests/MALImportRequest.php +++ b/app/Http/Requests/MALImportRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Enums\MALImportBehavior; use Illuminate\Foundation\Http\FormRequest; class MALImportRequest extends FormRequest @@ -25,7 +26,7 @@ public function rules(): array { return [ 'file' => ['required', 'file', 'mimes:xml', 'max:' . config('mal-import.max_xml_file_size')], - 'behavior' => ['required', 'string', 'in:overwrite'] + 'behavior' => ['required', 'integer', 'in:' . implode(',', MALImportBehavior::getValues())] ]; } } diff --git a/app/Jobs/ProcessMALImport.php b/app/Jobs/ProcessMALImport.php index 048b08162..6893cf211 100644 --- a/app/Jobs/ProcessMALImport.php +++ b/app/Jobs/ProcessMALImport.php @@ -2,8 +2,11 @@ namespace App\Jobs; +use App\Enums\MALImportBehavior; use App\Models\Anime; use App\Enums\UserLibraryStatus; +use App\Models\AnimeRating; +use App\Models\UserLibrary; use App\Notifications\MALImportFinished; use App\Models\User; use Illuminate\Bus\Queueable; @@ -40,9 +43,9 @@ class ProcessMALImport implements ShouldQueue /** * The behavior of the import action. * - * @var string + * @var MALImportBehavior */ - protected string $behavior; + protected MALImportBehavior $behavior; /** * The results of the import action. @@ -59,9 +62,9 @@ class ProcessMALImport implements ShouldQueue * * @param User $user * @param string $xmlContent - * @param string $behavior + * @param MALImportBehavior $behavior */ - public function __construct(User $user, string $xmlContent, string $behavior) + public function __construct(User $user, string $xmlContent, MALImportBehavior $behavior) { $this->user = $user; $this->xmlContent = $xmlContent; @@ -74,8 +77,9 @@ public function __construct(User $user, string $xmlContent, string $behavior) public function handle() { // Wipe current library if behavior is set to overwrite - if ($this->behavior === 'overwrite') { + if ($this->behavior->value === MALImportBehavior::Overwrite) { $this->user->library()->detach(); + $this->user->animeRating()->delete(); } // Create XML object @@ -87,7 +91,7 @@ public function handle() // Loop through the anime in the export file foreach($json['anime'] as $anime) { - $this->handleXMLFileAnime($anime['series_animedb_id'], $anime['my_status']); + $this->handleXMLFileAnime($anime['series_animedb_id'], $anime['my_status'], $anime['my_score']); } // Notify the user that the MAL import was finished @@ -99,16 +103,18 @@ public function handle() * * @param int $malID * @param string $malStatus + * @param int $malRating */ - protected function handleXMLFileAnime(int $malID, string $malStatus) + protected function handleXMLFileAnime(int $malID, string $malStatus, int $malRating) { // Try to find the Anime in our DB - $animeMatch = Anime::where('mal_id', $malID)->first(); + $anime = Anime::firstWhere('mal_id', $malID); // If a match was found - if ($animeMatch) { - // Convert the MAL status to one of our own + if (!empty($anime)) { + // Convert the MAL data to our own $status = $this->convertMALStatus($malStatus); + $rating = $this->convertMALRating($malRating); // Status not found if ($status === null) { @@ -117,11 +123,25 @@ protected function handleXMLFileAnime(int $malID, string $malStatus) } // Add the anime to their library - $this->user->library()->attach($animeMatch, ['status' => $status]); - - $this->registerSuccess($animeMatch->id, $malID, $status); + UserLibrary::updateOrCreate([ + 'user_id' => $this->user->id, + 'anime_id' => $anime->id, + ], [ + 'status' => $status + ]); + + // Updated their anime score + AnimeRating::updateOrCreate([ + 'user_id' => $this->user->id, + 'anime_id' => $anime->id, + ], [ + 'rating' => $rating, + ]); + + $this->registerSuccess($anime->id, $malID, $status, $rating); + } else { + $this->registerFailure($malID, 'MAL ID could not be found.'); } - else $this->registerFailure($malID, 'MAL ID could not be found.'); } /** @@ -142,19 +162,36 @@ protected function convertMALStatus(string $malStatus): ?int }; } + /** + * Converts and returns Kurozora specific rating. + * + * @param int $malRating + * @return int + */ + protected function convertMALRating(int $malRating): int + { + if ($malRating == 0) { + return $malRating; + } + + return round($malRating) * 0.5; + } + /** * Registers a success in the import process. * * @param int $animeID * @param int $malID * @param string $status + * @param int $rating */ - protected function registerSuccess(int $animeID, int $malID, string $status) + protected function registerSuccess(int $animeID, int $malID, string $status, int $rating) { $this->results['successful'][] = [ 'anime_id' => $animeID, 'mal_id' => $malID, - 'status' => $status + 'status' => $status, + 'rating' => $rating, ]; } diff --git a/app/Models/User.php b/app/Models/User.php index 190873fb7..6e34b8453 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -143,6 +143,16 @@ class User extends Authenticatable implements HasMedia, MustVerifyEmail, Reacter */ protected string $profileImageCollectionName = 'profile'; + /** + * Returns the anime ratings the user has. + * + * @return HasMany + */ + public function animeRating(): HasMany + { + return $this->hasMany(AnimeRating::class); + } + /** * Returns the associated feed messages for the user. * diff --git a/app/Notifications/MALImportFinished.php b/app/Notifications/MALImportFinished.php index 817f3b7f4..3cfddd937 100644 --- a/app/Notifications/MALImportFinished.php +++ b/app/Notifications/MALImportFinished.php @@ -2,6 +2,7 @@ namespace App\Notifications; +use App\Enums\MALImportBehavior; use App\Models\User; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -23,17 +24,17 @@ class MALImportFinished extends Notification implements ShouldQueue /** * The behavior used when importing. * - * @var string $behavior + * @var MALImportBehavior $behavior */ - private string $behavior; + private MALImportBehavior $behavior; /** * Create a new notification instance. * * @param array $results - * @param string $behavior + * @param MALImportBehavior $behavior */ - public function __construct(array $results, string $behavior) + public function __construct(array $results, MALImportBehavior $behavior) { $this->results = $results; $this->behavior = $behavior; @@ -61,7 +62,7 @@ public function toDatabase(mixed $notifiable): array return [ 'successful_count' => count($this->results['successful']), 'failure_count' => count($this->results['failure']), - 'behavior' => $this->behavior + 'behavior' => $this->behavior->description ]; } diff --git a/config/app.php b/config/app.php index 7d2e1ac29..c163d25b5 100644 --- a/config/app.php +++ b/config/app.php @@ -38,7 +38,7 @@ | or any other location as required by the application or its packages. */ - 'version' => '1.2.0-alpha.89', + 'version' => '1.2.0-alpha.91', /* |-------------------------------------------------------------------------- diff --git a/config/mal-import.php b/config/mal-import.php index 633986eec..baf447240 100644 --- a/config/mal-import.php +++ b/config/mal-import.php @@ -10,11 +10,11 @@ | This option controls the cooldown in days for users between importing | a MAL export file. | - | Default: 7 + | Default: 3 | */ - 'cooldown_in_days' => 7, + 'cooldown_in_days' => 3, /* |-------------------------------------------------------------------------- @@ -24,10 +24,10 @@ | This option controls the maximum allowed file size for a MAL export file | in kilobytes. | - | Default: 5000 + | Default: 10000 | */ - 'max_xml_file_size' => 5000 + 'max_xml_file_size' => 10000 ]; diff --git a/database/migrations/2018_08_22_154146_create_anime_ratings_table.php b/database/migrations/2018_08_22_154146_create_anime_ratings_table.php index 26095b439..faff62321 100644 --- a/database/migrations/2018_08_22_154146_create_anime_ratings_table.php +++ b/database/migrations/2018_08_22_154146_create_anime_ratings_table.php @@ -25,6 +25,9 @@ public function up() }); Schema::table(AnimeRating::TABLE_NAME, function(Blueprint $table) { + // Set unique key constraints + $table->unique(['anime_id', 'user_id']); + // Set foreign key constraints $table->foreign('anime_id')->references('id')->on(Anime::TABLE_NAME)->onDelete('cascade'); $table->foreign('user_id')->references('id')->on(User::TABLE_NAME)->onDelete('cascade'); diff --git a/public/openapi.json b/public/openapi.json index d12436462..881cec276 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -3491,12 +3491,13 @@ "description": "The lock status used to lock or unlock a thread.\n- 0 = unlock\n- 1 = lock" }, "MALImportBehavior": { - "type": "string", + "type": "integer", "enum": [ - "overwrite" + 0, + 1 ], - "default": "overwrite", - "description": "The set of available MAL import behavior types." + "default": 1, + "description": "The set of available MAL import behavior types.\n- 0 = overwrite\n- 1 = merge" }, "NotificationReadStatus": { "type": "integer", diff --git a/tests/API/MALImportTest.php b/tests/API/MALImportTest.php new file mode 100644 index 000000000..6655baf26 --- /dev/null +++ b/tests/API/MALImportTest.php @@ -0,0 +1,258 @@ + + + 6076 + + OVA + 1 + 0 + 1 + 0000-00-00 + 0000-00-00 + + 10 + + 0.00 + Completed + + 0 + + LOW + + 0 + 0 + 1 + default + 0 + + + + 2928 + + OVA + 1 + 0 + 1 + 0000-00-00 + 0000-00-00 + + 10 + + 0.00 + Dropped + + 0 + + HIGH + + 0 + 0 + 0 + default + 0 + + + + 3269 + + Movie + 1 + 0 + 1 + 0000-00-00 + 0000-00-00 + + 10 + + 0.00 + Plan to Watch + + 0 + + HIGH + + 0 + 0 + 0 + default + 0 + + + + 4469 + + Special + 1 + 0 + 1 + 0000-00-00 + 0000-00-00 + + 10 + + 0.00 + On-Hold + + 0 + + HIGH + + 0 + 0 + 0 + default + 0 + + + + 454 + + OVA + 1 + 0 + 1 + 0000-00-00 + 0000-00-00 + + 10 + + 0.00 + Completed + + 0 + + HIGH + + 0 + 0 + 0 + default + 0 + + + + 1143 + + Special + 1 + 0 + 1 + 0000-00-00 + 0000-00-00 + + 10 + + 0.00 + Watching + + 0 + + HIGH + + 0 + 0 + 0 + default + 0 + + + XML; + + /** + * User can import MAL library with overwrite behavior. + * + * @return void + * @test + */ + function user_can_import_mal_library_with_overwrite_behavior() + { + // Attach anime with id 21 + $anime = Anime::firstWhere('mal_id', 21); + $this->user->library()->attach($anime, ['status' => 'Watching']); + + // Prepare import file + $uploadFile = UploadedFile::fake()->createWithContent('animelist_1623616958_-_3667065.xml', self::$xmlContent); + + // Make sure the anime has been attached to the user + $this->assertEquals(1, $this->user->library()->count()); + + // Expect a job is dispatched + Notification::fake(); + + // Request import + $response = $this->auth()->json('POST', 'v1/me/library/mal-import', [ + 'file' => $uploadFile, + 'behavior' => MALImportBehavior::Overwrite, + ]); + $response->assertSuccessfulAPIResponse(); + + // Assert notification was sent + Notification::hasSent($this->user, MALImportFinished::class); + + // Assert anime has been imported in user's library + $this->assertEquals(6, $this->user->library()->count()); + + // Assert the anime we added in the beginning is gone + $this->assertNull($this->user->library->firstWhere('mal_id', 21)); + } + + /** + * User can import MAL library with merge behavior. + * + * @test + */ + function user_can_import_mal_library_with_merge_behavior() + { + // Attach anime with id 21 + $anime = Anime::firstWhere('mal_id', 21); + $this->user->library()->attach($anime, ['status' => 'Watching']); + + // Prepare import file + $uploadFile = UploadedFile::fake()->createWithContent('animelist_1623616958_-_3667065.xml', self::$xmlContent); + + // Make sure the anime has been attached to the user + $this->assertEquals(1, $this->user->library()->count()); + + // Expect a job is dispatched + Notification::fake(); + + // Request import + $response = $this->auth()->json('POST', 'v1/me/library/mal-import', [ + 'file' => $uploadFile, + 'behavior' => MALImportBehavior::Merge, + ]); + $response->assertSuccessfulAPIResponse(); + + // Assert notification was sent + Notification::hasSent($this->user, MALImportFinished::class); + + // Assert anime has been imported in user's library + $this->assertEquals(7, $this->user->library()->count()); + + // Assert the anime we added in the beginning is gone + $this->assertNotNull($this->user->library->firstWhere('mal_id', 21)); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 0bf2c719f..6d6f28eb2 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -10,6 +10,7 @@ use Illuminate\Testing\TestResponse; use Spatie\Snapshots\MatchesSnapshots; use Tests\Traits\ProvidesTestAnime; +use Tests\Traits\ProvidesTestMultipleAnime; use Tests\Traits\ProvidesTestUser; abstract class TestCase extends BaseTestCase @@ -60,6 +61,9 @@ protected function setUpTraits(): array if (isset($uses[ProvidesTestAnime::class])) { $this->initializeTestAnime(); } + if (isset($uses[ProvidesTestMultipleAnime::class])) { + $this->initializeTestMultipleAnime(); + } return $uses; } diff --git a/tests/Traits/ProvidesTestMultipleAnime.php b/tests/Traits/ProvidesTestMultipleAnime.php new file mode 100644 index 000000000..270676473 --- /dev/null +++ b/tests/Traits/ProvidesTestMultipleAnime.php @@ -0,0 +1,36 @@ +malIds as $malId) { + Anime::factory()->create([ + 'mal_id' => $malId + ]); + } + } +}