From af351d211bb813b1d8215de260efbd010a89a4ea Mon Sep 17 00:00:00 2001 From: Pig Fang Date: Thu, 30 Apr 2020 18:47:37 +0800 Subject: [PATCH] rewrite players management page with React --- app/Http/Controllers/AdminController.php | 77 ---- .../PlayersManagementController.php | 136 +++++++ app/Http/Kernel.php | 1 + .../SinglePlayer/UpdateOwnerNickName.php | 21 + app/Models/Player.php | 11 + app/Providers/EventServiceProvider.php | 14 +- composer.json | 1 + composer.lock | 48 ++- resources/assets/src/scripts/route.tsx | 2 +- resources/assets/src/scripts/types.ts | 2 + resources/assets/src/styles/common.scss | 1 + resources/assets/src/styles/dropdown.scss | 15 + resources/assets/src/views/admin/Players.vue | 246 ----------- .../admin/PlayersManagement/Card.module.scss | 10 + .../views/admin/PlayersManagement/Card.tsx | 148 +++++++ .../PlayersManagement/ModalUpdateTexture.tsx | 82 ++++ .../views/admin/PlayersManagement/index.tsx | 200 +++++++++ .../assets/tests/views/admin/Players.test.ts | 143 ------- .../views/admin/PlayersManagement.test.tsx | 362 +++++++++++++++++ .../assets/tests/views/user/Closet.test.tsx | 2 + .../assets/tests/views/user/Players.test.tsx | 20 +- routes/web.php | 15 +- .../ControllersTest/AdminControllerTest.php | 198 --------- .../PlayersManagementControllerTest.php | 381 ++++++++++++++++++ 24 files changed, 1458 insertions(+), 678 deletions(-) create mode 100644 app/Http/Controllers/PlayersManagementController.php create mode 100644 app/Listeners/SinglePlayer/UpdateOwnerNickName.php create mode 100644 resources/assets/src/styles/dropdown.scss delete mode 100644 resources/assets/src/views/admin/Players.vue create mode 100644 resources/assets/src/views/admin/PlayersManagement/Card.module.scss create mode 100644 resources/assets/src/views/admin/PlayersManagement/Card.tsx create mode 100644 resources/assets/src/views/admin/PlayersManagement/ModalUpdateTexture.tsx create mode 100644 resources/assets/src/views/admin/PlayersManagement/index.tsx delete mode 100644 resources/assets/tests/views/admin/Players.test.ts create mode 100644 resources/assets/tests/views/admin/PlayersManagement.test.tsx create mode 100644 tests/HttpTest/ControllersTest/PlayersManagementControllerTest.php diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php index 3c74b1dd..c53ef313 100644 --- a/app/Http/Controllers/AdminController.php +++ b/app/Http/Controllers/AdminController.php @@ -6,7 +6,6 @@ use App\Models\Player; use App\Models\Texture; use App\Models\User; use App\Notifications; -use App\Rules; use App\Services\OptionForm; use App\Services\PluginManager; use Auth; @@ -599,80 +598,4 @@ class AdminController extends Controller return json(trans('admin.users.operations.invalid'), 1); } } - - public function playerAjaxHandler(Request $request) - { - $action = $request->input('action'); - $currentUser = Auth::user(); - $player = Player::find($request->input('pid')); - - if (!$player) { - return json(trans('general.unexistent-player'), 1); - } - - $owner = $player->user; - if ( - $owner && $owner->uid !== $currentUser->uid && - $owner->permission >= $currentUser->permission - ) { - return json(trans('admin.players.no-permission'), 1); - } - - if ($action == 'texture') { - $this->validate($request, [ - 'type' => 'required', - 'tid' => 'required|integer', - ]); - - if (!Texture::find($request->tid) && $request->tid != 0) { - return json(trans('admin.players.textures.non-existent', ['tid' => $request->tid]), 1); - } - - $field = 'tid_'.$request->type; - $player->$field = $request->tid; - $player->save(); - - return json(trans('admin.players.textures.success', ['player' => $player->name]), 0); - } elseif ($action == 'owner') { - $this->validate($request, [ - 'uid' => 'required|integer', - ]); - - $user = User::find($request->uid); - - if (!$user) { - return json(trans('admin.users.operations.non-existent'), 1); - } - - $player->uid = $request->input('uid'); - $player->save(); - - return json(trans('admin.players.owner.success', ['player' => $player->name, 'user' => $user->nickname]), 0); - } elseif ($action == 'delete') { - $player->delete(); - - return json(trans('admin.players.delete.success'), 0); - } elseif ($action == 'name') { - $name = $this->validate($request, [ - 'name' => [ - 'required', - new Rules\PlayerName(), - 'min:'.option('player_name_length_min'), - 'max:'.option('player_name_length_max'), - ], - ])['name']; - - $player->name = $name; - $player->save(); - - if (option('single_player', false) && $owner) { - $owner->nickname = $name; - $owner->save(); - } - - return json(trans('admin.players.name.success', ['player' => $player->name]), 0); - } else { - return json(trans('admin.users.operations.invalid'), 1); - } - } } diff --git a/app/Http/Controllers/PlayersManagementController.php b/app/Http/Controllers/PlayersManagementController.php new file mode 100644 index 00000000..467f0beb --- /dev/null +++ b/app/Http/Controllers/PlayersManagementController.php @@ -0,0 +1,136 @@ +middleware(function (Request $request, $next) { + /** @var Player */ + $player = $request->route('player'); + $owner = $player->user; + + /** @var User */ + $currentUser = $request->user(); + + if ( + $owner && $owner->uid !== $currentUser->uid && + $owner->permission >= $currentUser->permission + ) { + return json(trans('admin.players.no-permission'), 1) + ->setStatusCode(403); + } + + return $next($request); + })->except(['list']); + } + + public function list(Request $request) + { + $query = $request->query('q'); + + return Player::usingSearchString($query)->paginate(10); + } + + public function name( + Player $player, + Request $request, + Dispatcher $dispatcher + ) { + $name = $this->validate($request, [ + 'player_name' => [ + 'required', + new Rules\PlayerName(), + 'min:'.option('player_name_length_min'), + 'max:'.option('player_name_length_max'), + 'unique:players,name', + ], + ])['player_name']; + + $dispatcher->dispatch('player.name.updating', [$player, $name]); + + $oldName = $player->name; + $player->name = $name; + $player->save(); + + $dispatcher->dispatch('player.name.updated', [$player, $oldName]); + + return json(trans('admin.players.name.success', ['player' => $player->name]), 0); + } + + public function owner( + Player $player, + Request $request, + Dispatcher $dispatcher + ) { + $uid = $this->validate($request, ['uid' => 'required|integer'])['uid']; + + $dispatcher->dispatch('player.owner.updating', [$player, $uid]); + + /** @var User */ + $user = User::find($request->uid); + if (empty($user)) { + return json(trans('admin.users.operations.non-existent'), 1); + } + + $player->uid = $uid; + $player->save(); + + $dispatcher->dispatch('player.owner.updated', [$player, $user]); + + return json(trans('admin.players.owner.success', [ + 'player' => $player->name, + 'user' => $user->nickname, + ]), 0); + } + + public function texture( + Player $player, + Request $request, + Dispatcher $dispatcher + ) { + $data = $this->validate($request, [ + 'tid' => 'required|integer', + 'type' => ['required', Rule::in(['skin', 'cape'])], + ]); + $tid = (int) $data['tid']; + $type = $data['type']; + + $dispatcher->dispatch('player.texture.updating', [$player, $type, $tid]); + + if (!Texture::find($tid) && $tid !== 0) { + return json(trans('admin.players.textures.non-existent', ['tid' => $tid]), 1); + } + + $field = 'tid_'.$type; + $previousTid = $player->$field; + $player->$field = $tid; + $player->save(); + + $dispatcher->dispatch('player.texture.updated', [$player, $type, $previousTid]); + + return json(trans('admin.players.textures.success', ['player' => $player->name]), 0); + } + + public function delete( + Player $player, + Dispatcher $dispatcher + ) { + $dispatcher->dispatch('player.deleting', [$player]); + + $player->delete(); + + $dispatcher->dispatch('player.deleted', [$player]); + + return json(trans('admin.players.delete.success'), 0); + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 88a210c5..e678ad70 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -32,6 +32,7 @@ class Kernel extends HttpKernel \Illuminate\Session\Middleware\StartSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class, + \Illuminate\Routing\Middleware\SubstituteBindings::class, \App\Http\Middleware\EnforceEverGreen::class, \App\Http\Middleware\RedirectToSetup::class, ], diff --git a/app/Listeners/SinglePlayer/UpdateOwnerNickName.php b/app/Listeners/SinglePlayer/UpdateOwnerNickName.php new file mode 100644 index 00000000..6e8e08bb --- /dev/null +++ b/app/Listeners/SinglePlayer/UpdateOwnerNickName.php @@ -0,0 +1,21 @@ +user; + + if (option('single_player', false) && $owner) { + $owner->nickname = $player->name; + $owner->save(); + } + } +} diff --git a/app/Models/Player.php b/app/Models/Player.php index 7c262a6c..7aa1ab27 100644 --- a/app/Models/Player.php +++ b/app/Models/Player.php @@ -7,6 +7,7 @@ use App\Models; use DateTimeInterface; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; +use Lorisleiva\LaravelSearchString\Concerns\SearchString; /** * @property int $pid @@ -22,6 +23,8 @@ use Illuminate\Support\Carbon; */ class Player extends Model { + use SearchString; + public const CREATED_AT = null; public const UPDATED_AT = 'last_modified'; @@ -40,6 +43,14 @@ class Player extends Model 'updated' => PlayerProfileUpdated::class, ]; + protected $searchStringColumns = [ + 'pid', 'uid', + 'tid_skin' => '/^(?:tid_)?skin$/', + 'tid_cape' => '/^(?:tid_)?cape$/', + 'name' => ['searchable' => true], + 'last_modified' => ['date' => true], + ]; + public function user() { return $this->belongsTo(Models\User::class, 'uid'); diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 8284246b..7dbabcf5 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Listeners; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; class EventServiceProvider extends ServiceProvider @@ -13,24 +14,27 @@ class EventServiceProvider extends ServiceProvider */ protected $listen = [ 'App\Events\PlayerRetrieved' => [ - 'App\Listeners\ResetInvalidTextureForPlayer', + Listeners\ResetInvalidTextureForPlayer::class, ], 'App\Events\TextureDeleting' => [ 'App\Listeners\TextureRemoved', ], 'App\Events\PluginWasEnabled' => [ - 'App\Listeners\CopyPluginAssets', - 'App\Listeners\GeneratePluginTranslations', + Listeners\CopyPluginAssets::class, + Listeners\GeneratePluginTranslations::class, ], 'plugin.versionChanged' => [ 'App\Listeners\CopyPluginAssets', 'App\Listeners\GeneratePluginTranslations', ], 'App\Events\PluginBootFailed' => [ - 'App\Listeners\NotifyFailedPlugin', + Listeners\NotifyFailedPlugin::class, ], 'App\Events\RenderingHeader' => [ - 'App\Listeners\SerializeGlobals', + Listeners\SerializeGlobals::class, + ], + 'player.name.updated' => [ + Listeners\SinglePlayer\UpdateOwnerNickName::class, ], ]; } diff --git a/composer.json b/composer.json index 1b825ff6..55cc41e9 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "intervention/image": "^2.5", "laravel/framework": "^7.0", "laravel/passport": "^8.4", + "lorisleiva/laravel-search-string": "^0.1.6", "nesbot/carbon": "^2.0", "nunomaduro/collision": "^4.1", "rcrowe/twigbridge": "^0.11.3", diff --git a/composer.lock b/composer.lock index 62d40dfe..32bb37b4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e9621ab58940ab0702842b6ba6c80ccb", + "content-hash": "d1615cec2f58f3b4eb54ac477e348b42", "packages": [ { "name": "blessing/filter", @@ -2330,6 +2330,52 @@ ], "time": "2019-07-13T18:58:26+00:00" }, + { + "name": "lorisleiva/laravel-search-string", + "version": "v0.1.6", + "source": { + "type": "git", + "url": "https://github.com/lorisleiva/laravel-search-string.git", + "reference": "68189cad7614d0c9cb09a83315e1ccfe49e7f0c1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lorisleiva/laravel-search-string/zipball/68189cad7614d0c9cb09a83315e1ccfe49e7f0c1", + "reference": "68189cad7614d0c9cb09a83315e1ccfe49e7f0c1", + "shasum": "" + }, + "require": { + "illuminate/support": "^5.5|^6.0|^7.0" + }, + "require-dev": { + "orchestra/testbench": "^5.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Lorisleiva\\LaravelSearchString\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Lorisleiva\\LaravelSearchString\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Loris Leiva", + "email": "loris.leiva@gmail.com" + } + ], + "description": "Generates database queries based on one unique string using a simple and customizable syntax.", + "time": "2020-04-05T15:40:30+00:00" + }, { "name": "monolog/monolog", "version": "2.0.2", diff --git a/resources/assets/src/scripts/route.tsx b/resources/assets/src/scripts/route.tsx index f6da9d0d..9789659c 100644 --- a/resources/assets/src/scripts/route.tsx +++ b/resources/assets/src/scripts/route.tsx @@ -58,7 +58,7 @@ export default [ }, { path: 'admin/players', - component: () => import('../views/admin/Players.vue'), + react: () => import('../views/admin/PlayersManagement'), el: '.content > .container-fluid', }, { diff --git a/resources/assets/src/scripts/types.ts b/resources/assets/src/scripts/types.ts index f4b63e19..b1b2cc09 100644 --- a/resources/assets/src/scripts/types.ts +++ b/resources/assets/src/scripts/types.ts @@ -14,8 +14,10 @@ export type User = { export type Player = { pid: number name: string + uid: number tid_skin: number tid_cape: number + last_modified: string } export type Texture = { diff --git a/resources/assets/src/styles/common.scss b/resources/assets/src/styles/common.scss index 4962432a..19b1fb69 100644 --- a/resources/assets/src/styles/common.scss +++ b/resources/assets/src/styles/common.scss @@ -1,4 +1,5 @@ @import './avatar'; +@import './dropdown'; a { outline: none; diff --git a/resources/assets/src/styles/dropdown.scss b/resources/assets/src/styles/dropdown.scss new file mode 100644 index 00000000..5cefddda --- /dev/null +++ b/resources/assets/src/styles/dropdown.scss @@ -0,0 +1,15 @@ +.dropdown-item { + &:hover { + color: #fff; + background-color: var(--blue); + } + + &.dropdown-item-danger { + color: var(--danger); + + &:hover, &:active { + color: #fff; + background-color: var(--danger); + } + } +} diff --git a/resources/assets/src/views/admin/Players.vue b/resources/assets/src/views/admin/Players.vue deleted file mode 100644 index 4562bdd4..00000000 --- a/resources/assets/src/views/admin/Players.vue +++ /dev/null @@ -1,246 +0,0 @@ - - - diff --git a/resources/assets/src/views/admin/PlayersManagement/Card.module.scss b/resources/assets/src/views/admin/PlayersManagement/Card.module.scss new file mode 100644 index 00000000..a439953f --- /dev/null +++ b/resources/assets/src/views/admin/PlayersManagement/Card.module.scss @@ -0,0 +1,10 @@ +@use '../../../styles/breakpoints'; + +.box { + width: 48%; + margin: 7px; + + @include breakpoints.less-than('lg') { + width: 98%; + } +} diff --git a/resources/assets/src/views/admin/PlayersManagement/Card.tsx b/resources/assets/src/views/admin/PlayersManagement/Card.tsx new file mode 100644 index 00000000..7be8b709 --- /dev/null +++ b/resources/assets/src/views/admin/PlayersManagement/Card.tsx @@ -0,0 +1,148 @@ +import React from 'react' +import { t } from '@/scripts/i18n' +import { showModal } from '@/scripts/notify' +import { Player } from '@/scripts/types' +import styles from './Card.module.scss' + +interface Props { + player: Player + onUpdateName(): void + onUpdateOwner(): void + onUpdateTexture(): void + onDelete(): void +} + +const Card: React.FC = (props) => { + const { player } = props + + const handlePreviewTextures = () => { + showModal({ + mode: 'alert', + title: t('general.player.previews'), + children: ( +
+
+ {player.tid_skin > 0 && ( + + {`${player.name} + + )} +
+
+ {player.tid_cape > 0 && ( + + {`${player.name} + + )} +
+
+ ), + }) + } + + return ( +
+
+ +
+
+
+
+
+ {player.name} +
+
+ +
+
+
+ PID: {player.pid} + + {t('general.player.owner')}: {player.uid} + +
+
+ + {`${t('general.player.last-modified')}: `} + {player.last_modified} + +
+
+
+
+ ) +} + +export default Card diff --git a/resources/assets/src/views/admin/PlayersManagement/ModalUpdateTexture.tsx b/resources/assets/src/views/admin/PlayersManagement/ModalUpdateTexture.tsx new file mode 100644 index 00000000..2dd3d8a7 --- /dev/null +++ b/resources/assets/src/views/admin/PlayersManagement/ModalUpdateTexture.tsx @@ -0,0 +1,82 @@ +import React, { useState } from 'react' +import { t } from '@/scripts/i18n' +import Modal from '@/components/Modal' + +interface Props { + open: boolean + onSubmit(type: 'skin' | 'cape', tid: number): void + onClose(): void +} + +const ModalUpdateTexture: React.FC = (props) => { + const [type, setType] = useState<'skin' | 'cape'>('skin') + const [tid, setTid] = useState('') + + const handleTypeChange = (event: React.ChangeEvent) => { + setType(event.target.value as 'skin' | 'cape') + } + + const handleTidChange = (event: React.ChangeEvent) => { + setTid(event.target.value) + } + + const handleConfirm = () => { + props.onSubmit(type, Number.parseInt(tid)) + setType('skin') + setTid('') + } + + const handleClose = () => { + setType('skin') + setTid('') + props.onClose() + } + + return ( + +
+ +
+ + +
+
+
+ + +
+
+ ) +} + +export default ModalUpdateTexture diff --git a/resources/assets/src/views/admin/PlayersManagement/index.tsx b/resources/assets/src/views/admin/PlayersManagement/index.tsx new file mode 100644 index 00000000..89c1a6a9 --- /dev/null +++ b/resources/assets/src/views/admin/PlayersManagement/index.tsx @@ -0,0 +1,200 @@ +import React, { useState, useEffect } from 'react' +import { hot } from 'react-hot-loader/root' +import { useImmer } from 'use-immer' +import { t } from '@/scripts/i18n' +import * as fetch from '@/scripts/net' +import { Player, Paginator } from '@/scripts/types' +import { toast, showModal } from '@/scripts/notify' +import Loading from '@/components/Loading' +import Pagination from '@/components/Pagination' +import Card from './Card' +import ModalUpdateTexture from './ModalUpdateTexture' + +const PlayersManagement: React.FC = () => { + const [players, setPlayers] = useImmer([]) + const [page, setPage] = useState(1) + const [totalPages, setTotalPages] = useState(1) + const [isLoading, setIsLoading] = useState(false) + const [query, setQuery] = useState('') + const [textureUpdating, setTextureUpdating] = useState(-1) + + const getPlayers = async () => { + setIsLoading(true) + const { data, last_page }: Paginator = await fetch.get( + '/admin/players/list', + { + q: query, + page, + }, + ) + setTotalPages(last_page) + setPlayers(() => data) + setIsLoading(false) + } + + useEffect(() => { + getPlayers() + }, [page]) + + const handleQueryChange = (event: React.ChangeEvent) => { + setQuery(event.target.value) + } + + const handleSubmitQuery = (event: React.FormEvent) => { + event.preventDefault() + getPlayers() + } + + const handleUpdateName = async (player: Player, index: number) => { + let name: string + try { + const { value } = await showModal({ + mode: 'prompt', + text: t('admin.changePlayerNameNotice'), + input: player.name, + validator: (value: string) => { + if (!value) { + return t('admin.emptyPlayerName') + } + }, + }) + name = value + } catch { + return + } + + const { code, message } = await fetch.put( + `/admin/players/${player.pid}/name`, + { player_name: name }, + ) + if (code === 0) { + toast.success(message) + setPlayers((players) => { + players[index].name = name + }) + } else { + toast.error(message) + } + } + + const handleUpdateOwner = async (player: Player, index: number) => { + let uid: number + try { + const { value } = await showModal({ + mode: 'prompt', + text: t('admin.changePlayerOwner'), + input: player.uid.toString(), + inputType: 'number', + }) + uid = Number.parseInt(value) + } catch { + return + } + + const { code, message } = await fetch.put( + `/admin/players/${player.pid}/owner`, + { uid }, + ) + if (code === 0) { + toast.success(message) + setPlayers((players) => { + players[index].uid = uid + }) + } else { + toast.error(message) + } + } + + const handleCloseModalUpdateTexture = () => setTextureUpdating(-1) + + const handleUpdateTexture = async (type: 'skin' | 'cape', tid: number) => { + const { code, message } = await fetch.put( + `/admin/players/${players[textureUpdating].pid}/textures`, + { type, tid }, + ) + + if (code === 0) { + toast.success(message) + setPlayers((players) => { + const field = `tid_${type}` as 'tid_skin' | 'tid_cape' + players[textureUpdating][field] = tid + }) + } else { + toast.error(message) + } + } + + const handleDelete = async (player: Player) => { + try { + await showModal({ + text: t('admin.deletePlayerNotice'), + okButtonType: 'danger', + }) + } catch { + return + } + + const { code, message } = await fetch.del( + `/admin/players/${player.pid}`, + ) + if (code === 0) { + setPlayers((players) => players.filter(({ pid }) => pid !== player.pid)) + toast.success(message) + } else { + toast.error(message) + } + } + + return ( +
+
+
+ +
+ +
+
+
+
+ {isLoading ? ( + + ) : players.length === 0 ? ( +
No players.
+ ) : ( +
+ {players.map((player, i) => ( + handleUpdateName(player, i)} + onUpdateOwner={() => handleUpdateOwner(player, i)} + onUpdateTexture={() => setTextureUpdating(i)} + onDelete={() => handleDelete(player)} + /> + ))} +
+ )} +
+
+
+ +
+
+ -1} + onSubmit={handleUpdateTexture} + onClose={handleCloseModalUpdateTexture} + /> +
+ ) +} + +export default hot(PlayersManagement) diff --git a/resources/assets/tests/views/admin/Players.test.ts b/resources/assets/tests/views/admin/Players.test.ts deleted file mode 100644 index 39ab5804..00000000 --- a/resources/assets/tests/views/admin/Players.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import Vue from 'vue' -import { mount } from '@vue/test-utils' -import { flushPromises } from '../../utils' -import { showModal } from '@/scripts/notify' -import Modal from '@/components/Modal.vue' -import Players from '@/views/admin/Players.vue' - -jest.mock('@/scripts/notify') - -test('fetch data after initializing', () => { - Vue.prototype.$http.get.mockResolvedValue({ data: [] }) - mount(Players) - expect(Vue.prototype.$http.get).toBeCalledWith( - '/admin/players/list', - { - page: 1, perPage: 10, search: '', sortField: 'pid', sortType: 'asc', - }, - ) -}) - -test('change texture', async () => { - Vue.prototype.$http.get.mockResolvedValue({ - data: [ - { pid: 1, tid_skin: 0 }, - ], - }) - Vue.prototype.$http.post - .mockResolvedValueOnce({ code: 1, message: '1' }) - .mockResolvedValueOnce({ code: 0, message: '0' }) - - const wrapper = mount(Players) - await flushPromises() - const modal = wrapper.find(Modal) - wrapper.findAll('.btn-default').trigger('click') - - wrapper.find('.modal-body input').setValue('5') - - modal.vm.$emit('confirm') - await flushPromises() - expect(Vue.prototype.$http.post).toBeCalledWith( - '/admin/players?action=texture', - { - pid: 1, tid: 5, type: 'skin', - }, - ) - modal.vm.$emit('confirm') - await flushPromises() - expect(wrapper.html()).toContain('/preview/5?height=64') -}) - -test('change player name', async () => { - Vue.prototype.$http.get.mockResolvedValue({ - data: [ - { pid: 1, name: 'old' }, - ], - }) - Vue.prototype.$http.post - .mockResolvedValueOnce({ code: 1, message: '1' }) - .mockResolvedValueOnce({ code: 0, message: '0' }) - showModal - .mockRejectedValueOnce(null) - .mockResolvedValue({ value: 'new' }) - const wrapper = mount(Players) - await flushPromises() - const button = wrapper.find('[data-test="name"]') - - button.trigger('click') - expect(Vue.prototype.$http.post).not.toBeCalled() - - button.trigger('click') - await flushPromises() - expect(Vue.prototype.$http.post).toBeCalledWith( - '/admin/players?action=name', - { pid: 1, name: 'new' }, - ) - button.trigger('click') - await flushPromises() - expect(wrapper.text()).toContain('new') -}) - -test('change owner', async () => { - Vue.prototype.$http.get.mockResolvedValue({ - data: [ - { pid: 1, uid: 2 }, - ], - }) - Vue.prototype.$http.post - .mockResolvedValueOnce({ code: 1, message: '1' }) - .mockResolvedValueOnce({ code: 0, message: '0' }) - showModal - .mockRejectedValueOnce(null) - .mockResolvedValue({ value: '3' }) - - const wrapper = mount(Players) - await flushPromises() - const button = wrapper.find('[data-test="owner"]') - - button.trigger('click') - expect(Vue.prototype.$http.post).not.toBeCalled() - - button.trigger('click') - await flushPromises() - expect(Vue.prototype.$http.post).toBeCalledWith( - '/admin/players?action=owner', - { pid: 1, uid: 3 }, - ) - button.trigger('click') - await flushPromises() - expect(wrapper.text()).toContain('3') -}) - -test('delete player', async () => { - Vue.prototype.$http.get.mockResolvedValue({ - data: [ - { pid: 1, name: 'to-be-deleted' }, - ], - }) - Vue.prototype.$http.post - .mockResolvedValueOnce({ code: 1, message: '1' }) - .mockResolvedValueOnce({ code: 0, message: '0' }) - showModal - .mockRejectedValueOnce(null) - .mockResolvedValue({ value: '' }) - - const wrapper = mount(Players) - await flushPromises() - const button = wrapper.find('.btn-danger') - - button.trigger('click') - expect(Vue.prototype.$http.post).not.toBeCalled() - - button.trigger('click') - await flushPromises() - expect(Vue.prototype.$http.post).toBeCalledWith( - '/admin/players?action=delete', - { pid: 1 }, - ) - expect(wrapper.text()).toContain('to-be-deleted') - - button.trigger('click') - await flushPromises() - expect(wrapper.text()).toContain('No data') -}) diff --git a/resources/assets/tests/views/admin/PlayersManagement.test.tsx b/resources/assets/tests/views/admin/PlayersManagement.test.tsx new file mode 100644 index 00000000..486b61e4 --- /dev/null +++ b/resources/assets/tests/views/admin/PlayersManagement.test.tsx @@ -0,0 +1,362 @@ +import React from 'react' +import { render, waitFor, fireEvent } from '@testing-library/react' +import { t } from '@/scripts/i18n' +import * as fetch from '@/scripts/net' +import { Player, Paginator } from '@/scripts/types' +import PlayersManagement from '@/views/admin/PlayersManagement' + +jest.mock('@/scripts/net') + +const fixture: Readonly = Object.freeze({ + pid: 1, + name: 'kumiko', + uid: 1, + tid_skin: 1, + tid_cape: 2, + last_modified: new Date().toString(), +}) + +function createPaginator(data: Player[]): Paginator { + return { + data, + total: data.length, + from: 1, + to: data.length, + current_page: 1, + last_page: 1, + } +} + +test('search players', async () => { + fetch.get.mockResolvedValue(createPaginator([])) + + const { getByTitle, getByText } = render() + await waitFor(() => + expect(fetch.get).toBeCalledWith('/admin/players/list', { q: '', page: 1 }), + ) + + fireEvent.input(getByTitle(t('vendor.datatable.search')), { + target: { value: 's' }, + }) + fireEvent.click(getByText(t('vendor.datatable.search'))) + await waitFor(() => + expect(fetch.get).toBeCalledWith('/admin/players/list', { + q: 's', + page: 1, + }), + ) +}) + +test('preview textures', async () => { + fetch.get.mockResolvedValue(createPaginator([fixture])) + + const { getByText, queryByAltText } = render() + await waitFor(() => expect(fetch.get).toBeCalled()) + + fireEvent.click(getByText(t('general.player.previews'))) + + expect( + queryByAltText(`${fixture.name} - ${t('general.skin')}`), + ).toHaveAttribute('src', `${blessing.base_url}/preview/${fixture.tid_skin}`) + expect( + queryByAltText(`${fixture.name} - ${t('general.cape')}`), + ).toHaveAttribute('src', `${blessing.base_url}/preview/${fixture.tid_cape}`) + + fireEvent.click(getByText(t('general.confirm'))) +}) + +describe('update player name', () => { + beforeEach(() => { + fetch.get.mockResolvedValue(createPaginator([fixture])) + }) + + it('empty value', async () => { + const { getByText, getByDisplayValue, queryByText } = render( + , + ) + + await waitFor(() => expect(fetch.get).toBeCalled()) + fireEvent.click(getByText(t('admin.changePlayerName'))) + fireEvent.input(getByDisplayValue(fixture.name), { + target: { value: '' }, + }) + fireEvent.click(getByText(t('general.confirm'))) + + expect(queryByText(t('admin.emptyPlayerName'))).toBeInTheDocument() + expect(fetch.put).not.toBeCalled() + + fireEvent.click(getByText(t('general.cancel'))) + + expect(queryByText(fixture.name)).toBeInTheDocument() + }) + + it('succeeded', async () => { + fetch.put.mockResolvedValue({ code: 0, message: 'ok' }) + + const { getByText, getByDisplayValue, queryByText, queryByRole } = render( + , + ) + + await waitFor(() => expect(fetch.get).toBeCalled()) + fireEvent.click(getByText(t('admin.changePlayerName'))) + fireEvent.input(getByDisplayValue(fixture.name), { + target: { value: 'reina' }, + }) + fireEvent.click(getByText(t('general.confirm'))) + + await waitFor(() => + expect(fetch.put).toBeCalledWith(`/admin/players/${fixture.pid}/name`, { + player_name: 'reina', + }), + ) + expect(queryByText('ok')).toBeInTheDocument() + expect(queryByRole('status')).toHaveClass('alert-success') + expect(queryByText('reina')).toBeInTheDocument() + }) + + it('failed', async () => { + fetch.put.mockResolvedValue({ code: 1, message: 'failed' }) + + const { getByText, getByDisplayValue, queryByText, queryByRole } = render( + , + ) + + await waitFor(() => expect(fetch.get).toBeCalled()) + fireEvent.click(getByText(t('admin.changePlayerName'))) + fireEvent.input(getByDisplayValue(fixture.name), { + target: { value: 'reina' }, + }) + fireEvent.click(getByText(t('general.confirm'))) + + await waitFor(() => + expect(fetch.put).toBeCalledWith(`/admin/players/${fixture.pid}/name`, { + player_name: 'reina', + }), + ) + expect(queryByText('failed')).toBeInTheDocument() + expect(queryByRole('alert')).toHaveClass('alert-danger') + expect(queryByText(fixture.name)).toBeInTheDocument() + }) +}) + +describe('update owner', () => { + beforeEach(() => { + fetch.get.mockResolvedValue(createPaginator([fixture])) + }) + + it('cancelled', async () => { + const { getByText, queryByText } = render() + + await waitFor(() => expect(fetch.get).toBeCalled()) + fireEvent.click(getByText(t('admin.changeOwner'))) + fireEvent.click(getByText(t('general.cancel'))) + + expect(fetch.put).not.toBeCalled() + expect( + queryByText(`${t('general.player.owner')}: ${fixture.uid}`), + ).toBeInTheDocument() + }) + + it('succeeded', async () => { + fetch.put.mockResolvedValue({ code: 0, message: 'ok' }) + + const { getByText, getByDisplayValue, queryByText, queryByRole } = render( + , + ) + + await waitFor(() => expect(fetch.get).toBeCalled()) + fireEvent.click(getByText(t('admin.changeOwner'))) + fireEvent.input(getByDisplayValue(fixture.uid.toString()), { + target: { value: '2' }, + }) + fireEvent.click(getByText(t('general.confirm'))) + + await waitFor(() => + expect(fetch.put).toBeCalledWith(`/admin/players/${fixture.pid}/owner`, { + uid: 2, + }), + ) + expect(queryByText('ok')).toBeInTheDocument() + expect(queryByRole('status')).toHaveClass('alert-success') + expect(queryByText(`${t('general.player.owner')}: 2`)).toBeInTheDocument() + }) + + it('failed', async () => { + fetch.put.mockResolvedValue({ code: 1, message: 'failed' }) + + const { getByText, getByDisplayValue, queryByText, queryByRole } = render( + , + ) + + await waitFor(() => expect(fetch.get).toBeCalled()) + fireEvent.click(getByText(t('admin.changeOwner'))) + fireEvent.input(getByDisplayValue(fixture.uid.toString()), { + target: { value: '2' }, + }) + fireEvent.click(getByText(t('general.confirm'))) + + await waitFor(() => + expect(fetch.put).toBeCalledWith(`/admin/players/${fixture.pid}/owner`, { + uid: 2, + }), + ) + expect(queryByText('failed')).toBeInTheDocument() + expect(queryByRole('alert')).toHaveClass('alert-danger') + expect( + queryByText(`${t('general.player.owner')}: ${fixture.uid}`), + ).toBeInTheDocument() + }) +}) + +describe('update texture', () => { + beforeEach(() => { + fetch.get.mockResolvedValue(createPaginator([fixture])) + }) + + it('cancelled', async () => { + const { getByText } = render() + + await waitFor(() => expect(fetch.get).toBeCalled()) + fireEvent.click(getByText(t('admin.changeTexture'))) + fireEvent.click(getByText(t('general.cancel'))) + + expect(fetch.put).not.toBeCalled() + }) + + it('skin', async () => { + fetch.put.mockResolvedValue({ code: 0, message: 'ok' }) + + const { getByText, getByLabelText, queryByText, queryByRole } = render( + , + ) + + await waitFor(() => expect(fetch.get).toBeCalled()) + fireEvent.click(getByText(t('admin.changeTexture'))) + fireEvent.input(getByLabelText('TID'), { + target: { value: '2' }, + }) + fireEvent.click(getByText(t('general.confirm'))) + + await waitFor(() => + expect(fetch.put).toBeCalledWith( + `/admin/players/${fixture.pid}/textures`, + { + type: 'skin', + tid: 2, + }, + ), + ) + expect(queryByText('ok')).toBeInTheDocument() + expect(queryByRole('status')).toHaveClass('alert-success') + }) + + it('cape', async () => { + fetch.put.mockResolvedValue({ code: 0, message: 'ok' }) + + const { getByText, getByLabelText, queryByText, queryByRole } = render( + , + ) + + await waitFor(() => expect(fetch.get).toBeCalled()) + fireEvent.click(getByText(t('admin.changeTexture'))) + fireEvent.click(getByLabelText(t('general.cape'))) + fireEvent.input(getByLabelText('TID'), { + target: { value: '2' }, + }) + fireEvent.click(getByText(t('general.confirm'))) + + await waitFor(() => + expect(fetch.put).toBeCalledWith( + `/admin/players/${fixture.pid}/textures`, + { + type: 'cape', + tid: 2, + }, + ), + ) + expect(queryByText('ok')).toBeInTheDocument() + expect(queryByRole('status')).toHaveClass('alert-success') + }) + + it('failed', async () => { + fetch.put.mockResolvedValue({ code: 1, message: 'failed' }) + + const { getByText, getByLabelText, queryByText, queryByRole } = render( + , + ) + + await waitFor(() => expect(fetch.get).toBeCalled()) + fireEvent.click(getByText(t('admin.changeTexture'))) + fireEvent.input(getByLabelText('TID'), { + target: { value: '2' }, + }) + fireEvent.click(getByText(t('general.confirm'))) + + await waitFor(() => + expect(fetch.put).toBeCalledWith( + `/admin/players/${fixture.pid}/textures`, + { + type: 'skin', + tid: 2, + }, + ), + ) + expect(queryByText('failed')).toBeInTheDocument() + expect(queryByRole('alert')).toHaveClass('alert-danger') + }) +}) + +describe('delete player', () => { + beforeEach(() => { + fetch.get.mockResolvedValue(createPaginator([fixture])) + }) + + it('cancelled', async () => { + const { getByText, queryByText } = render() + + await waitFor(() => expect(fetch.get).toBeCalled()) + fireEvent.click(getByText(t('admin.deletePlayer'))) + fireEvent.click(getByText(t('general.cancel'))) + + expect(fetch.del).not.toBeCalled() + expect(queryByText(fixture.name)).toBeInTheDocument() + }) + + it('succeeded', async () => { + fetch.del.mockResolvedValue({ code: 0, message: 'ok' }) + + const { getByText, queryByText, queryByRole } = render( + , + ) + + await waitFor(() => expect(fetch.get).toBeCalled()) + fireEvent.click(getByText(t('admin.deletePlayer'))) + fireEvent.click(getByText(t('general.confirm'))) + + await waitFor(() => + expect(fetch.del).toBeCalledWith(`/admin/players/${fixture.pid}`), + ) + expect(queryByText('ok')).toBeInTheDocument() + expect(queryByRole('status')).toHaveClass('alert-success') + expect(queryByText(fixture.name)).not.toBeInTheDocument() + }) + + it('failed', async () => { + fetch.del.mockResolvedValue({ code: 1, message: 'failed' }) + + const { getByText, queryByText, queryByRole } = render( + , + ) + + await waitFor(() => expect(fetch.get).toBeCalled()) + fireEvent.click(getByText(t('admin.deletePlayer'))) + fireEvent.click(getByText(t('general.confirm'))) + + await waitFor(() => + expect(fetch.del).toBeCalledWith(`/admin/players/${fixture.pid}`), + ) + expect(queryByText('failed')).toBeInTheDocument() + expect(queryByRole('alert')).toHaveClass('alert-danger') + expect(queryByText(fixture.name)).toBeInTheDocument() + }) +}) diff --git a/resources/assets/tests/views/user/Closet.test.tsx b/resources/assets/tests/views/user/Closet.test.tsx index 30942421..cbd93175 100644 --- a/resources/assets/tests/views/user/Closet.test.tsx +++ b/resources/assets/tests/views/user/Closet.test.tsx @@ -45,8 +45,10 @@ const fixtureCape: Readonly = Object.freeze({ const fixturePlayer: Readonly = Object.freeze({ pid: 1, name: 'kumiko', + uid: 1, tid_skin: 1, tid_cape: 2, + last_modified: new Date().toString(), }) function createPaginator(data: ClosetItem[]): Paginator { diff --git a/resources/assets/tests/views/user/Players.test.tsx b/resources/assets/tests/views/user/Players.test.tsx index a4811a16..3783eb4b 100644 --- a/resources/assets/tests/views/user/Players.test.tsx +++ b/resources/assets/tests/views/user/Players.test.tsx @@ -10,8 +10,10 @@ jest.mock('@/scripts/net') const fixture: Readonly = Object.freeze({ pid: 1, name: 'kumiko', + uid: 1, tid_skin: 1, tid_cape: 2, + last_modified: new Date().toString(), }) beforeEach(() => { @@ -38,7 +40,14 @@ test('loading indicator', () => { }) test('search players', async () => { - const fixture2: Player = { pid: 2, name: 'reina', tid_skin: 3, tid_cape: 4 } + const fixture2: Player = { + pid: 2, + name: 'reina', + uid: 2, + tid_skin: 3, + tid_cape: 4, + last_modified: new Date().toString(), + } fetch.get.mockResolvedValue({ data: [fixture, fixture2] }) const { getByPlaceholderText, queryByText } = render() @@ -63,7 +72,14 @@ describe('select player automatically', () => { }) it('more players', async () => { - const fixture2: Player = { pid: 2, name: 'reina', tid_skin: 3, tid_cape: 4 } + const fixture2: Player = { + pid: 2, + name: 'reina', + uid: 2, + tid_skin: 3, + tid_cape: 4, + last_modified: new Date().toString(), + } fetch.get.mockResolvedValue({ data: [fixture, fixture2] }) render() await waitFor(() => expect(fetch.get).toBeCalledTimes(1)) diff --git a/routes/web.php b/routes/web.php index b3b716c6..28cc3100 100644 --- a/routes/web.php +++ b/routes/web.php @@ -133,11 +133,16 @@ Route::prefix('admin') Route::get('list', 'AdminController@getUserData'); }); - Route::prefix('players')->group(function () { - Route::view('', 'admin.players'); - Route::post('', 'AdminController@playerAjaxHandler'); - Route::get('list', 'AdminController@getPlayerData'); - }); + Route::prefix('players') + ->name('players.') + ->group(function () { + Route::view('', 'admin.players'); + Route::get('list', 'PlayersManagementController@list')->name('list'); + Route::put('{player}/name', 'PlayersManagementController@name')->name('name'); + Route::put('{player}/owner', 'PlayersManagementController@owner')->name('owner'); + Route::put('{player}/textures', 'PlayersManagementController@texture')->name('texture'); + Route::delete('{player}', 'PlayersManagementController@delete')->name('delete'); + }); Route::prefix('closet')->group(function () { Route::post('{uid}', 'ClosetManagementController@add'); diff --git a/tests/HttpTest/ControllersTest/AdminControllerTest.php b/tests/HttpTest/ControllersTest/AdminControllerTest.php index f8d61c5f..35695fcd 100644 --- a/tests/HttpTest/ControllersTest/AdminControllerTest.php +++ b/tests/HttpTest/ControllersTest/AdminControllerTest.php @@ -387,202 +387,4 @@ class AdminControllerTest extends TestCase ]); $this->assertNull(User::find($user->uid)); } - - public function testPlayerAjaxHandler() - { - $player = factory(Player::class)->create(); - - // Operate on a not-existed player - $this->postJson('/admin/players', ['pid' => -1]) - ->assertJson([ - 'code' => 1, - 'message' => trans('general.unexistent-player'), - ]); - - // An admin cannot operate another admin's player - $admin = factory(User::class)->states('admin')->create(); - $this->postJson( - '/admin/players', - ['pid' => factory(Player::class)->create(['uid' => $admin->uid])->pid] - )->assertJson([ - 'code' => 1, - 'message' => trans('admin.players.no-permission'), - ]); - $superAdmin = factory(User::class)->states('superAdmin')->create(); - $this->postJson( - '/admin/players', - ['pid' => factory(Player::class)->create(['uid' => $superAdmin->uid])->pid] - )->assertJson([ - 'code' => 1, - 'message' => trans('admin.players.no-permission'), - ]); - // For self is OK - $this->actingAs($admin)->postJson( - '/admin/players', - ['pid' => factory(Player::class)->create(['uid' => $admin->uid])->pid] - )->assertJson([ - 'code' => 1, - 'message' => trans('admin.users.operations.invalid'), - ]); - - // Change texture without `type` field - $this->postJson('/admin/players', [ - 'pid' => $player->pid, - 'action' => 'texture', - ])->assertJsonValidationErrors(['type']); - - // Change texture without `tid` field - $this->postJson('/admin/players', [ - 'pid' => $player->pid, - 'action' => 'texture', - 'type' => 'skin', - ])->assertJsonValidationErrors(['tid']); - - // Change texture with a not-integer value - $this->postJson('/admin/players', [ - 'pid' => $player->pid, - 'action' => 'texture', - 'type' => 'skin', - 'tid' => 'string', - ])->assertJsonValidationErrors(['tid']); - - // Invalid texture - $this->postJson('/admin/players', [ - 'pid' => $player->pid, - 'action' => 'texture', - 'type' => 'skin', - 'tid' => -1, - ])->assertJson([ - 'code' => 1, - 'message' => trans('admin.players.textures.non-existent', ['tid' => -1]), - ]); - - $skin = factory(Texture::class)->create(); - $cape = factory(Texture::class)->states('cape')->create(); - - // Skin - $this->postJson('/admin/players', [ - 'pid' => $player->pid, - 'action' => 'texture', - 'type' => 'skin', - 'tid' => $skin->tid, - ])->assertJson([ - 'code' => 0, - 'message' => trans('admin.players.textures.success', ['player' => $player->name]), - ]); - $player = Player::find($player->pid); - $this->assertEquals($skin->tid, $player->tid_skin); - - // Cape - $this->postJson('/admin/players', [ - 'pid' => $player->pid, - 'action' => 'texture', - 'type' => 'cape', - 'tid' => $cape->tid, - ])->assertJson([ - 'code' => 0, - 'message' => trans('admin.players.textures.success', ['player' => $player->name]), - ]); - $player = Player::find($player->pid); - $this->assertEquals($cape->tid, $player->tid_cape); - - // Reset texture - $this->postJson('/admin/players', [ - 'pid' => $player->pid, - 'action' => 'texture', - 'type' => 'skin', - 'tid' => 0, - ])->assertJson([ - 'code' => 0, - 'message' => trans('admin.players.textures.success', ['player' => $player->name]), - ]); - $player = Player::find($player->pid); - $this->assertEquals(0, $player->tid_skin); - $this->assertNotEquals(0, $player->tid_cape); - - $this->postJson('/admin/players', [ - 'pid' => $player->pid, - 'action' => 'texture', - 'type' => 'cape', - 'tid' => 0, - ])->assertJson([ - 'code' => 0, - 'message' => trans('admin.players.textures.success', ['player' => $player->name]), - ]); - $player = Player::find($player->pid); - $this->assertEquals(0, $player->tid_cape); - - // Change owner without `uid` field - $this->postJson('/admin/players', [ - 'pid' => $player->pid, - 'action' => 'owner', - ])->assertJsonValidationErrors(['uid']); - - // Change owner with a not-integer `uid` value - $this->postJson('/admin/players', [ - 'pid' => $player->pid, - 'action' => 'owner', - 'uid' => 'string', - ])->assertJsonValidationErrors(['uid']); - - // Change owner to a not-existed user - $this->postJson('/admin/players', [ - 'pid' => $player->pid, - 'action' => 'owner', - 'uid' => -1, - ])->assertJson([ - 'code' => 1, - 'message' => trans('admin.users.operations.non-existent'), - ]); - - // Change owner successfully - $user = factory(User::class)->create(); - $this->postJson('/admin/players', [ - 'pid' => $player->pid, - 'action' => 'owner', - 'uid' => $user->uid, - ])->assertJson([ - 'code' => 0, - 'message' => trans( - 'admin.players.owner.success', - ['player' => $player->name, 'user' => $user->nickname] - ), - ]); - - // Rename a player without `name` field - $this->postJson('/admin/players', [ - 'pid' => $player->pid, - 'action' => 'name', - ])->assertJsonValidationErrors(['name']); - - // Rename a player successfully - $this->postJson('/admin/players', [ - 'pid' => $player->pid, - 'action' => 'name', - 'name' => 'new_name', - ])->assertJson([ - 'code' => 0, - 'message' => trans('admin.players.name.success', ['player' => 'new_name']), - ]); - - // Single player - option(['single_player' => true]); - $this->postJson('/admin/players', [ - 'pid' => $player->pid, - 'action' => 'name', - 'name' => 'abc', - ])->assertJson(['code' => 0]); - $player->refresh(); - $this->assertEquals('abc', $player->user->nickname); - - // Delete a player - $this->postJson('/admin/players', [ - 'pid' => $player->pid, - 'action' => 'delete', - ])->assertJson([ - 'code' => 0, - 'message' => trans('admin.players.delete.success'), - ]); - $this->assertNull(Player::find($player->pid)); - } } diff --git a/tests/HttpTest/ControllersTest/PlayersManagementControllerTest.php b/tests/HttpTest/ControllersTest/PlayersManagementControllerTest.php new file mode 100644 index 00000000..3194d691 --- /dev/null +++ b/tests/HttpTest/ControllersTest/PlayersManagementControllerTest.php @@ -0,0 +1,381 @@ +actingAs(factory(\App\Models\User::class)->states('admin')->create()); + } + + public function testList() + { + $player = factory(Player::class)->create(); + + $this->getJson(route('admin.players.list')) + ->assertJson(['data' => [$player->toArray()]]); + } + + public function testAccessControl() + { + // an admin can't operate another admin's player + $admin = factory(User::class)->states('admin')->create(); + /** @var Player */ + $player = factory(Player::class)->create(['uid' => $admin->uid]); + $this->putJson( + route('admin.players.name', ['player' => $player->pid]), + ['player_name' => 'abcd'] + )->assertJson([ + 'code' => 1, + 'message' => trans('admin.players.no-permission'), + ])->assertForbidden(); + + // for self is OK + $this->actingAs($admin) + ->putJson( + route('admin.players.name', ['player' => $player->pid]), + ['player_name' => 'abcd'] + )->assertJson(['code' => 0]); + + // super admin + $superAdmin = factory(User::class)->states('superAdmin')->create(); + /** @var Player */ + $player = factory(Player::class)->create(['uid' => $superAdmin->uid]); + $this->putJson( + route('admin.players.name', ['player' => $player->pid]), + ['player_name' => 'abcd'] + )->assertJson([ + 'code' => 1, + 'message' => trans('admin.players.no-permission'), + ])->assertForbidden(); + } + + public function testName() + { + /** @var Player */ + $player = factory(Player::class)->create(); + + // missing `player_name` field + $this->putJson( + route('admin.players.name', ['player' => $player->pid]) + )->assertJsonValidationErrors(['player_name']); + + // duplicated player name + $this->putJson( + route('admin.players.name', ['player' => $player->pid]), + ['player_name' => $player->name] + )->assertJsonValidationErrors(['player_name']); + + // single player + option(['single_player' => true]); + $this->putJson( + route('admin.players.name', ['player' => $player->pid]), + ['player_name' => 'abc'] + )->assertJson(['code' => 0]); + $player->refresh(); + $this->assertEquals('abc', $player->user->nickname); + option(['single_player' => false]); + + // rename a player successfully + Event::fake(); + $this->putJson( + route('admin.players.name', ['player' => $player->pid]), + ['player_name' => 'new_name'] + )->assertJson([ + 'code' => 0, + 'message' => trans('admin.players.name.success', ['player' => 'new_name']), + ]); + $oldName = $player->name; + $player->refresh(); + $this->assertEquals('new_name', $player->name); + Event::assertDispatched( + 'player.name.updating', + function ($eventName, $payload) use ($player) { + $this->assertEquals($player->pid, $payload[0]->pid); + $this->assertEquals('new_name', $payload[1]); + + return true; + } + ); + Event::assertDispatched( + 'player.name.updated', + function ($eventName, $payload) use ($player, $oldName) { + $this->assertEquals($player->pid, $payload[0]->pid); + $this->assertEquals($oldName, $payload[1]); + + return true; + } + ); + } + + public function testOwner() + { + Event::fake(); + + /** @var Player */ + $player = factory(Player::class)->create(); + + // missing `uid` field + $this->putJson(route('admin.players.owner', ['player' => $player->pid])) + ->assertJsonValidationErrors(['uid']); + + // with a non-integer `uid` value + $this->putJson( + route('admin.players.owner', ['player' => $player->pid]), + ['uid' => 's'] + )->assertJsonValidationErrors(['uid']); + + // change owner to a not-existed user + $this->putJson( + route('admin.players.owner', ['player' => $player->pid]), + ['uid' => -1] + )->assertJson([ + 'code' => 1, + 'message' => trans('admin.users.operations.non-existent'), + ]); + Event::assertDispatched( + 'player.owner.updating', + function ($eventName, $payload) use ($player) { + $this->assertEquals($player->pid, $payload[0]->pid); + $this->assertEquals(-1, $payload[1]); + + return true; + } + ); + Event::assertNotDispatched('player.owner.updated'); + + // change owner successfully + Event::fake(); + /** @var User */ + $user = factory(User::class)->create(); + $this->putJson( + route('admin.players.owner', ['player' => $player->pid]), + ['uid' => $user->uid] + )->assertJson([ + 'code' => 0, + 'message' => trans( + 'admin.players.owner.success', + ['player' => $player->name, 'user' => $user->nickname] + ), + ]); + Event::assertDispatched( + 'player.owner.updating', + function ($eventName, $payload) use ($player, $user) { + $this->assertEquals($player->pid, $payload[0]->pid); + $this->assertEquals($user->uid, $payload[1]); + + return true; + } + ); + Event::assertDispatched( + 'player.owner.updated', + function ($eventName, $payload) use ($player, $user) { + $this->assertEquals($player->pid, $payload[0]->pid); + $this->assertEquals($user->uid, $payload[1]->uid); + + return true; + } + ); + } + + public function testTexture() + { + Event::fake(); + + /** @var Player */ + $player = factory(Player::class)->create(); + + // missing `tid` field + $this->putJson( + route('admin.players.texture', ['player' => $player->pid]) + )->assertJsonValidationErrors(['tid']); + + // change texture with a non-integer value + $this->putJson( + route('admin.players.texture', ['player' => $player->pid]), + ['tid' => 's'] + )->assertJsonValidationErrors(['tid']); + + // missing `type` field + $this->putJson( + route('admin.players.texture', ['player' => $player->pid]), + ['tid' => -1] + )->assertJsonValidationErrors(['type']); + + // invalid type + $this->putJson( + route('admin.players.texture', ['player' => $player->pid]), + ['tid' => -1, 'type' => 'elytra'] + )->assertJsonValidationErrors(['type']); + + // invalid texture + $this->putJson( + route('admin.players.texture', ['player' => $player->pid]), + ['tid' => -1, 'type' => 'skin'] + )->assertJson([ + 'code' => 1, + 'message' => trans('admin.players.textures.non-existent', ['tid' => -1]), + ]); + Event::assertDispatched( + 'player.texture.updating', + function ($eventName, $payload) use ($player) { + $this->assertEquals($player->pid, $payload[0]->pid); + $this->assertEquals('skin', $payload[1]); + $this->assertEquals(-1, $payload[2]); + + return true; + } + ); + Event::assertNotDispatched('player.texture.updated'); + + /** @var Texture */ + $skin = factory(Texture::class)->create(); + /** @var Texture */ + $cape = factory(Texture::class)->states('cape')->create(); + + // skin + Event::fake(); + $this->putJson( + route('admin.players.texture', ['player' => $player->pid]), + ['tid' => $skin->tid, 'type' => 'skin'] + )->assertJson([ + 'code' => 0, + 'message' => trans('admin.players.textures.success', ['player' => $player->name]), + ]); + $previousTid = $player->tid_skin; + $player->refresh(); + $this->assertEquals($skin->tid, $player->tid_skin); + Event::assertDispatched( + 'player.texture.updating', + function ($eventName, $payload) use ($player, $skin) { + $this->assertEquals($player->pid, $payload[0]->pid); + $this->assertEquals('skin', $payload[1]); + $this->assertEquals($skin->tid, $payload[2]); + + return true; + } + ); + Event::assertDispatched( + 'player.texture.updated', + function ($eventName, $payload) use ($player, $previousTid) { + $this->assertEquals($player->pid, $payload[0]->pid); + $this->assertEquals('skin', $payload[1]); + $this->assertEquals($previousTid, $payload[2]); + + return true; + } + ); + + // cape + Event::fake(); + $this->putJson( + route('admin.players.texture', ['player' => $player->pid]), + ['tid' => $cape->tid, 'type' => 'cape'] + )->assertJson([ + 'code' => 0, + 'message' => trans('admin.players.textures.success', ['player' => $player->name]), + ]); + $previousTid = $player->tid_cape; + $player->refresh(); + $this->assertEquals($cape->tid, $player->tid_cape); + Event::assertDispatched( + 'player.texture.updating', + function ($eventName, $payload) use ($player, $cape) { + $this->assertEquals($player->pid, $payload[0]->pid); + $this->assertEquals('cape', $payload[1]); + $this->assertEquals($cape->tid, $payload[2]); + + return true; + } + ); + Event::assertDispatched( + 'player.texture.updated', + function ($eventName, $payload) use ($player, $previousTid) { + $this->assertEquals($player->pid, $payload[0]->pid); + $this->assertEquals('cape', $payload[1]); + $this->assertEquals($previousTid, $payload[2]); + + return true; + } + ); + + // reset texture + Event::fake(); + $this->putJson( + route('admin.players.texture', ['player' => $player->pid]), + ['tid' => 0, 'type' => 'skin'] + )->assertJson([ + 'code' => 0, + 'message' => trans('admin.players.textures.success', ['player' => $player->name]), + ]); + $previousTid = $player->tid_skin; + $player->refresh(); + $this->assertEquals(0, $player->tid_skin); + $this->assertNotEquals(0, $player->tid_cape); + Event::assertDispatched( + 'player.texture.updating', + function ($eventName, $payload) use ($player) { + $this->assertEquals($player->pid, $payload[0]->pid); + $this->assertEquals('skin', $payload[1]); + $this->assertEquals(0, $payload[2]); + + return true; + } + ); + Event::assertDispatched( + 'player.texture.updated', + function ($eventName, $payload) use ($player, $previousTid) { + $this->assertEquals($player->pid, $payload[0]->pid); + $this->assertEquals('skin', $payload[1]); + $this->assertEquals($previousTid, $payload[2]); + + return true; + } + ); + + $this->putJson( + route('admin.players.texture', ['player' => $player->pid]), + ['tid' => 0, 'type' => 'cape'] + )->assertJson([ + 'code' => 0, + 'message' => trans('admin.players.textures.success', ['player' => $player->name]), + ]); + $player->refresh(); + $this->assertEquals(0, $player->tid_cape); + } + + public function testDelete() + { + Event::fake(); + + /** @var Player */ + $player = factory(Player::class)->create(); + + $this->deleteJson(route('admin.players.delete', ['player' => $player->pid])) + ->assertJson([ + 'code' => 0, + 'message' => trans('admin.players.delete.success'), + ]); + Event::assertDispatched('player.deleting', function ($eventName, $payload) use ($player) { + $this->assertEquals($player->pid, $payload[0]->pid); + + return true; + }); + Event::assertDispatched('player.deleted', function ($eventName, $payload) use ($player) { + $this->assertEquals($player->pid, $payload[0]->pid); + + return true; + }); + $this->assertNull(Player::find($player->pid)); + } +}