From 78cd61a686c6c1ba484f1da8687f4e1bf3c06859 Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Mon, 15 May 2023 14:34:08 -0500 Subject: [PATCH] feat(shorebird_cli): `shorebird channels delete` (#496) --- .../lib/src/commands/channels/channels.dart | 1 + .../commands/channels/channels_command.dart | 1 + .../channels/delete_channels_command.dart | 125 ++++++++++++++ .../delete_channels_command_test.dart | 155 ++++++++++++++++++ 4 files changed, 282 insertions(+) create mode 100644 packages/shorebird_cli/lib/src/commands/channels/delete_channels_command.dart create mode 100644 packages/shorebird_cli/test/src/commands/channels/delete_channels_command_test.dart diff --git a/packages/shorebird_cli/lib/src/commands/channels/channels.dart b/packages/shorebird_cli/lib/src/commands/channels/channels.dart index 86e60788..8eff1c14 100644 --- a/packages/shorebird_cli/lib/src/commands/channels/channels.dart +++ b/packages/shorebird_cli/lib/src/commands/channels/channels.dart @@ -1,3 +1,4 @@ export 'channels_command.dart'; export 'create_channels_command.dart'; +export 'delete_channels_command.dart'; export 'list_channels_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/channels/channels_command.dart b/packages/shorebird_cli/lib/src/commands/channels/channels_command.dart index 7d1fa087..a87b28a8 100644 --- a/packages/shorebird_cli/lib/src/commands/channels/channels_command.dart +++ b/packages/shorebird_cli/lib/src/commands/channels/channels_command.dart @@ -9,6 +9,7 @@ class ChannelsCommand extends ShorebirdCommand { /// {@macro channels_command} ChannelsCommand({required super.logger}) { addSubcommand(CreateChannelsCommand(logger: logger)); + addSubcommand(DeleteChannelsCommand(logger: logger)); addSubcommand(ListChannelsCommand(logger: logger)); } diff --git a/packages/shorebird_cli/lib/src/commands/channels/delete_channels_command.dart b/packages/shorebird_cli/lib/src/commands/channels/delete_channels_command.dart new file mode 100644 index 00000000..8ba52197 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/channels/delete_channels_command.dart @@ -0,0 +1,125 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:shorebird_cli/src/auth_logger_mixin.dart'; +import 'package:shorebird_cli/src/command.dart'; +import 'package:shorebird_cli/src/shorebird_config_mixin.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; + +/// {@template delete_channels_command} +/// `shorebird channels delete` +/// Delete an existing channel for a Shorebird app. +/// {@endtemplate} +class DeleteChannelsCommand extends ShorebirdCommand + with AuthLoggerMixin, ShorebirdConfigMixin { + /// {@macro delete_channels_command} + DeleteChannelsCommand({ + required super.logger, + super.buildCodePushClient, + super.auth, + }) { + argParser + ..addOption( + _appIdOption, + help: 'The app id that contains the channel to be deleted.', + ) + ..addOption( + _channelNameOption, + help: 'The name of the channel to delete.', + ); + } + + static const String _appIdOption = 'app-id'; + static const String _channelNameOption = 'name'; + + @override + String get description => 'Delete an existing channel for a Shorebird app.'; + + @override + String get name => 'delete'; + + @override + Future? run() async { + if (!auth.isAuthenticated) { + printNeedsAuthInstructions(); + return ExitCode.noUser.code; + } + + final client = buildCodePushClient( + httpClient: auth.client, + hostedUri: hostedUri, + ); + + final appId = results[_appIdOption] as String? ?? getShorebirdYaml()?.appId; + if (appId == null) { + logger.err( + ''' +Could not find an app id. + +You must either specify an app id via the "--$_appIdOption" flag or run this command from within a directory with a valid "shorebird.yaml" file.''', + ); + return ExitCode.usage.code; + } + + final channelName = results[_channelNameOption] as String? ?? + logger.prompt( + '''${lightGreen.wrap('?')} What is the name of the channel you would like to delete?''', + ); + ; + + final getChannelsProgress = logger.progress('Fetching channels'); + final List channels; + try { + channels = await client.getChannels(appId: appId); + getChannelsProgress.complete(); + } catch (error) { + getChannelsProgress.fail(); + logger.err('$error'); + return ExitCode.software.code; + } + + final channel = channels.firstWhereOrNull((c) => c.name == channelName); + if (channel == null) { + logger.err( + ''' +Could not find a channel with the name "$channelName". + +Available channels: +${channels.map((c) => ' - ${c.name}').join('\n')}''', + ); + return ExitCode.software.code; + } + + logger.info( + ''' + +${styleBold.wrap(lightGreen.wrap('šŸ—‘ļø Ready to delete an existing channel!'))} + +šŸ“± App ID: ${lightCyan.wrap(appId)} +šŸ“ŗ Channel: ${lightCyan.wrap(channel.name)} +''', + ); + + final confirm = logger.confirm('Would you like to continue?'); + + if (!confirm) { + logger.info('Aborted.'); + return ExitCode.success.code; + } + + final progress = logger.progress('Deleting channel'); + try { + await client.deleteChannel(channelId: channel.id); + progress.complete(); + } catch (error) { + progress.fail(); + logger.err('$error'); + return ExitCode.software.code; + } + + logger.success('\nāœ… Channel Deleted!'); + + return ExitCode.success.code; + } +} diff --git a/packages/shorebird_cli/test/src/commands/channels/delete_channels_command_test.dart b/packages/shorebird_cli/test/src/commands/channels/delete_channels_command_test.dart new file mode 100644 index 00000000..7e384e8e --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/channels/delete_channels_command_test.dart @@ -0,0 +1,155 @@ +import 'package:args/args.dart'; +import 'package:http/http.dart' as http; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shorebird_cli/src/auth/auth.dart'; +import 'package:shorebird_cli/src/commands/commands.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; +import 'package:test/test.dart'; + +class _MockArgResults extends Mock implements ArgResults {} + +class _MockHttpClient extends Mock implements http.Client {} + +class _MockAuth extends Mock implements Auth {} + +class _MockCodePushClient extends Mock implements CodePushClient {} + +class _MockLogger extends Mock implements Logger {} + +class _MockProgress extends Mock implements Progress {} + +void main() { + group('delete', () { + const appId = 'test-app-id'; + const channelName = 'my-channel'; + const channel = Channel(id: 0, appId: appId, name: channelName); + + late ArgResults argResults; + late http.Client httpClient; + late Auth auth; + late CodePushClient codePushClient; + late Logger logger; + late Progress progress; + late DeleteChannelsCommand command; + + setUp(() { + argResults = _MockArgResults(); + httpClient = _MockHttpClient(); + auth = _MockAuth(); + codePushClient = _MockCodePushClient(); + logger = _MockLogger(); + progress = _MockProgress(); + command = DeleteChannelsCommand( + auth: auth, + buildCodePushClient: ({ + required http.Client httpClient, + Uri? hostedUri, + }) { + return codePushClient; + }, + logger: logger, + )..testArgResults = argResults; + + when(() => argResults['app-id']).thenReturn(appId); + when(() => argResults['name']).thenReturn(channelName); + when(() => auth.isAuthenticated).thenReturn(true); + when(() => auth.client).thenReturn(httpClient); + when(() => logger.confirm(any())).thenReturn(true); + when(() => logger.progress(any())).thenReturn(progress); + when( + () => codePushClient.getChannels(appId: any(named: 'appId')), + ).thenAnswer((_) async => [channel]); + when( + () => codePushClient.deleteChannel(channelId: any(named: 'channelId')), + ).thenAnswer((_) async {}); + }); + + test('description is correct', () { + expect( + command.description, + equals('Delete an existing channel for a Shorebird app.'), + ); + }); + + test('returns ExitCode.noUser when not logged in', () async { + when(() => auth.isAuthenticated).thenReturn(false); + expect(await command.run(), ExitCode.noUser.code); + }); + + test('returns ExitCode.usage when app id is missing.', () async { + when(() => argResults['app-id']).thenReturn(null); + expect(await command.run(), ExitCode.usage.code); + }); + + test('returns ExitCode.success when user aborts', () async { + when(() => logger.confirm(any())).thenReturn(false); + expect(await command.run(), ExitCode.success.code); + verifyNever( + () => codePushClient.deleteChannel( + channelId: any(named: 'channelId'), + ), + ); + verify(() => logger.info('Aborted.')).called(1); + }); + + test('returns ExitCode.software when fetching channels fails', () async { + const error = 'oops something went wrong'; + when( + () => codePushClient.getChannels(appId: any(named: 'appId')), + ).thenThrow(error); + expect(await command.run(), ExitCode.software.code); + verify(() => logger.err(error)).called(1); + }); + + test('returns ExitCode.software when channel does not exist', () async { + when( + () => codePushClient.getChannels(appId: any(named: 'appId')), + ).thenAnswer((_) async => []); + expect(await command.run(), ExitCode.software.code); + verify( + () => logger.err( + any( + that: contains( + 'Could not find a channel with the name "$channelName".', + ), + ), + ), + ).called(1); + }); + + test('returns ExitCode.software when deleting a channel fails', () async { + const error = 'oops something went wrong'; + when( + () => codePushClient.deleteChannel(channelId: any(named: 'channelId')), + ).thenThrow(error); + expect(await command.run(), ExitCode.software.code); + verify(() => logger.err(error)).called(1); + }); + + test('prompts for channel name when not provided', () async { + when(() => argResults['name']).thenReturn(null); + when(() => logger.prompt(any())).thenReturn(channelName); + when( + () => codePushClient.deleteChannel(channelId: any(named: 'channelId')), + ).thenAnswer((_) async => channel); + expect(await command.run(), ExitCode.success.code); + verify( + () => logger.prompt( + '''${lightGreen.wrap('?')} What is the name of the channel you would like to delete?''', + ), + ).called(1); + verify( + () => codePushClient.deleteChannel(channelId: channel.id), + ).called(1); + }); + + test('returns ExitCode.success on success', () async { + when( + () => codePushClient.deleteChannel(channelId: any(named: 'channelId')), + ).thenAnswer((_) async => channel); + expect(await command.run(), ExitCode.success.code); + verify(() => logger.success('\nāœ… Channel Deleted!')).called(1); + }); + }); +}