diff --git a/.travis.yml b/.travis.yml index 8177ec8c..ee82e47b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,6 @@ git: cache: directories: - vendor - - plugins - node_modules env: diff --git a/app/Http/Controllers/MarketController.php b/app/Http/Controllers/MarketController.php new file mode 100644 index 00000000..4c96c626 --- /dev/null +++ b/app/Http/Controllers/MarketController.php @@ -0,0 +1,156 @@ +guzzle = $guzzle; + $this->guzzleConfig = [ + 'headers' => ['User-Agent' => config('secure.user_agent')], + 'verify' => config('secure.certificates') + ]; + } + + public function marketData() + { + $plugins = collect($this->getAllAvailablePlugins())->map(function ($item) { + $plugin = plugin($item['name']); + $manager = app('plugins'); + + if ($plugin) { + $item['enabled'] = $plugin->isEnabled(); + $item['installed'] = $plugin->version; + $item['update_available'] = Comparator::greaterThan($item['version'], $item['installed']); + } else { + $item['installed'] = false; + } + + $requirements = array_get($item, 'require', []); + unset($item['require']); + + $item['dependencies'] = [ + 'isRequirementsSatisfied' => $manager->isRequirementsSatisfied($requirements), + 'requirements' => $requirements, + 'unsatisfiedRequirements' => $manager->getUnsatisfiedRequirements($requirements) + ]; + + return $item; + }); + + return $plugins; + } + + public function checkUpdates() + { + $pluginsHaveUpdate = collect($this->getAllAvailablePlugins())->filter(function ($item) { + $plugin = plugin($item['name']); + return $plugin && Comparator::greaterThan($item['version'], $plugin->version); + }); + + return json([ + 'available' => $pluginsHaveUpdate->isNotEmpty(), + 'plugins' => $pluginsHaveUpdate->values()->all() + ]); + } + + public function download(Request $request, PluginManager $manager) + { + $name = $request->get('name'); + $metadata = $this->getPluginMetadata($name); + + if (! $metadata) { + return json(trans('admin.plugins.market.non-existent', ['plugin' => $name]), 1); + } + + // Gather plugin distribution URL + $url = $metadata['dist']['url']; + $filename = array_last(explode('/', $url)); + $plugins_dir = $manager->getPluginsDir(); + $tmp_path = $plugins_dir.DIRECTORY_SEPARATOR.$filename; + + // Download + try { + $this->guzzle->request('GET', $url, array_merge($this->guzzleConfig, [ + 'sink' => $tmp_path + ])); + } catch (Exception $e) { + report($e); + return json(trans('admin.plugins.market.download-failed', ['error' => $e->getMessage()]), 2); + } + + // Check file's sha1 hash + if (sha1_file($tmp_path) !== $metadata['dist']['shasum']) { + @unlink($tmp_path); + return json(trans('admin.plugins.market.shasum-failed'), 3); + } + + // Unzip + $zip = new ZipArchive(); + $res = $zip->open($tmp_path); + + if ($res === true) { + if ($zip->extractTo($plugins_dir) === false) { + return json(trans('admin.plugins.market.unzip-failed', ['error' => 'Unable to extract the file.']), 4); + } + } else { + return json(trans('admin.plugins.market.unzip-failed', ['error' => $res]), 4); + } + $zip->close(); + @unlink($tmp_path); + + return json(trans('admin.plugins.market.install-success'), 0); + } + + protected function getPluginMetadata($name) + { + return collect($this->getAllAvailablePlugins())->where('name', $name)->first(); + } + + protected function getAllAvailablePlugins() + { + if (app()->environment('testing') || ! $this->registryCache) { + try { + $pluginsJson = $this->guzzle->request( + 'GET', config('plugins.registry'), $this->guzzleConfig + )->getBody(); + } catch (Exception $e) { + throw new Exception(trans('admin.plugins.market.connection-error', [ + 'error' => htmlentities($e->getMessage()) + ])); + } + + $this->registryCache = json_decode($pluginsJson, true); + } + + return array_get($this->registryCache, 'packages', []); + } +} diff --git a/app/Http/Controllers/PluginController.php b/app/Http/Controllers/PluginController.php index 56ef1760..8b3641df 100644 --- a/app/Http/Controllers/PluginController.php +++ b/app/Http/Controllers/PluginController.php @@ -11,7 +11,7 @@ use App\Services\PluginManager; class PluginController extends Controller { - public function config($name, Request $request) + public function config($name) { $plugin = plugin($name); diff --git a/app/Services/PluginManager.php b/app/Services/PluginManager.php index 1b0ad671..2698429f 100644 --- a/app/Services/PluginManager.php +++ b/app/Services/PluginManager.php @@ -277,20 +277,24 @@ class PluginManager /** * Get the unsatisfied requirements of plugin. * - * @param string|Plugin $plugin + * @param string|Plugin|array $plugin * @return array */ public function getUnsatisfiedRequirements($plugin) { - if (! $plugin instanceof Plugin) { - $plugin = $this->getPlugin($plugin); - } + if (is_array($plugin)) { + $requirements = $plugin; + } else { + if (! $plugin instanceof Plugin) { + $plugin = $this->getPlugin($plugin); + } - if (! $plugin) { - throw new \InvalidArgumentException('Plugin with given name does not exist.'); - } + if (! $plugin) { + throw new \InvalidArgumentException('Plugin with given name does not exist.'); + } - $requirements = $plugin->getRequirements(); + $requirements = $plugin->getRequirements(); + } $unsatisfied = []; @@ -334,7 +338,7 @@ class PluginManager /** * Whether the plugin's requirements are satisfied. * - * @param string|Plugin $plugin + * @param string|Plugin|array $plugin * @return bool */ public function isRequirementsSatisfied($plugin) @@ -347,7 +351,7 @@ class PluginManager * * @return string */ - protected function getPluginsDir() + public function getPluginsDir() { return config('plugins.directory') ?: base_path('plugins'); } diff --git a/config/plugins.php b/config/plugins.php index bd499d11..9dc2a713 100644 --- a/config/plugins.php +++ b/config/plugins.php @@ -22,4 +22,14 @@ return [ | */ 'url' => menv('PLUGINS_URL'), + + /* + |-------------------------------------------------------------------------- + | Plugins Market Source + |-------------------------------------------------------------------------- + | + | Specify where to get plugins' metadata for plugin market. + | + */ + 'registry' => menv('PLUGINS_REGISTRY', 'https://coding.net/u/printempw/p/bs-plugins-archive/git/raw/master/plugins.json'), ]; diff --git a/resources/assets/src/components/admin/Market.vue b/resources/assets/src/components/admin/Market.vue new file mode 100644 index 00000000..e6443d1e --- /dev/null +++ b/resources/assets/src/components/admin/Market.vue @@ -0,0 +1,207 @@ + + + diff --git a/resources/assets/src/components/route.js b/resources/assets/src/components/route.js index 869fabf9..158d35cd 100644 --- a/resources/assets/src/components/route.js +++ b/resources/assets/src/components/route.js @@ -34,6 +34,11 @@ export default [ component: () => import('./admin/Customization'), el: '#change-color' }, + { + path: 'admin/plugins/market', + component: () => import('./admin/Market'), + el: '.content' + }, { path: 'auth/login', component: () => import('./auth/Login'), diff --git a/resources/assets/tests/components/admin/Market.test.js b/resources/assets/tests/components/admin/Market.test.js new file mode 100644 index 00000000..9401c1eb --- /dev/null +++ b/resources/assets/tests/components/admin/Market.test.js @@ -0,0 +1,131 @@ +import Vue from 'vue'; +import { mount } from '@vue/test-utils'; +import Market from '@/components/admin/Market'; +import { flushPromises } from '../../utils'; +import { swal } from '@/js/notify'; + +jest.mock('@/js/notify'); + +test('render dependencies', async () => { + Vue.prototype.$http.get.mockResolvedValue([ + { name: 'a', dependencies: { requirements: [] } }, + { name: 'b', dependencies: { + requirements: { 'a': '^1.0.0', 'c': '^2.0.0' }, unsatisfiedRequirements: { 'c': {} } + } } + ]); + const wrapper = mount(Market); + await flushPromises(); + + expect(wrapper.text()).toContain('admin.noDependencies'); + expect(wrapper.find('span.label.bg-green').text()).toBe('a: ^1.0.0'); + expect(wrapper.find('span.label.bg-red').text()).toBe('c: ^2.0.0'); +}); + +test('render operation buttons', async () => { + Vue.prototype.$http.get.mockResolvedValue([ + { name: 'a', dependencies: { requirements: [] }, installed: true, update_available: true }, + { name: 'b', dependencies: { requirements: [] }, installed: true, enabled: true }, + { name: 'c', dependencies: { requirements: [] }, installed: true }, + { name: 'd', dependencies: { requirements: [] }, installed: false }, + ]); + const wrapper = mount(Market); + await flushPromises(); + const tbody = wrapper.find('tbody'); + + expect(tbody.find('tr:nth-child(1)').text()).toContain('admin.updatePlugin'); + expect(tbody.find('tr:nth-child(2)').text()).toContain('admin.statusEnabled'); + expect(tbody.find('tr:nth-child(3)').text()).toContain('admin.enablePlugin'); + expect(tbody.find('tr:nth-child(4)').text()).toContain('admin.installPlugin'); +}); + +test('install plugin', async () => { + Vue.prototype.$http.get.mockResolvedValue([ + { name: 'd', dependencies: { requirements: [] }, installed: false }, + ]); + Vue.prototype.$http.post + .mockResolvedValueOnce({ errno: 1, msg: '1' }) + .mockResolvedValueOnce({ errno: 0, msg: '0' }); + const wrapper = mount(Market); + await flushPromises(); + const button = wrapper.find('button'); + + button.trigger('click'); + await flushPromises(); + expect(Vue.prototype.$http.post).toBeCalledWith( + '/admin/plugins/market/download', + { name: 'd' } + ); + + button.trigger('click'); + await wrapper.vm.$nextTick(); + expect(wrapper.text()).toContain('admin.enablePlugin'); +}); + +test('update plugin', async () => { + Vue.prototype.$http.get.mockResolvedValue([ + { + name: 'a', + version: '2.0.0', + dependencies: { requirements: [] }, + installed: '1.0.0', + update_available: true + }, + ]); + Vue.prototype.$http.post + .mockResolvedValueOnce({ errno: 1, msg: '1' }); + swal.mockResolvedValueOnce({ dismiss: 1 }) + .mockResolvedValue({}); + const wrapper = mount(Market); + await flushPromises(); + const button = wrapper.find('button'); + + button.trigger('click'); + await flushPromises(); + expect(Vue.prototype.$http.post).not.toBeCalled(); + + button.trigger('click'); + await flushPromises(); + expect(Vue.prototype.$http.post).toBeCalledWith( + '/admin/plugins/market/download', + { name: 'a' } + ); +}); + +test('enable installed plugin', async () => { + Vue.prototype.$http.get.mockResolvedValue([ + { name: 'a', dependencies: { requirements: [] }, installed: true }, + { name: 'b', dependencies: { requirements: {} }, installed: true }, + ]); + Vue.prototype.$http.post + .mockResolvedValueOnce({ errno: 1, msg: '1', reason: ['`a
`b'] }) + .mockResolvedValue({ errno: 0, msg: '0' }); + swal.mockResolvedValueOnce({ dismiss: 1 }) + .mockResolvedValueOnce({}); + const wrapper = mount(Market); + await flushPromises(); + const buttons = wrapper.findAll('button'); + + buttons.at(0).trigger('click'); + await flushPromises(); + expect(swal).toBeCalledWith({ + text: 'admin.noDependenciesNotice', + type: 'warning', + showCancelButton: true + }); + expect(Vue.prototype.$http.post).not.toBeCalled(); + + buttons.at(0).trigger('click'); + await flushPromises(); + expect(Vue.prototype.$http.post).toBeCalledWith( + '/admin/plugins/manage', + { action: 'enable', name: 'a' } + ); + expect(swal).toBeCalledWith({ + type: 'warning', + html: '

1

' + }); + + buttons.at(1).trigger('click'); + await flushPromises(); + expect(wrapper.text()).toContain('admin.statusEnabled'); +}); diff --git a/resources/lang/en/admin.yml b/resources/lang/en/admin.yml index 08749fb6..29caf8b8 100644 --- a/resources/lang/en/admin.yml +++ b/resources/lang/en/admin.yml @@ -102,23 +102,26 @@ plugins: version: Version dependencies: Dependencies - status: - title: Status - enabled: Enabled - disabled: Disabled - operations: title: Operations enabled: :plugin has been enabled. unsatisfied: notice: There are unsatisfied dependencies in the plugin, therefore we can't enable it. Please install or update the plugins listed below. - disabled: The :name plugin is not enabled - version: The version of :name does not satisfies the constraint :constraint + disabled: "The `:name` plugin is not enabled" + version: "The version of `:name` does not satisfies the constraint `:constraint`" disabled: :plugin has been disabled. deleted: The plugin was deleted successfully. no-config-notice: The plugin is not installed or doesn't provide configuration page. not-found: No such plugin. + market: + connection-error: Unable to connect to the plugins registry. :error + non-existent: The plugin :plugin does not exist. + download-failed: Unable to download the plugin. :error + shasum-failed: The downloaded file failed hash check, please retry. + unzip-failed: Unable to extract the plugin. :error + install-success: Plugin was installed. + empty: No result update: diff --git a/resources/lang/en/front-end.yml b/resources/lang/en/front-end.yml index 3e151009..0e68a971 100644 --- a/resources/lang/en/front-end.yml +++ b/resources/lang/en/front-end.yml @@ -271,6 +271,17 @@ admin: whyDependencies: What's this? statusEnabled: Enabled statusDisabled: Disabled + pluginTitle: Plugin + pluginAuthor: Author + pluginVersion: Version + pluginName: Name + pluginDescription: Description + pluginDependencies: Dependencies + installPlugin: Install + pluginInstalling: Installing... + updatePlugin: Update + pluginUpdating: Updating... + confirmUpdate: Are you sure to update ":plugin" from :old to :new? enablePlugin: Enable disablePlugin: Disable confirmDeletion: Are you sure to delete this plugin? diff --git a/resources/lang/zh_CN/admin.yml b/resources/lang/zh_CN/admin.yml index 21cab487..a1cf571f 100644 --- a/resources/lang/zh_CN/admin.yml +++ b/resources/lang/zh_CN/admin.yml @@ -112,13 +112,21 @@ plugins: enabled: :plugin 已启用 unsatisfied: notice: 无法启用此插件,因为其仍有未满足的依赖关系。请检查以下插件的版本,更新或安装它们: - disabled: :name 插件未启用 - version: :name 的版本不符合要求 :constraint + disabled: "`:name` 插件未启用" + version: "`:name` 的版本不符合要求 `:constraint`" disabled: :plugin 已禁用 deleted: 插件已被成功删除 no-config-notice: 插件未安装或未提供配置页面 not-found: 插件不存在 + market: + connection-error: 无法连接至插件市场源,错误信息::error + non-existent: 插件 :plugin 不存在 + download-failed: 插件下载失败,错误信息::error + shasum-failed: 文件校验失败,请尝试重新下载 + unzip-failed: 插件解压缩失败,错误信息::error + install-success: 插件安装成功 + empty: 无结果 update: diff --git a/resources/lang/zh_CN/front-end.yml b/resources/lang/zh_CN/front-end.yml index 97bf0ae8..b89d525c 100644 --- a/resources/lang/zh_CN/front-end.yml +++ b/resources/lang/zh_CN/front-end.yml @@ -267,6 +267,17 @@ admin: whyDependencies: 为什么会这样? statusEnabled: 已启用 statusDisabled: 已禁用 + pluginTitle: 插件 + pluginAuthor: 作者 + pluginVersion: 版本 + pluginName: 插件标识 + pluginDescription: 描述 + pluginDependencies: 依赖关系 + installPlugin: 安装 + pluginInstalling: 正在安装... + updatePlugin: 更新 + pluginUpdating: 正在更新... + confirmUpdate: 确定将「:plugin」从 :old 升级至 :new? enablePlugin: 启用插件 disablePlugin: 禁用插件 confirmDeletion: 真的要删除这个插件吗? diff --git a/resources/views/admin/market.blade.php b/resources/views/admin/market.blade.php new file mode 100644 index 00000000..333ef15a --- /dev/null +++ b/resources/views/admin/market.blade.php @@ -0,0 +1,20 @@ +@extends('admin.master') + +@section('title', trans('general.plugin-market')) + +@section('content') + + +
+ +
+

+ @lang('general.plugin-market') +

+
+ + +
+
+ +@endsection diff --git a/routes/web.php b/routes/web.php index d35c78b5..6c0e7f94 100644 --- a/routes/web.php +++ b/routes/web.php @@ -123,11 +123,15 @@ Route::group(['middleware' => ['auth', 'admin'], 'prefix' => 'admin'], function Route::group(['prefix' => 'plugins'], function () { Route::get ('/data', 'PluginController@getPluginData'); - Route::redirect('/market', 'https://github.com/printempw/blessing-skin-server/wiki/Plugins'); Route::view('/manage', 'admin.plugins'); Route::post('/manage', 'PluginController@manage'); Route::any ('/config/{name}', 'PluginController@config'); + + Route::view('/market', 'admin.market'); + Route::get ('/market-data', 'MarketController@marketData'); + Route::get ('/market/check', 'MarketController@checkUpdates'); + Route::post('/market/download', 'MarketController@download'); }); Route::group(['prefix' => 'update'], function () { diff --git a/tests/Concerns/GeneratesFakePlugins.php b/tests/Concerns/GeneratesFakePlugins.php new file mode 100644 index 00000000..886ecad2 --- /dev/null +++ b/tests/Concerns/GeneratesFakePlugins.php @@ -0,0 +1,143 @@ + str_random(10), + 'version' => '0.0.'.rand(1, 9), + 'title' => str_random(20), + 'description' => str_random(60), + 'author' => array_get($info, 'author', str_random(10)), + 'url' => 'https://'.str_random(10).'.test', + 'namespace' => str_random(10), + 'require' => [ + 'blessing-skin-server' => '^3.4.0' + ] + ], $info); + } + + /** + * Generate plugin information for plugins registry (with "dist" field). + * + * @param array $info + * @return array + */ + protected function generateFakePluginsRegistryPackage($info = []) + { + return $this->generateFakePlguinInfo(array_replace([ + 'dist' => [ + 'type' => 'zip', + 'url' => 'https://plugins-registry.test/'.str_random(10).'.zip', + 'shasum' => strtolower(str_random(40)) + ] + ], $info)); + } + + /** + * Generate fake content of a plugins registry. + * You can also pass two arguments (name and version) as a shortcut. + * If no argument is passed, we will randomly generate 10 fake plugins. + * + * @param array $plugins An array of plugin information. + * @return string JSON encoded content. + */ + protected function generateFakePluginsRegistry($plugins = []) + { + if (func_num_args() == 2) { + $plugins = [ + [ + 'name' => func_get_arg(0), + 'version' => func_get_arg(1) + ] + ]; + } + + $packages = []; + + if (count($plugins) == 0) { + // Randomly generate 10 fake plugins + for ($i = 0; $i < 10; $i++) { + $packages[] = $this->generateFakePluginsRegistryPackage(); + } + } else { + foreach ($plugins as $info) { + $packages[] = $this->generateFakePluginsRegistryPackage($info); + } + } + + return json_encode([ + 'packages' => $packages + ]); + } + + /** + * Generate a fake plugin in plugins directory with given information. + * + * @param array $info The "name" field is required. + * @return void + */ + protected function generateFakePlugin($info) + { + $plugin_dir = base_path("plugins/{$info['name']}"); + + if (! is_dir($plugin_dir)) { + mkdir($plugin_dir); + } + + // Generate fake config view + if ($config = array_get($info, 'config')) { + $views_path = "$plugin_dir/views"; + + if (! is_dir($views_path)) { + mkdir($views_path); + } + + file_put_contents("$views_path/$config", str_random(64)); + } + + file_put_contents("$plugin_dir/package.json", json_encode( + $this->generateFakePlguinInfo($info) + )); + + file_put_contents("$plugin_dir/bootstrap.php", "open($zipPath, ZipArchive::CREATE); + $zip->addEmptyDir($name); + $zip->addFromString("$name/package.json", json_encode( + $this->generateFakePlguinInfo($info) + )); + $zip->close(); + + return $zipPath; + } +} diff --git a/tests/MarketControllerTest.php b/tests/MarketControllerTest.php new file mode 100644 index 00000000..e4519014 --- /dev/null +++ b/tests/MarketControllerTest.php @@ -0,0 +1,195 @@ +actAs('superAdmin'); + } + + public function testDownload() + { + $this->setupGuzzleClientMock(); + + // Try to download a non-existent plugin + $this->appendToGuzzleQueue(200, [], $this->generateFakePluginsRegistry()); + $this->postJson('/admin/plugins/market/download', [ + 'name' => 'non-existent-plugin' + ])->assertJson([ + 'errno' => 1, + 'msg' => trans('admin.plugins.market.non-existent', ['plugin' => 'non-existent-plugin']) + ]); + + // Can't download due to connection error + $this->appendToGuzzleQueue([ + new Response(200, [], $this->generateFakePluginsRegistry('fake-test-download', '0.0.1')), + new RequestException('Connection Error', new Request('GET', 'whatever')), + ]); + $this->postJson('/admin/plugins/market/download', [ + 'name' => 'fake-test-download' + ])->assertJson([ + 'errno' => 2, + 'msg' => trans('admin.plugins.market.download-failed', ['error' => 'Connection Error']) + ]); + + // Downloaded plugin archive was tampered + $fakeArchive = $this->generateFakePluginArchive(['name' => 'fake-test-download', 'version' => '0.0.1']); + $this->appendToGuzzleQueue([ + new Response(200, [], $this->generateFakePluginsRegistry('fake-test-download', '0.0.1')), + new Response(200, [], fopen($fakeArchive, 'r')), + ]); + $this->postJson('/admin/plugins/market/download', [ + 'name' => 'fake-test-download' + ])->assertJson([ + 'errno' => 3, + 'msg' => trans('admin.plugins.market.shasum-failed') + ]); + + // Download and extract plugin + $shasum = sha1_file($fakeArchive); + $this->appendToGuzzleQueue([ + new Response(200, [], $this->generateFakePluginsRegistry([ + [ + 'name' => 'fake-test-download', + 'version' => '0.0.1', + 'dist' => [ + 'url' => 'whatever', + 'shasum' => $shasum + ] + ] + ])), + new Response(200, [], fopen($fakeArchive, 'r')), + ]); + $this->postJson('/admin/plugins/market/download', [ + 'name' => 'fake-test-download' + ])->assertJson([ + 'errno' => 0, + 'msg' => trans('admin.plugins.market.install-success') + ]); + $this->assertTrue(is_dir(base_path('plugins/fake-test-download'))); + $this->assertTrue(empty(glob(base_path('plugins/fake-test-download_*.zip')))); + + // Broken archive + file_put_contents($fakeArchive, 'broken'); + $shasum = sha1_file($fakeArchive); + $this->appendToGuzzleQueue([ + new Response(200, [], $this->generateFakePluginsRegistry([ + [ + 'name' => 'fake-test-download', + 'version' => '0.0.1', + 'dist' => [ + 'url' => 'whatever', + 'shasum' => $shasum + ] + ] + ])), + new Response(200, [], fopen($fakeArchive, 'r')), + ]); + $this->postJson('/admin/plugins/market/download', [ + 'name' => 'fake-test-download' + ])->assertJson([ + 'errno' => 4, + 'msg' => trans('admin.plugins.market.unzip-failed', ['error' => 19]) + ]); + } + + public function testCheckUpdates() + { + $this->setupGuzzleClientMock(); + + // Not installed + $this->appendToGuzzleQueue(200, [], $this->generateFakePluginsRegistry('fake-test-update', '0.0.1')); + $this->getJson('/admin/plugins/market/check') + ->assertJson([ + 'available' => false, + 'plugins' => [] + ]); + + // Generate fake plugin and refresh plugin manager + $this->generateFakePlugin(['name' => 'fake-test-update', 'version' => '0.0.1']); + $this->app->singleton('plugins', \App\Services\PluginManager::class); + + // Plugin up-to-date + $this->appendToGuzzleQueue(200, [], $this->generateFakePluginsRegistry('fake-test-update', '0.0.1')); + $this->getJson('/admin/plugins/market/check') + ->assertJson([ + 'available' => false, + 'plugins' => [] + ]); + + // New version available + $this->appendToGuzzleQueue(200, [], $this->generateFakePluginsRegistry('fake-test-update', '2.3.3')); + $this->getJson('/admin/plugins/market/check') + ->assertJson([ + 'available' => true, + 'plugins' => [[ + 'name' => 'fake-test-update' + ]] + ]); + } + + public function testMarketData() + { + $this->setupGuzzleClientMock([ + new RequestException('Connection Error', new Request('POST', 'whatever')), + new Response(200, [], $this->generateFakePluginsRegistry()), + ]); + + // Expected an exception, but unable to be asserted. + $this->getJson('/admin/plugins/market-data'); + + $this->getJson('/admin/plugins/market-data') + ->assertJsonStructure([ + [ + 'name', + 'title', + 'version', + 'installed', + 'description', + 'author', + 'dist', + 'dependencies' + ] + ]); + + // Detect installed plugins + $this->appendToGuzzleQueue(200, [], $this->generateFakePluginsRegistry('fake', '0.0.1')); + $this->generateFakePlugin(['name' => 'fake', 'version' => '0.0.1']); + $this->getJson('/admin/plugins/market-data') + ->assertJsonStructure([ + [ + 'name', + 'title', + 'version', + 'installed', + 'description', + 'author', + 'dist', + 'dependencies' + ] + ]); + } + + protected function tearDown() + { + // Clean fake plugins + File::deleteDirectory(base_path('plugins/fake-test-download')); + File::deleteDirectory(base_path('plugins/fake-test-update')); + File::deleteDirectory(base_path('plugins/fake')); + + parent::tearDown(); + } +}