diff --git a/packages/shorebird_cli/lib/src/commands/collaborators/add_collaborators_command.dart b/packages/shorebird_cli/lib/src/commands/collaborators/add_collaborators_command.dart index 36c842e7..0271d2cd 100644 --- a/packages/shorebird_cli/lib/src/commands/collaborators/add_collaborators_command.dart +++ b/packages/shorebird_cli/lib/src/commands/collaborators/add_collaborators_command.dart @@ -81,7 +81,7 @@ ${styleBold.wrap(lightGreen.wrap('šŸš€ Ready to add a new collaborator!'))} final progress = logger.progress('Adding collaborator'); try { - await client.createAppCollaborator(appId: appId, email: collaborator); + await client.createCollaborator(appId: appId, email: collaborator); progress.complete(); } catch (error) { progress.fail(); diff --git a/packages/shorebird_cli/lib/src/commands/collaborators/collaborators.dart b/packages/shorebird_cli/lib/src/commands/collaborators/collaborators.dart index 0a64e73d..72fefd4c 100644 --- a/packages/shorebird_cli/lib/src/commands/collaborators/collaborators.dart +++ b/packages/shorebird_cli/lib/src/commands/collaborators/collaborators.dart @@ -1,3 +1,4 @@ export 'add_collaborators_command.dart'; export 'collaborators_command.dart'; +export 'delete_collaborators_command.dart'; export 'list_collaborators_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/collaborators/collaborators_command.dart b/packages/shorebird_cli/lib/src/commands/collaborators/collaborators_command.dart index ef3bead1..189b3c65 100644 --- a/packages/shorebird_cli/lib/src/commands/collaborators/collaborators_command.dart +++ b/packages/shorebird_cli/lib/src/commands/collaborators/collaborators_command.dart @@ -9,6 +9,7 @@ class CollaboratorsCommand extends ShorebirdCommand { /// {@macro collaborators_command} CollaboratorsCommand({required super.logger}) { addSubcommand(AddCollaboratorsCommand(logger: logger)); + addSubcommand(DeleteCollaboratorsCommand(logger: logger)); addSubcommand(ListCollaboratorsCommand(logger: logger)); } diff --git a/packages/shorebird_cli/lib/src/commands/collaborators/delete_collaborators_command.dart b/packages/shorebird_cli/lib/src/commands/collaborators/delete_collaborators_command.dart new file mode 100644 index 00000000..b4a436be --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/collaborators/delete_collaborators_command.dart @@ -0,0 +1,126 @@ +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_collaborators_command} +/// `shorebird collaborators delete` +/// Delete an existing collaborator from a Shorebird app. +/// {@endtemplate} +class DeleteCollaboratorsCommand extends ShorebirdCommand + with AuthLoggerMixin, ShorebirdConfigMixin { + /// {@macro delete_collaborators_command} + DeleteCollaboratorsCommand({ + required super.logger, + super.buildCodePushClient, + super.auth, + }) { + argParser + ..addOption( + _appIdOption, + help: 'The app id that contains the collaborator to be deleted.', + ) + ..addOption( + _collaboratorEmailOption, + help: 'The email of the collaborator to delete.', + ); + } + + static const String _appIdOption = 'app-id'; + static const String _collaboratorEmailOption = 'email'; + + @override + String get description => + 'Delete an existing collaborator from 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 email = results[_collaboratorEmailOption] as String? ?? + logger.prompt( + '''${lightGreen.wrap('?')} What is the email of the collaborator you would like to delete?''', + ); + + final getCollaboratorsProgress = logger.progress('Fetching collaborators'); + final List collaborators; + try { + collaborators = await client.getCollaborators(appId: appId); + getCollaboratorsProgress.complete(); + } catch (error) { + getCollaboratorsProgress.fail(); + logger.err('$error'); + return ExitCode.software.code; + } + + final collaborator = collaborators.firstWhereOrNull( + (c) => c.email == email, + ); + if (collaborator == null) { + logger.err( + ''' +Could not find a collaborator with the email "$email". +Available collaborators: +${collaborators.map((c) => ' - ${c.email}').join('\n')}''', + ); + return ExitCode.software.code; + } + + logger.info( + ''' +${styleBold.wrap(lightGreen.wrap('šŸ—‘ļø Ready to delete an existing collaborator!'))} +šŸ“± App ID: ${lightCyan.wrap(appId)} +šŸ¤ Collaborator: ${lightCyan.wrap(collaborator.email)} +''', + ); + + final confirm = logger.confirm('Would you like to continue?'); + + if (!confirm) { + logger.info('Aborted.'); + return ExitCode.success.code; + } + + final progress = logger.progress('Deleting collaborator'); + try { + await client.deleteCollaborator( + appId: appId, + userId: collaborator.userId, + ); + progress.complete(); + } catch (error) { + progress.fail(); + logger.err('$error'); + return ExitCode.software.code; + } + + logger.success('\nāœ… Collaborator Deleted!'); + + return ExitCode.success.code; + } +} diff --git a/packages/shorebird_cli/test/src/commands/collaborators/add_collaborators_command_test.dart b/packages/shorebird_cli/test/src/commands/collaborators/add_collaborators_command_test.dart index 2bf07dfb..81e362b9 100644 --- a/packages/shorebird_cli/test/src/commands/collaborators/add_collaborators_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/collaborators/add_collaborators_command_test.dart @@ -96,7 +96,7 @@ void main() { 'when adding a collaborator fails', () async { const error = 'oops something went wrong'; when( - () => codePushClient.createAppCollaborator( + () => codePushClient.createCollaborator( appId: any(named: 'appId'), email: any(named: 'email'), ), @@ -109,7 +109,7 @@ void main() { when(() => argResults['email']).thenReturn(null); when(() => logger.prompt(any())).thenReturn(email); when( - () => codePushClient.createAppCollaborator( + () => codePushClient.createCollaborator( appId: any(named: 'appId'), email: any(named: 'email'), ), @@ -121,13 +121,13 @@ void main() { ), ).called(1); verify( - () => codePushClient.createAppCollaborator(appId: appId, email: email), + () => codePushClient.createCollaborator(appId: appId, email: email), ).called(1); }); test('returns ExitCode.success on success', () async { when( - () => codePushClient.createAppCollaborator( + () => codePushClient.createCollaborator( appId: any(named: 'appId'), email: any(named: 'email'), ), diff --git a/packages/shorebird_cli/test/src/commands/collaborators/delete_collaborators_command_test.dart b/packages/shorebird_cli/test/src/commands/collaborators/delete_collaborators_command_test.dart new file mode 100644 index 00000000..cd511fd3 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/collaborators/delete_collaborators_command_test.dart @@ -0,0 +1,176 @@ +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 email = 'jane.doe@shorebird.dev'; + const collaborator = Collaborator(userId: 0, email: email); + + late ArgResults argResults; + late http.Client httpClient; + late Auth auth; + late CodePushClient codePushClient; + late Logger logger; + late Progress progress; + late DeleteCollaboratorsCommand command; + + setUp(() { + argResults = _MockArgResults(); + httpClient = _MockHttpClient(); + auth = _MockAuth(); + codePushClient = _MockCodePushClient(); + logger = _MockLogger(); + progress = _MockProgress(); + command = DeleteCollaboratorsCommand( + auth: auth, + buildCodePushClient: ({ + required http.Client httpClient, + Uri? hostedUri, + }) { + return codePushClient; + }, + logger: logger, + )..testArgResults = argResults; + + when(() => argResults['app-id']).thenReturn(appId); + when(() => argResults['email']).thenReturn(email); + when(() => auth.isAuthenticated).thenReturn(true); + when(() => auth.client).thenReturn(httpClient); + when(() => logger.confirm(any())).thenReturn(true); + when(() => logger.progress(any())).thenReturn(progress); + when( + () => codePushClient.getCollaborators(appId: any(named: 'appId')), + ).thenAnswer((_) async => [collaborator]); + when( + () => codePushClient.deleteCollaborator( + appId: any(named: 'appId'), + userId: any(named: 'userId'), + ), + ).thenAnswer((_) async {}); + }); + + test('description is correct', () { + expect( + command.description, + equals('Delete an existing collaborator from 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.deleteCollaborator( + appId: any(named: 'appId'), + userId: any(named: 'userId'), + ), + ); + verify(() => logger.info('Aborted.')).called(1); + }); + + test( + 'returns ExitCode.software ' + 'when fetching collaborators fails', () async { + const error = 'oops something went wrong'; + when( + () => codePushClient.getCollaborators(appId: any(named: 'appId')), + ).thenThrow(error); + expect(await command.run(), ExitCode.software.code); + verify(() => logger.err(error)).called(1); + }); + + test('returns ExitCode.software when collaborator does not exist', + () async { + when( + () => codePushClient.getCollaborators(appId: any(named: 'appId')), + ).thenAnswer((_) async => []); + expect(await command.run(), ExitCode.software.code); + verify( + () => logger.err( + any( + that: contains( + 'Could not find a collaborator with the email "$email".', + ), + ), + ), + ).called(1); + }); + + test( + 'returns ExitCode.software ' + 'when deleting a collaborator fails', () async { + const error = 'oops something went wrong'; + when( + () => codePushClient.deleteCollaborator( + appId: any(named: 'appId'), + userId: any(named: 'userId'), + ), + ).thenThrow(error); + expect(await command.run(), ExitCode.software.code); + verify(() => logger.err(error)).called(1); + }); + + test('prompts for email when not provided', () async { + when(() => argResults['email']).thenReturn(null); + when(() => logger.prompt(any())).thenReturn(email); + when( + () => codePushClient.deleteCollaborator( + appId: any(named: 'appId'), + userId: any(named: 'userId'), + ), + ).thenAnswer((_) async => collaborator); + expect(await command.run(), ExitCode.success.code); + verify( + () => logger.prompt( + '''${lightGreen.wrap('?')} What is the email of the collaborator you would like to delete?''', + ), + ).called(1); + verify( + () => codePushClient.deleteCollaborator( + appId: appId, + userId: collaborator.userId, + ), + ).called(1); + }); + + test('returns ExitCode.success on success', () async { + when( + () => codePushClient.deleteCollaborator( + appId: any(named: 'appId'), + userId: any(named: 'userId'), + ), + ).thenAnswer((_) async {}); + expect(await command.run(), ExitCode.success.code); + verify(() => logger.success('\nāœ… Collaborator Deleted!')).called(1); + }); + }); +} diff --git a/packages/shorebird_code_push_client/lib/src/code_push_client.dart b/packages/shorebird_code_push_client/lib/src/code_push_client.dart index 2bbb664f..089dc0f1 100644 --- a/packages/shorebird_code_push_client/lib/src/code_push_client.dart +++ b/packages/shorebird_code_push_client/lib/src/code_push_client.dart @@ -44,7 +44,7 @@ class CodePushClient { /// Add a new collaborator to the app. /// Collaborators can manage the app including its releases and patches. - Future createAppCollaborator({ + Future createCollaborator({ required String appId, required String email, }) async { @@ -216,7 +216,7 @@ class CodePushClient { } /// Remove [userId] as a collaborator from [appId]. - Future deleteAppCollaborator({ + Future deleteCollaborator({ required String appId, required int userId, }) async { diff --git a/packages/shorebird_code_push_client/test/src/code_push_client_test.dart b/packages/shorebird_code_push_client/test/src/code_push_client_test.dart index 3a92f0b6..c7a23859 100644 --- a/packages/shorebird_code_push_client/test/src/code_push_client_test.dart +++ b/packages/shorebird_code_push_client/test/src/code_push_client_test.dart @@ -53,7 +53,7 @@ void main() { }); }); - group('createAppCollaborator', () { + group('createCollaborator', () { const appId = 'test-app-id'; const email = 'jane.doe@shorebird.dev'; @@ -69,7 +69,7 @@ void main() { ); expect( - codePushClient.createAppCollaborator(appId: appId, email: email), + codePushClient.createCollaborator(appId: appId, email: email), throwsA( isA().having( (e) => e.message, @@ -95,7 +95,7 @@ void main() { ); expect( - codePushClient.createAppCollaborator(appId: appId, email: email), + codePushClient.createCollaborator(appId: appId, email: email), throwsA( isA().having( (e) => e.message, @@ -115,7 +115,7 @@ void main() { ), ).thenAnswer((_) async => http.Response('', HttpStatus.created)); - await codePushClient.createAppCollaborator(appId: appId, email: email); + await codePushClient.createCollaborator(appId: appId, email: email); final uri = verify( () => httpClient.post( @@ -868,7 +868,7 @@ void main() { }); }); - group('deleteAppCollaborator', () { + group('deleteCollaborator', () { const appId = 'test-app-id'; const userId = 42; @@ -880,7 +880,7 @@ void main() { ); expect( - codePushClient.deleteAppCollaborator(appId: appId, userId: userId), + codePushClient.deleteCollaborator(appId: appId, userId: userId), throwsA( isA().having( (e) => e.message, @@ -902,7 +902,7 @@ void main() { ); expect( - codePushClient.deleteAppCollaborator(appId: appId, userId: userId), + codePushClient.deleteCollaborator(appId: appId, userId: userId), throwsA( isA().having( (e) => e.message, @@ -921,7 +921,7 @@ void main() { ), ).thenAnswer((_) async => http.Response('', HttpStatus.noContent)); - await codePushClient.deleteAppCollaborator( + await codePushClient.deleteCollaborator( appId: appId, userId: userId, );