diff --git a/app/Http/Controllers/ClosetManagementController.php b/app/Http/Controllers/ClosetManagementController.php new file mode 100644 index 00000000..0e5d5fb2 --- /dev/null +++ b/app/Http/Controllers/ClosetManagementController.php @@ -0,0 +1,34 @@ +input('tid')); + + /** @var User */ + $user = User::findOrFail($uid); + $user->closet()->attach($texture->tid, ['item_name' => $texture->name]); + + return json('', 0, compact('user', 'texture')); + } + + public function remove(Request $request, $uid) + { + /** @var Texture */ + $texture = Texture::findOrFail($request->input('tid')); + + /** @var User */ + $user = User::findOrFail($uid); + $user->closet()->detach($texture->tid); + + return json('', 0, compact('user', 'texture')); + } +} diff --git a/package.json b/package.json index b9f422c9..fcc34fcf 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@hot-loader/react-dom": "^16.11.0", "@tweenjs/tween.js": "^18.4.2", "admin-lte": "^3.0.1", - "blessing-skin-shell": "^0.1.0", + "blessing-skin-shell": "^0.2.0", "echarts": "^4.6.0", "jquery": "^3.4.1", "lodash.debounce": "^4.0.8", @@ -224,7 +224,8 @@ "coveragePathIgnorePatterns": [ "/node_modules/", "/resources/assets/tests/setup", - "/resources/assets/tests/utils" + "/resources/assets/tests/utils", + "/resources/assets/tests/scripts/cli/stdio" ], "testMatch": [ "/resources/assets/tests/**/*.test.ts", diff --git a/resources/assets/src/scripts/cli.tsx b/resources/assets/src/scripts/cli.tsx index 1a7a0335..d54aa470 100644 --- a/resources/assets/src/scripts/cli.tsx +++ b/resources/assets/src/scripts/cli.tsx @@ -5,6 +5,7 @@ import { FitAddon } from 'xterm-addon-fit' import { Shell } from 'blessing-skin-shell' import 'xterm/css/xterm.css' import Draggable from 'react-draggable' +import ClosetCommand from './cli/ClosetCommand' import styles from '@/styles/terminal.module.scss' let launched = false @@ -29,6 +30,8 @@ const TerminalWindow: React.FC<{ onClose(): void }> = props => { fitAddon.fit() const shell = new Shell(terminal) + shell.addExternal('closet', ClosetCommand) + const unbind = terminal.onData(e => shell.input(e)) launched = true diff --git a/resources/assets/src/scripts/cli/ClosetCommand.ts b/resources/assets/src/scripts/cli/ClosetCommand.ts new file mode 100644 index 00000000..063141c5 --- /dev/null +++ b/resources/assets/src/scripts/cli/ClosetCommand.ts @@ -0,0 +1,63 @@ +import type { Stdio } from 'blessing-skin-shell' +import * as fetch from '../net' +import { User, Texture } from '../types' + +type Subcommand = 'add' | 'remove' + +type Response = fetch.ResponseBody<{ user: User; texture: Texture }> + +export default async function closet(stdio: Stdio, args: string[]) { + if (args.includes('-h') || args.includes('--help')) { + stdio.println('Usage: closet ') + return + } + + const command = args[0] as Subcommand + const uid = args[1] + const tid = args[2] + + if (!command) { + stdio.println('Supported subcommand: add, remove.') + return + } + if (!uid) { + stdio.println('User ID must be provided.') + return + } + if (!tid) { + stdio.println('Texture ID must be provided.') + return + } + + switch (command) { + case 'add': { + const { code, data } = await fetch.post( + `/admin/closet/${uid}`, + { tid }, + ) + if (code === 0) { + const { texture, user } = data + stdio.println( + `Texture "${texture.name}" was added to user ${user.nickname}'s closet.`, + ) + } else { + stdio.println('Error occurred.') + } + break + } + case 'remove': { + const { code, data } = await fetch.del(`/admin/closet/${uid}`, { + tid, + }) + if (code === 0) { + const { texture, user } = data + stdio.println( + `Texture "${texture.name}" was removed from user ${user.nickname}'s closet.`, + ) + } else { + stdio.println('Error occurred.') + } + break + } + } +} diff --git a/resources/assets/src/scripts/types.ts b/resources/assets/src/scripts/types.ts index 30cd790f..f4b63e19 100644 --- a/resources/assets/src/scripts/types.ts +++ b/resources/assets/src/scripts/types.ts @@ -1,3 +1,16 @@ +export type User = { + uid: number + email: string + nickname: string + score: number + avatar: number + permission: number + ip: string + last_sign_at: string + register_at: string + verified: boolean +} + export type Player = { pid: number name: string diff --git a/resources/assets/src/styles/terminal.module.scss b/resources/assets/src/styles/terminal.module.scss index a615699e..ea5c2b23 100644 --- a/resources/assets/src/styles/terminal.module.scss +++ b/resources/assets/src/styles/terminal.module.scss @@ -1,7 +1,7 @@ @use '../styles/breakpoints'; .terminal { - z-index: 1060; + z-index: 1040; position: fixed; bottom: 7vh; user-select: none; diff --git a/resources/assets/tests/scripts/cli/ClosetCommand.test.ts b/resources/assets/tests/scripts/cli/ClosetCommand.test.ts new file mode 100644 index 00000000..f82318dd --- /dev/null +++ b/resources/assets/tests/scripts/cli/ClosetCommand.test.ts @@ -0,0 +1,101 @@ +import * as fetch from '@/scripts/net' +import runCommand from '@/scripts/cli/ClosetCommand' +import { Stdio } from './stdio' + +jest.mock('@/scripts/net') + +test('help message', async () => { + let stdio = new Stdio() + await runCommand(stdio, ['-h']) + expect(stdio.getStdout()).toInclude('Usage') + + stdio = new Stdio() + await runCommand(stdio, ['--help']) + expect(stdio.getStdout()).toInclude('Usage') +}) + +test('missing subcommand', async () => { + const stdio = new Stdio() + await runCommand(stdio, []) + expect(fetch.post).not.toBeCalled() + expect(fetch.del).not.toBeCalled() +}) + +test('unsupported subcommand', async () => { + const stdio = new Stdio() + await runCommand(stdio, ['abc']) + expect(fetch.post).not.toBeCalled() + expect(fetch.del).not.toBeCalled() +}) + +test('missing uid', async () => { + const stdio = new Stdio() + await runCommand(stdio, ['add']) + expect(stdio.getStdout()).toInclude('User ID') + expect(fetch.post).not.toBeCalled() + expect(fetch.del).not.toBeCalled() +}) + +test('missing tid', async () => { + const stdio = new Stdio() + await runCommand(stdio, ['add', '1']) + expect(stdio.getStdout()).toInclude('Texture ID') + expect(fetch.post).not.toBeCalled() + expect(fetch.del).not.toBeCalled() +}) + +describe('add texture', () => { + it('succeeded', async () => { + fetch.post.mockResolvedValue({ + code: 0, + data: { user: { nickname: 'kumiko' }, texture: { name: 'eupho' } }, + }) + + const stdio = new Stdio() + await runCommand(stdio, ['add', '1', '2']) + + const stdout = stdio.getStdout() + expect(stdout).toInclude('kumiko') + expect(stdout).toInclude('eupho') + expect(fetch.post).toBeCalledWith('/admin/closet/1', { tid: '2' }) + }) + + it('failed', async () => { + fetch.post.mockResolvedValue({ code: 1 }) + + const stdio = new Stdio() + await runCommand(stdio, ['add', '1', '2']) + + const stdout = stdio.getStdout() + expect(stdout).toInclude('Error occurred.') + expect(fetch.post).toBeCalledWith('/admin/closet/1', { tid: '2' }) + }) +}) + +describe('remove texture', () => { + it('succeeded', async () => { + fetch.del.mockResolvedValue({ + code: 0, + data: { user: { nickname: 'kumiko' }, texture: { name: 'eupho' } }, + }) + + const stdio = new Stdio() + await runCommand(stdio, ['remove', '1', '2']) + + const stdout = stdio.getStdout() + expect(stdout).toInclude('kumiko') + expect(stdout).toInclude('eupho') + expect(fetch.del).toBeCalledWith('/admin/closet/1', { tid: '2' }) + }) + + it('failed', async () => { + fetch.del.mockResolvedValue({ code: 1 }) + + const stdio = new Stdio() + await runCommand(stdio, ['remove', '1', '2']) + + const stdout = stdio.getStdout() + expect(stdout).toInclude('Error occurred.') + expect(fetch.del).toBeCalledWith('/admin/closet/1', { tid: '2' }) + }) +}) diff --git a/resources/assets/tests/scripts/cli/stdio.ts b/resources/assets/tests/scripts/cli/stdio.ts new file mode 100644 index 00000000..7431823e --- /dev/null +++ b/resources/assets/tests/scripts/cli/stdio.ts @@ -0,0 +1,24 @@ +export class Stdio { + private stdout = '' + + public print(data: string) { + this.stdout += data + } + + public println(data: string) { + this.stdout += data + this.stdout += '\r\n' + } + + public reset() { + // + } + + public free() { + // + } + + public getStdout() { + return this.stdout + } +} diff --git a/routes/web.php b/routes/web.php index dd6b0194..80a09cec 100644 --- a/routes/web.php +++ b/routes/web.php @@ -140,6 +140,11 @@ Route::prefix('admin') Route::get('list', 'AdminController@getPlayerData'); }); + Route::prefix('closet')->group(function () { + Route::post('{uid}', 'ClosetManagementController@add'); + Route::delete('{uid}', 'ClosetManagementController@remove'); + }); + Route::prefix('reports')->group(function () { Route::view('', 'admin.reports'); Route::post('', 'ReportController@review'); diff --git a/tests/HttpTest/ControllersTest/ClosetManagementControllerTest.php b/tests/HttpTest/ControllersTest/ClosetManagementControllerTest.php new file mode 100644 index 00000000..f7fb1a10 --- /dev/null +++ b/tests/HttpTest/ControllersTest/ClosetManagementControllerTest.php @@ -0,0 +1,53 @@ +actingAs(factory(\App\Models\User::class)->states('admin')->create()); + } + + public function testAdd() + { + $user = factory(User::class)->create(); + $texture = factory(Texture::class)->create(); + + $this->postJson('/admin/closet/'.$user->uid, ['tid' => $texture->tid]) + ->assertJson([ + 'code' => 0, + 'data' => [ + 'user' => $user->toArray(), + 'texture' => $texture->toArray(), + ], + ]); + $item = $user->closet()->first(); + $this->assertEquals($texture->tid, $item->tid); + $this->assertEquals($texture->name, $item->pivot->item_name); + } + + public function testRemove() + { + $user = factory(User::class)->create(); + $texture = factory(Texture::class)->create(); + $user->closet()->attach($texture->tid, ['item_name' => '']); + + $this->deleteJson('/admin/closet/'.$user->uid, ['tid' => $texture->tid]) + ->assertJson([ + 'code' => 0, + 'data' => [ + 'user' => $user->toArray(), + 'texture' => $texture->toArray(), + ], + ]); + $this->assertCount(0, $user->closet); + } +} diff --git a/yarn.lock b/yarn.lock index c718c5fc..379bf48f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2267,10 +2267,10 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.0.0.tgz#23c0df14f6a88077f5f986c0d167ec03c3d5537c" integrity sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow== -blessing-skin-shell@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/blessing-skin-shell/-/blessing-skin-shell-0.1.0.tgz#6251b154d2e5b225d65f1b6e8c7489bb2eb67303" - integrity sha512-6c7RwPqLS1mXsrLd6QU7xUjr/gfmDgAY5L+CqOMWwwjLfjuohw6SK6cnGyfWxeK8KoDj5zX3QyoYuCKapXlkUg== +blessing-skin-shell@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/blessing-skin-shell/-/blessing-skin-shell-0.2.0.tgz#2454e305c69de134164491d2715a34d599c0d8a1" + integrity sha512-bwVHWep3jlPwgY7rAmr+KZJr1RdfVS9OTQB25avRW4EzONflQRiRgU76T8X/DeWa3PItEEGf3qNUJJuRhN5orw== bluebird@^3.1.1, bluebird@^3.5.5: version "3.5.5"