diff --git a/cspell.config.yaml b/cspell.config.yaml index f4a4b5723..025fe33e1 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -116,7 +116,9 @@ words: - reimplementation - reinit - requirepass # From .github dir, doesn't show up in "**" check? + - resends # "server resends the same patch on rollforward" - reusables + - rollforward # POST /.../patches//rollforward — un-rolls-back a patch - spawnee - Retryable - RSAPKCS diff --git a/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart b/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart index 53af236ad..cf48c7f1d 100644 --- a/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart +++ b/packages/shorebird_cli/lib/src/code_push_client_wrapper.dart @@ -958,6 +958,56 @@ aar artifact already exists, continuing...'''); } } + /// Rolls back the patch identified by [patchId] under [releaseId]. + /// + /// [patchNumber] is used purely for the human-readable progress message; + /// pass it through when the caller already has it on hand. + Future rollbackPatch({ + required String appId, + required int releaseId, + required int patchId, + int? patchNumber, + }) async { + final label = patchNumber != null ? 'patch $patchNumber' : 'patch'; + final progress = logger.progress('Rolling back $label'); + try { + await codePushClient.rollbackPatch( + appId: appId, + releaseId: releaseId, + patchId: patchId, + ); + progress.complete(); + } catch (error) { + _handleErrorAndExit(error, progress: progress); + } + } + + /// Rolls forward (un-rolls-back) the patch identified by [patchId] under + /// [releaseId]. Returns the patch to its active state; the server resends + /// the same patch artifact to devices on the next patch check. + /// + /// [patchNumber] is used purely for the human-readable progress message; + /// pass it through when the caller already has it on hand. + Future rollforwardPatch({ + required String appId, + required int releaseId, + required int patchId, + int? patchNumber, + }) async { + final label = patchNumber != null ? 'patch $patchNumber' : 'patch'; + final progress = logger.progress('Rolling forward $label'); + try { + await codePushClient.rollforwardPatch( + appId: appId, + releaseId: releaseId, + patchId: patchId, + ); + progress.complete(); + } catch (error) { + _handleErrorAndExit(error, progress: progress); + } + } + /// Publishes a patch to the Shorebird server. This consists of creating a /// patch, uploading patch artifacts, and promoting the patch to a specific /// channel based on the provided [track]. diff --git a/packages/shorebird_cli/lib/src/commands/patches/patches.dart b/packages/shorebird_cli/lib/src/commands/patches/patches.dart index 3538da2ec..115f44afc 100644 --- a/packages/shorebird_cli/lib/src/commands/patches/patches.dart +++ b/packages/shorebird_cli/lib/src/commands/patches/patches.dart @@ -2,4 +2,6 @@ export 'patches_command.dart'; export 'patches_info_command.dart'; export 'patches_list_command.dart'; export 'promote_command.dart'; +export 'rollback_command.dart'; +export 'rollforward_command.dart'; export 'set_track_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/patches/patches_command.dart b/packages/shorebird_cli/lib/src/commands/patches/patches_command.dart index cef0108e7..6820f51e3 100644 --- a/packages/shorebird_cli/lib/src/commands/patches/patches_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patches/patches_command.dart @@ -10,6 +10,8 @@ class PatchesCommand extends ShorebirdCommand { addSubcommand(PatchesInfoCommand()); addSubcommand(PatchesListCommand()); addSubcommand(PromoteCommand()); + addSubcommand(RollbackCommand()); + addSubcommand(RollforwardCommand()); addSubcommand(SetTrackCommand()); } diff --git a/packages/shorebird_cli/lib/src/commands/patches/rollback_command.dart b/packages/shorebird_cli/lib/src/commands/patches/rollback_command.dart new file mode 100644 index 000000000..9cb8a11df --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/patches/rollback_command.dart @@ -0,0 +1,155 @@ +import 'package:collection/collection.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/common_arguments.dart'; +import 'package:shorebird_cli/src/json_output.dart'; +import 'package:shorebird_cli/src/logging/logging.dart'; +import 'package:shorebird_cli/src/shorebird_command.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/src/base/process.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; + +/// {@template rollback_command} +/// Rolls back a patch on a release. Devices on this release that next call +/// the patch-check endpoint will receive the patch number in +/// `rolled_back_patch_numbers`, prompting them to revert to the prior patch +/// (or the base release if no other patch is available on the channel). +/// +/// Sample usage: +/// ```sh +/// shorebird patches rollback --release-version=1.0.0+1 --patch-number=1 +/// ``` +/// {@endtemplate} +class RollbackCommand extends ShorebirdCommand { + /// {@macro rollback_command} + RollbackCommand() { + argParser + ..addOption( + CommonArguments.releaseVersionArg.name, + help: CommonArguments.patchReleaseVersionDescription, + mandatory: true, + ) + ..addOption( + 'patch-number', + help: 'The patch number to roll back (e.g. "1").', + mandatory: true, + ) + ..addOption( + CommonArguments.appIdArg.name, + help: CommonArguments.appIdArg.description, + ) + ..addOption( + CommonArguments.flavorArg.name, + help: 'The product flavor to use (e.g. "prod").', + ); + } + + @override + String get name => 'rollback'; + + @override + String get description => + 'Rolls back a patch on a release.\n\n' + 'Example output:\n' + ' Patch 1 on release 1.0.0+1 has been rolled back.\n\n' + '${ShorebirdCommand.jsonHint( + 'shorebird patches rollback --release-version 1.0.0+1 ' + '--patch-number 1 --app-id --json', + )}'; + + @override + Future run() async { + final (:appId, :errorCode) = await resolveAppId(); + if (errorCode != null) return errorCode; + + final releaseVersion = + results[CommonArguments.releaseVersionArg.name] as String; + final patchNumber = int.parse(results['patch-number'] as String); + + final Release release; + final List patches; + try { + release = await codePushClientWrapper.getRelease( + appId: appId, + releaseVersion: releaseVersion, + ); + patches = await codePushClientWrapper.getReleasePatches( + appId: appId, + releaseId: release.id, + ); + } on ProcessExit catch (e) { + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.fetchFailed, + message: 'Failed to fetch patches for release "$releaseVersion".', + ); + return e.exitCode; + } + rethrow; + } + + final patch = patches.firstWhereOrNull((p) => p.number == patchNumber); + if (patch == null) { + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.usageError, + message: + 'No patch found with number $patchNumber ' + 'for release "$releaseVersion".', + ); + return ExitCode.usage.code; + } + logger + ..err('No patch found with number $patchNumber') + ..info( + 'Available patches: ${patches.map((p) => p.number).join(', ')}', + ); + return ExitCode.usage.code; + } + + if (patch.isRolledBack) { + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.usageError, + message: 'Patch $patchNumber is already rolled back.', + ); + return ExitCode.usage.code; + } + logger.err('Patch $patchNumber is already rolled back'); + return ExitCode.usage.code; + } + + try { + await codePushClientWrapper.rollbackPatch( + appId: appId, + releaseId: release.id, + patchId: patch.id, + patchNumber: patch.number, + ); + } on ProcessExit catch (e) { + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.softwareError, + message: + 'Failed to roll back patch $patchNumber ' + 'of release "$releaseVersion".', + ); + return e.exitCode; + } + rethrow; + } + + if (isJsonMode) { + emitJsonSuccess({ + 'release_version': releaseVersion, + 'patch_number': patchNumber, + 'action': 'rollback', + }); + return ExitCode.success.code; + } + + logger.success( + 'Patch $patchNumber on release $releaseVersion has been rolled back.', + ); + return ExitCode.success.code; + } +} diff --git a/packages/shorebird_cli/lib/src/commands/patches/rollforward_command.dart b/packages/shorebird_cli/lib/src/commands/patches/rollforward_command.dart new file mode 100644 index 000000000..1f2142c44 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/patches/rollforward_command.dart @@ -0,0 +1,156 @@ +import 'package:collection/collection.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/common_arguments.dart'; +import 'package:shorebird_cli/src/json_output.dart'; +import 'package:shorebird_cli/src/logging/logging.dart'; +import 'package:shorebird_cli/src/shorebird_command.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/src/base/process.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; + +/// {@template rollforward_command} +/// Rolls forward (reactivates) a previously rolled-back patch on a release. +/// The server flips `is_rolled_back` from `true` to `false` on the same +/// patch row, so the same patch artifact (same hash, same number) becomes +/// active again. Devices on this release will pick the patch back up on +/// their next launch's auto-update cycle. +/// +/// Sample usage: +/// ```sh +/// shorebird patches rollforward --release-version=1.0.0+1 --patch-number=1 +/// ``` +/// {@endtemplate} +class RollforwardCommand extends ShorebirdCommand { + /// {@macro rollforward_command} + RollforwardCommand() { + argParser + ..addOption( + CommonArguments.releaseVersionArg.name, + help: CommonArguments.patchReleaseVersionDescription, + mandatory: true, + ) + ..addOption( + 'patch-number', + help: 'The patch number to roll forward (e.g. "1").', + mandatory: true, + ) + ..addOption( + CommonArguments.appIdArg.name, + help: CommonArguments.appIdArg.description, + ) + ..addOption( + CommonArguments.flavorArg.name, + help: 'The product flavor to use (e.g. "prod").', + ); + } + + @override + String get name => 'rollforward'; + + @override + String get description => + 'Rolls forward (reactivates) a previously rolled-back patch.\n\n' + 'Example output:\n' + ' Patch 1 on release 1.0.0+1 has been rolled forward.\n\n' + '${ShorebirdCommand.jsonHint( + 'shorebird patches rollforward --release-version 1.0.0+1 ' + '--patch-number 1 --app-id --json', + )}'; + + @override + Future run() async { + final (:appId, :errorCode) = await resolveAppId(); + if (errorCode != null) return errorCode; + + final releaseVersion = + results[CommonArguments.releaseVersionArg.name] as String; + final patchNumber = int.parse(results['patch-number'] as String); + + final Release release; + final List patches; + try { + release = await codePushClientWrapper.getRelease( + appId: appId, + releaseVersion: releaseVersion, + ); + patches = await codePushClientWrapper.getReleasePatches( + appId: appId, + releaseId: release.id, + ); + } on ProcessExit catch (e) { + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.fetchFailed, + message: 'Failed to fetch patches for release "$releaseVersion".', + ); + return e.exitCode; + } + rethrow; + } + + final patch = patches.firstWhereOrNull((p) => p.number == patchNumber); + if (patch == null) { + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.usageError, + message: + 'No patch found with number $patchNumber ' + 'for release "$releaseVersion".', + ); + return ExitCode.usage.code; + } + logger + ..err('No patch found with number $patchNumber') + ..info( + 'Available patches: ${patches.map((p) => p.number).join(', ')}', + ); + return ExitCode.usage.code; + } + + if (!patch.isRolledBack) { + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.usageError, + message: 'Patch $patchNumber is already active (not rolled back).', + ); + return ExitCode.usage.code; + } + logger.err('Patch $patchNumber is already active (not rolled back)'); + return ExitCode.usage.code; + } + + try { + await codePushClientWrapper.rollforwardPatch( + appId: appId, + releaseId: release.id, + patchId: patch.id, + patchNumber: patch.number, + ); + } on ProcessExit catch (e) { + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.softwareError, + message: + 'Failed to roll forward patch $patchNumber ' + 'of release "$releaseVersion".', + ); + return e.exitCode; + } + rethrow; + } + + if (isJsonMode) { + emitJsonSuccess({ + 'release_version': releaseVersion, + 'patch_number': patchNumber, + 'action': 'rollforward', + }); + return ExitCode.success.code; + } + + logger.success( + 'Patch $patchNumber on release $releaseVersion has been rolled forward.', + ); + return ExitCode.success.code; + } +} diff --git a/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart b/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart index 1802cbba0..4a18ebb97 100644 --- a/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart +++ b/packages/shorebird_cli/test/src/code_push_client_wrapper_test.dart @@ -2485,6 +2485,129 @@ You can manage this release in the ${link(uri: uri, message: 'Shorebird Console' }); }); + group('rollbackPatch', () { + const releaseId = 7; + const patchNumber = 1; + + test('exits with code 70 when rollback fails', () async { + const error = 'something went wrong'; + when( + () => codePushClient.rollbackPatch( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + patchId: any(named: 'patchId'), + ), + ).thenThrow(error); + + await expectLater( + () async => runWithOverrides( + () => codePushClientWrapper.rollbackPatch( + appId: appId, + releaseId: releaseId, + patchId: patchId, + patchNumber: patchNumber, + ), + ), + exitsWithCode(ExitCode.software), + ); + verify(() => progress.fail(error)).called(1); + }); + + test('completes progress when patch is rolled back', () async { + when( + () => codePushClient.rollbackPatch( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + patchId: any(named: 'patchId'), + ), + ).thenAnswer((_) async {}); + + await runWithOverrides( + () => codePushClientWrapper.rollbackPatch( + appId: appId, + releaseId: releaseId, + patchId: patchId, + patchNumber: patchNumber, + ), + ); + + verify(() => progress.complete()).called(1); + }); + + test( + 'omits patch number from progress label when not provided', + () async { + when( + () => codePushClient.rollbackPatch( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + patchId: any(named: 'patchId'), + ), + ).thenAnswer((_) async {}); + + await runWithOverrides( + () => codePushClientWrapper.rollbackPatch( + appId: appId, + releaseId: releaseId, + patchId: patchId, + ), + ); + + verify(() => logger.progress('Rolling back patch')).called(1); + }, + ); + }); + + group('rollforwardPatch', () { + const releaseId = 7; + const patchNumber = 1; + + test('exits with code 70 when rollforward fails', () async { + const error = 'something went wrong'; + when( + () => codePushClient.rollforwardPatch( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + patchId: any(named: 'patchId'), + ), + ).thenThrow(error); + + await expectLater( + () async => runWithOverrides( + () => codePushClientWrapper.rollforwardPatch( + appId: appId, + releaseId: releaseId, + patchId: patchId, + patchNumber: patchNumber, + ), + ), + exitsWithCode(ExitCode.software), + ); + verify(() => progress.fail(error)).called(1); + }); + + test('completes progress when patch is rolled forward', () async { + when( + () => codePushClient.rollforwardPatch( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + patchId: any(named: 'patchId'), + ), + ).thenAnswer((_) async {}); + + await runWithOverrides( + () => codePushClientWrapper.rollforwardPatch( + appId: appId, + releaseId: releaseId, + patchId: patchId, + patchNumber: patchNumber, + ), + ); + + verify(() => progress.complete()).called(1); + }); + }); + group('createPatchArtifacts', () { test('exits with code 70 when creating patch artifact fails', () async { const error = 'something went wrong'; diff --git a/packages/shorebird_cli/test/src/commands/patches/rollback_command_test.dart b/packages/shorebird_cli/test/src/commands/patches/rollback_command_test.dart new file mode 100644 index 000000000..ff61855e8 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/patches/rollback_command_test.dart @@ -0,0 +1,439 @@ +import 'dart:convert'; + +import 'package:args/args.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/commands/patches/rollback_command.dart'; +import 'package:shorebird_cli/src/config/config.dart'; +import 'package:shorebird_cli/src/json_output.dart'; +import 'package:shorebird_cli/src/logging/shorebird_logger.dart'; +import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/shorebird_validator.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/src/base/process.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; +import 'package:test/test.dart'; + +import '../../helpers.dart'; +import '../../mocks.dart'; + +void main() { + group(RollbackCommand, () { + const appId = 'app-id'; + const releaseVersion = '1.0.0'; + const patchNumber = 1; + const shorebirdYaml = ShorebirdYaml(appId: appId); + final release = Release( + id: 0, + appId: appId, + version: releaseVersion, + flutterRevision: 'flutter-revision', + flutterVersion: 'flutter-version', + displayName: releaseVersion, + platformStatuses: const {ReleasePlatform.android: ReleaseStatus.active}, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + const activePatch = ReleasePatch( + id: 0, + number: patchNumber, + channel: 'stable', + isRolledBack: false, + artifacts: [], + ); + const rolledBackPatch = ReleasePatch( + id: 0, + number: patchNumber, + channel: 'stable', + isRolledBack: true, + artifacts: [], + ); + + late ArgResults argResults; + late CodePushClientWrapper codePushClientWrapper; + late ShorebirdEnv shorebirdEnv; + late ShorebirdValidator shorebirdValidator; + late ShorebirdLogger logger; + late RollbackCommand command; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + isJsonModeRef.overrideWith(() => false), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ); + } + + setUp(() { + argResults = MockArgResults(); + codePushClientWrapper = MockCodePushClientWrapper(); + logger = MockShorebirdLogger(); + shorebirdEnv = MockShorebirdEnv(); + shorebirdValidator = MockShorebirdValidator(); + + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults.rest).thenReturn([]); + when(() => argResults['app-id']).thenReturn(null); + when(() => argResults['flavor']).thenReturn(null); + when(() => argResults['release-version']).thenReturn(releaseVersion); + when( + () => argResults['patch-number'], + ).thenReturn(patchNumber.toString()); + + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), + ), + ).thenAnswer((_) async => {}); + when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml); + + when( + () => codePushClientWrapper.getRelease( + appId: any(named: 'appId'), + releaseVersion: any(named: 'releaseVersion'), + ), + ).thenAnswer((_) async => release); + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer((_) async => [activePatch]); + when( + () => codePushClientWrapper.rollbackPatch( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + patchId: any(named: 'patchId'), + patchNumber: any(named: 'patchNumber'), + ), + ).thenAnswer((_) async {}); + + command = runWithOverrides(RollbackCommand.new) + ..testArgResults = argResults; + }); + + test('name is correct', () { + expect(command.name, 'rollback'); + }); + + test('has correct description', () { + expect( + command.description, + startsWith('Rolls back a patch on a release'), + ); + }); + + group('when validation fails', () { + final exception = ShorebirdNotInitializedException(); + + setUp(() { + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), + ), + ).thenThrow(exception); + }); + + test('exits with exit code from validation error', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(exception.exitCode.code)); + }); + }); + + group('when --app-id is provided', () { + setUp(() { + when(() => argResults['app-id']).thenReturn('explicit-app-id'); + }); + + test('does not require shorebird.yaml', () async { + await runWithOverrides(command.run); + verify( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + ), + ).called(1); + }); + + test('uses the explicit app id', () async { + await runWithOverrides(command.run); + verify( + () => codePushClientWrapper.getRelease( + appId: 'explicit-app-id', + releaseVersion: releaseVersion, + ), + ).called(1); + }); + }); + + group('when no matching patch is found', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer( + (_) async => [ + const ReleasePatch( + id: 99, + number: patchNumber + 1, + channel: 'stable', + isRolledBack: false, + artifacts: [], + ), + ], + ); + }); + + test('exits with usage error', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.usage.code)); + verify( + () => logger.err('No patch found with number $patchNumber'), + ).called(1); + }); + }); + + group('when patch is already rolled back', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer((_) async => [rolledBackPatch]); + }); + + test('exits with usage error and does not POST', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.usage.code)); + verify( + () => logger.err('Patch $patchNumber is already rolled back'), + ).called(1); + verifyNever( + () => codePushClientWrapper.rollbackPatch( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + patchId: any(named: 'patchId'), + patchNumber: any(named: 'patchNumber'), + ), + ); + }); + }); + + group('when patch is rolled back successfully', () { + test('calls rollbackPatch and logs success', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + verify( + () => codePushClientWrapper.rollbackPatch( + appId: appId, + releaseId: release.id, + patchId: activePatch.id, + patchNumber: patchNumber, + ), + ).called(1); + verify( + () => logger.success( + 'Patch $patchNumber on release $releaseVersion ' + 'has been rolled back.', + ), + ).called(1); + }); + }); + + group('when fetching patches fails', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenThrow(ProcessExit(ExitCode.software.code)); + }); + + test('in human-readable mode, rethrows ProcessExit', () async { + await expectLater( + () => runWithOverrides(command.run), + throwsA(isA()), + ); + }); + + test('in --json mode, emits fetch_failed envelope', () async { + final captured = []; + final result = await captureStdout( + () => runScoped( + command.run, + values: { + codePushClientWrapperRef.overrideWith( + () => codePushClientWrapper, + ), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ), + captured: captured, + ); + expect(result, equals(ExitCode.software.code)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + expect( + (decoded['error'] as Map)['code'], + 'fetch_failed', + ); + }); + }); + + group('when rollbackPatch itself fails', () { + setUp(() { + when( + () => codePushClientWrapper.rollbackPatch( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + patchId: any(named: 'patchId'), + patchNumber: any(named: 'patchNumber'), + ), + ).thenThrow(ProcessExit(ExitCode.software.code)); + }); + + test('in human-readable mode, rethrows ProcessExit', () async { + await expectLater( + () => runWithOverrides(command.run), + throwsA(isA()), + ); + }); + + test('in --json mode, emits software_error envelope', () async { + final captured = []; + final result = await captureStdout( + () => runScoped( + command.run, + values: { + codePushClientWrapperRef.overrideWith( + () => codePushClientWrapper, + ), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ), + captured: captured, + ); + expect(result, equals(ExitCode.software.code)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + expect( + (decoded['error'] as Map)['code'], + 'software_error', + ); + }); + }); + + group('--json', () { + R runJsonMode(R Function() body) { + return runScoped( + body, + values: { + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ); + } + + test( + 'emits JSON success with release_version, patch_number, action', + () async { + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.success.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'success'); + final data = decoded['data'] as Map; + expect(data['release_version'], releaseVersion); + expect(data['patch_number'], patchNumber); + expect(data['action'], 'rollback'); + }, + ); + + group('when patch is already rolled back', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer((_) async => [rolledBackPatch]); + }); + + test('emits usage_error envelope', () async { + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.usage.code)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + expect( + (decoded['error'] as Map)['code'], + 'usage_error', + ); + }); + }); + + group('when no patch with the given number exists', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer( + (_) async => [ + const ReleasePatch( + id: 99, + number: patchNumber + 1, + channel: 'stable', + isRolledBack: false, + artifacts: [], + ), + ], + ); + }); + + test('emits usage_error envelope', () async { + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.usage.code)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + expect( + (decoded['error'] as Map)['code'], + 'usage_error', + ); + }); + }); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/commands/patches/rollforward_command_test.dart b/packages/shorebird_cli/test/src/commands/patches/rollforward_command_test.dart new file mode 100644 index 000000000..e73f0a751 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/patches/rollforward_command_test.dart @@ -0,0 +1,441 @@ +import 'dart:convert'; + +import 'package:args/args.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/commands/patches/rollforward_command.dart'; +import 'package:shorebird_cli/src/config/config.dart'; +import 'package:shorebird_cli/src/json_output.dart'; +import 'package:shorebird_cli/src/logging/shorebird_logger.dart'; +import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/shorebird_validator.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/src/base/process.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; +import 'package:test/test.dart'; + +import '../../helpers.dart'; +import '../../mocks.dart'; + +void main() { + group(RollforwardCommand, () { + const appId = 'app-id'; + const releaseVersion = '1.0.0'; + const patchNumber = 1; + const shorebirdYaml = ShorebirdYaml(appId: appId); + final release = Release( + id: 0, + appId: appId, + version: releaseVersion, + flutterRevision: 'flutter-revision', + flutterVersion: 'flutter-version', + displayName: releaseVersion, + platformStatuses: const {ReleasePlatform.android: ReleaseStatus.active}, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + const activePatch = ReleasePatch( + id: 0, + number: patchNumber, + channel: 'stable', + isRolledBack: false, + artifacts: [], + ); + const rolledBackPatch = ReleasePatch( + id: 0, + number: patchNumber, + channel: 'stable', + isRolledBack: true, + artifacts: [], + ); + + late ArgResults argResults; + late CodePushClientWrapper codePushClientWrapper; + late ShorebirdEnv shorebirdEnv; + late ShorebirdValidator shorebirdValidator; + late ShorebirdLogger logger; + late RollforwardCommand command; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + isJsonModeRef.overrideWith(() => false), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ); + } + + setUp(() { + argResults = MockArgResults(); + codePushClientWrapper = MockCodePushClientWrapper(); + logger = MockShorebirdLogger(); + shorebirdEnv = MockShorebirdEnv(); + shorebirdValidator = MockShorebirdValidator(); + + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults.rest).thenReturn([]); + when(() => argResults['app-id']).thenReturn(null); + when(() => argResults['flavor']).thenReturn(null); + when(() => argResults['release-version']).thenReturn(releaseVersion); + when( + () => argResults['patch-number'], + ).thenReturn(patchNumber.toString()); + + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), + ), + ).thenAnswer((_) async => {}); + when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml); + + when( + () => codePushClientWrapper.getRelease( + appId: any(named: 'appId'), + releaseVersion: any(named: 'releaseVersion'), + ), + ).thenAnswer((_) async => release); + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer((_) async => [rolledBackPatch]); + when( + () => codePushClientWrapper.rollforwardPatch( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + patchId: any(named: 'patchId'), + patchNumber: any(named: 'patchNumber'), + ), + ).thenAnswer((_) async {}); + + command = runWithOverrides(RollforwardCommand.new) + ..testArgResults = argResults; + }); + + test('name is correct', () { + expect(command.name, 'rollforward'); + }); + + test('has correct description', () { + expect( + command.description, + startsWith('Rolls forward (reactivates) a previously rolled-back'), + ); + }); + + group('when validation fails', () { + final exception = ShorebirdNotInitializedException(); + + setUp(() { + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), + ), + ).thenThrow(exception); + }); + + test('exits with exit code from validation error', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(exception.exitCode.code)); + }); + }); + + group('when --app-id is provided', () { + setUp(() { + when(() => argResults['app-id']).thenReturn('explicit-app-id'); + }); + + test('does not require shorebird.yaml', () async { + await runWithOverrides(command.run); + verify( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + ), + ).called(1); + }); + + test('uses the explicit app id', () async { + await runWithOverrides(command.run); + verify( + () => codePushClientWrapper.getRelease( + appId: 'explicit-app-id', + releaseVersion: releaseVersion, + ), + ).called(1); + }); + }); + + group('when no matching patch is found', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer( + (_) async => [ + const ReleasePatch( + id: 99, + number: patchNumber + 1, + channel: 'stable', + isRolledBack: true, + artifacts: [], + ), + ], + ); + }); + + test('exits with usage error', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.usage.code)); + verify( + () => logger.err('No patch found with number $patchNumber'), + ).called(1); + }); + }); + + group('when patch is already active', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer((_) async => [activePatch]); + }); + + test('exits with usage error and does not POST', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.usage.code)); + verify( + () => logger.err( + 'Patch $patchNumber is already active (not rolled back)', + ), + ).called(1); + verifyNever( + () => codePushClientWrapper.rollforwardPatch( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + patchId: any(named: 'patchId'), + patchNumber: any(named: 'patchNumber'), + ), + ); + }); + }); + + group('when patch is rolled forward successfully', () { + test('calls rollforwardPatch and logs success', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + verify( + () => codePushClientWrapper.rollforwardPatch( + appId: appId, + releaseId: release.id, + patchId: rolledBackPatch.id, + patchNumber: patchNumber, + ), + ).called(1); + verify( + () => logger.success( + 'Patch $patchNumber on release $releaseVersion ' + 'has been rolled forward.', + ), + ).called(1); + }); + }); + + group('when fetching patches fails', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenThrow(ProcessExit(ExitCode.software.code)); + }); + + test('in human-readable mode, rethrows ProcessExit', () async { + await expectLater( + () => runWithOverrides(command.run), + throwsA(isA()), + ); + }); + + test('in --json mode, emits fetch_failed envelope', () async { + final captured = []; + final result = await captureStdout( + () => runScoped( + command.run, + values: { + codePushClientWrapperRef.overrideWith( + () => codePushClientWrapper, + ), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ), + captured: captured, + ); + expect(result, equals(ExitCode.software.code)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + expect( + (decoded['error'] as Map)['code'], + 'fetch_failed', + ); + }); + }); + + group('when rollforwardPatch itself fails', () { + setUp(() { + when( + () => codePushClientWrapper.rollforwardPatch( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + patchId: any(named: 'patchId'), + patchNumber: any(named: 'patchNumber'), + ), + ).thenThrow(ProcessExit(ExitCode.software.code)); + }); + + test('in human-readable mode, rethrows ProcessExit', () async { + await expectLater( + () => runWithOverrides(command.run), + throwsA(isA()), + ); + }); + + test('in --json mode, emits software_error envelope', () async { + final captured = []; + final result = await captureStdout( + () => runScoped( + command.run, + values: { + codePushClientWrapperRef.overrideWith( + () => codePushClientWrapper, + ), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ), + captured: captured, + ); + expect(result, equals(ExitCode.software.code)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + expect( + (decoded['error'] as Map)['code'], + 'software_error', + ); + }); + }); + + group('--json', () { + R runJsonMode(R Function() body) { + return runScoped( + body, + values: { + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ); + } + + test( + 'emits JSON success with release_version, patch_number, action', + () async { + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.success.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'success'); + final data = decoded['data'] as Map; + expect(data['release_version'], releaseVersion); + expect(data['patch_number'], patchNumber); + expect(data['action'], 'rollforward'); + }, + ); + + group('when patch is already active', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer((_) async => [activePatch]); + }); + + test('emits usage_error envelope', () async { + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.usage.code)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + expect( + (decoded['error'] as Map)['code'], + 'usage_error', + ); + }); + }); + + group('when no patch with the given number exists', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer( + (_) async => [ + const ReleasePatch( + id: 99, + number: patchNumber + 1, + channel: 'stable', + isRolledBack: true, + artifacts: [], + ), + ], + ); + }); + + test('emits usage_error envelope', () async { + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.usage.code)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + expect( + (decoded['error'] as Map)['code'], + 'usage_error', + ); + }); + }); + }); + }); +} 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 58484feb1..df862c1f0 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 @@ -484,6 +484,58 @@ class CodePushClient { } } + /// Rolls back the patch with [patchId] under [releaseId] for [appId]. + /// + /// Devices on the affected release_version that next call the patch-check + /// endpoint will receive the patch number in `rolled_back_patch_numbers`, + /// which signals the updater to revert to the prior patch (or the base + /// release if none). + /// + /// Idempotent: the server returns `304 Not Modified` if the patch is + /// already rolled back; this method treats that as success. + Future rollbackPatch({ + required String appId, + required int releaseId, + required int patchId, + }) async { + final response = await _httpClient.post( + Uri.parse( + '$_v1/apps/$appId/releases/$releaseId/patches/$patchId/rollback', + ), + ); + + // 304 means the patch was already rolled back — treat as a no-op success. + if (response.statusCode == HttpStatus.notModified) return; + if (!response.isSuccess) { + throw _parseErrorResponse(response.statusCode, response.body); + } + } + + /// Rolls forward (un-rolls-back) the patch with [patchId] under [releaseId] + /// for [appId]. The server flips `is_rolled_back` from `true` to `false` + /// on the same patch row, so the same patch artifact (same hash) becomes + /// active again. + /// + /// Idempotent: the server returns `304 Not Modified` if the patch is + /// already active; this method treats that as success. + Future rollforwardPatch({ + required String appId, + required int releaseId, + required int patchId, + }) async { + final response = await _httpClient.post( + Uri.parse( + '$_v1/apps/$appId/releases/$releaseId/patches/$patchId/rollforward', + ), + ); + + // 304 means the patch was already active — treat as a no-op success. + if (response.statusCode == HttpStatus.notModified) return; + if (!response.isSuccess) { + throw _parseErrorResponse(response.statusCode, response.body); + } + } + /// Gets the list of organizations the user is a member of, along with the /// user's role in each organization. Future> getOrganizationMemberships() 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 fec38a67f..4f2b16a93 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 @@ -2006,6 +2006,199 @@ void main() { }); }); + group('rollbackPatch', () { + const releaseId = 7; + const patchId = 11; + + test('makes the correct request', () async { + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.noContent, + ), + ); + + await codePushClient.rollbackPatch( + appId: appId, + releaseId: releaseId, + patchId: patchId, + ); + + final request = + verify(() => httpClient.send(captureAny())).captured.single + as http.BaseRequest; + expect(request.method, equals('POST')); + expect( + request.url, + equals( + v1('apps/$appId/releases/$releaseId/patches/$patchId/rollback'), + ), + ); + expect(request.hasHeaders(expectedHeaders), isTrue); + }); + + test('treats 304 Not Modified as success (idempotent)', () async { + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.notModified, + ), + ); + + await expectLater( + codePushClient.rollbackPatch( + appId: appId, + releaseId: releaseId, + patchId: patchId, + ), + completes, + ); + }); + + test('treats 204 No Content as success', () async { + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.noContent, + ), + ); + + await expectLater( + codePushClient.rollbackPatch( + appId: appId, + releaseId: releaseId, + patchId: patchId, + ), + completes, + ); + }); + + test('throws an exception if the http request fails (unknown)', () async { + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.badRequest, + ), + ); + + expect( + codePushClient.rollbackPatch( + appId: appId, + releaseId: releaseId, + patchId: patchId, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + CodePushClient.unknownErrorMessage, + ), + ), + ); + }); + + test( + 'throws a parsed exception on a structured error response', + () async { + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + Stream.value(utf8.encode(json.encode(errorResponse.toJson()))), + HttpStatus.failedDependency, + ), + ); + + expect( + codePushClient.rollbackPatch( + appId: appId, + releaseId: releaseId, + patchId: patchId, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + errorResponse.message, + ), + ), + ); + }, + ); + }); + + group('rollforwardPatch', () { + const releaseId = 7; + const patchId = 11; + + test('makes the correct request', () async { + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.noContent, + ), + ); + + await codePushClient.rollforwardPatch( + appId: appId, + releaseId: releaseId, + patchId: patchId, + ); + + final request = + verify(() => httpClient.send(captureAny())).captured.single + as http.BaseRequest; + expect(request.method, equals('POST')); + expect( + request.url, + equals( + v1('apps/$appId/releases/$releaseId/patches/$patchId/rollforward'), + ), + ); + expect(request.hasHeaders(expectedHeaders), isTrue); + }); + + test('treats 304 Not Modified as success (idempotent)', () async { + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.notModified, + ), + ); + + await expectLater( + codePushClient.rollforwardPatch( + appId: appId, + releaseId: releaseId, + patchId: patchId, + ), + completes, + ); + }); + + test('throws an exception if the http request fails (unknown)', () async { + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.badRequest, + ), + ); + + expect( + codePushClient.rollforwardPatch( + appId: appId, + releaseId: releaseId, + patchId: patchId, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + CodePushClient.unknownErrorMessage, + ), + ), + ); + }); + }); + group('getOrganizationMemberships', () { group('when response is not success', () { setUp(() {