feat(shorebird_cli): add account command (whoami + orgs) (#3742)
This commit is contained in:
@@ -116,6 +116,23 @@ class CodePushClientWrapper {
|
||||
);
|
||||
}
|
||||
|
||||
/// Fetches the currently authenticated user.
|
||||
Future<PrivateUser> getCurrentUser() async {
|
||||
final progress = logger.progress('Fetching account');
|
||||
final PrivateUser? user;
|
||||
try {
|
||||
user = await codePushClient.getCurrentUser();
|
||||
progress.complete();
|
||||
} catch (error) {
|
||||
_handleErrorAndExit(error, progress: progress);
|
||||
}
|
||||
if (user == null) {
|
||||
logger.err('Could not find current user.');
|
||||
throw ProcessExit(ExitCode.software.code);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/// Fetches the organization memberships for the current user.
|
||||
Future<List<OrganizationMembership>> getOrganizationMemberships() async {
|
||||
final progress = logger.progress('Fetching organizations');
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export 'account_command.dart';
|
||||
export 'orgs_command.dart';
|
||||
export 'whoami_command.dart';
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:shorebird_cli/src/commands/account/account.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_command.dart';
|
||||
|
||||
/// {@template account_command}
|
||||
/// Commands for inspecting the current Shorebird account.
|
||||
/// {@endtemplate}
|
||||
class AccountCommand extends ShorebirdCommand {
|
||||
/// {@macro account_command}
|
||||
AccountCommand() {
|
||||
addSubcommand(OrgsCommand());
|
||||
addSubcommand(WhoamiCommand());
|
||||
}
|
||||
|
||||
@override
|
||||
String get name => 'account';
|
||||
|
||||
@override
|
||||
String get description => 'Manage your Shorebird account.';
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:mason_logger/mason_logger.dart';
|
||||
import 'package:shorebird_cli/src/code_push_client_wrapper.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/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';
|
||||
|
||||
/// {@template orgs_command}
|
||||
/// `shorebird account orgs`
|
||||
/// List the organizations the current user belongs to.
|
||||
/// {@endtemplate}
|
||||
class OrgsCommand extends ShorebirdCommand {
|
||||
/// {@macro orgs_command}
|
||||
OrgsCommand();
|
||||
|
||||
@override
|
||||
String get name => 'orgs';
|
||||
|
||||
@override
|
||||
String get description =>
|
||||
'List the organizations you belong to.\n\n'
|
||||
'Example output (space-separated: id name type role):\n'
|
||||
' 1 Acme Corp team admin\n'
|
||||
' 2 user@example.com personal owner\n\n'
|
||||
'Type is "personal" or "team". Role is "owner", "admin", or '
|
||||
'"developer".\n\n'
|
||||
'${ShorebirdCommand.jsonHint('shorebird account orgs --json')}';
|
||||
|
||||
@override
|
||||
Future<int> run() async {
|
||||
try {
|
||||
await shorebirdValidator.validatePreconditions(
|
||||
checkUserIsAuthenticated: true,
|
||||
);
|
||||
} on PreconditionFailedException catch (error) {
|
||||
return error.exitCode.code;
|
||||
}
|
||||
|
||||
final List<OrganizationMembership> memberships;
|
||||
try {
|
||||
memberships = await codePushClientWrapper.getOrganizationMemberships();
|
||||
} on ProcessExit catch (e) {
|
||||
if (isJsonMode) {
|
||||
emitJsonError(
|
||||
code: JsonErrorCode.fetchFailed,
|
||||
message: 'Failed to fetch organizations.',
|
||||
);
|
||||
return e.exitCode;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
|
||||
if (isJsonMode) {
|
||||
emitJsonSuccess({
|
||||
'organizations': [
|
||||
for (final m in memberships)
|
||||
{
|
||||
'id': m.organization.id,
|
||||
'name': m.organization.name,
|
||||
'type': m.organization.organizationType.name,
|
||||
'role': m.role.name,
|
||||
},
|
||||
],
|
||||
});
|
||||
return ExitCode.success.code;
|
||||
}
|
||||
|
||||
if (memberships.isEmpty) {
|
||||
logger.info('No organizations found.');
|
||||
return ExitCode.success.code;
|
||||
}
|
||||
|
||||
for (final membership in memberships) {
|
||||
final org = membership.organization;
|
||||
logger.info(
|
||||
'${org.id} ${lightCyan.wrap(org.name)} '
|
||||
'${org.organizationType.name} ${membership.role.name}',
|
||||
);
|
||||
}
|
||||
|
||||
return ExitCode.success.code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:mason_logger/mason_logger.dart';
|
||||
import 'package:shorebird_cli/src/code_push_client_wrapper.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/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';
|
||||
|
||||
/// {@template whoami_command}
|
||||
/// `shorebird account whoami`
|
||||
/// Show the currently authenticated Shorebird user.
|
||||
/// {@endtemplate}
|
||||
class WhoamiCommand extends ShorebirdCommand {
|
||||
/// {@macro whoami_command}
|
||||
WhoamiCommand();
|
||||
|
||||
@override
|
||||
String get name => 'whoami';
|
||||
|
||||
@override
|
||||
String get description =>
|
||||
'Show the currently authenticated Shorebird user.\n\n'
|
||||
'Example output:\n'
|
||||
' ID: 42\n'
|
||||
' Email: user@example.com\n'
|
||||
' Display name: Example User\n'
|
||||
' Plan: paid\n'
|
||||
' Overage limit: 10000\n\n'
|
||||
'Plan is "paid" (active Shorebird subscription) or "free".\n'
|
||||
'Overage limit is the max pay-as-you-go patch installs allowed '
|
||||
'beyond your plan ("none" if unset).\n\n'
|
||||
'${ShorebirdCommand.jsonHint('shorebird account whoami --json')}';
|
||||
|
||||
@override
|
||||
Future<int> run() async {
|
||||
try {
|
||||
await shorebirdValidator.validatePreconditions(
|
||||
checkUserIsAuthenticated: true,
|
||||
);
|
||||
} on PreconditionFailedException catch (error) {
|
||||
return error.exitCode.code;
|
||||
}
|
||||
|
||||
final PrivateUser user;
|
||||
try {
|
||||
user = await codePushClientWrapper.getCurrentUser();
|
||||
} on ProcessExit catch (e) {
|
||||
if (isJsonMode) {
|
||||
emitJsonError(
|
||||
code: JsonErrorCode.fetchFailed,
|
||||
message: 'Failed to fetch account.',
|
||||
);
|
||||
return e.exitCode;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
|
||||
final plan = (user.hasActiveSubscription ?? false) ? 'paid' : 'free';
|
||||
|
||||
if (isJsonMode) {
|
||||
emitJsonSuccess({
|
||||
'user': {
|
||||
'id': user.id,
|
||||
'email': user.email,
|
||||
'display_name': user.displayName,
|
||||
'plan': plan,
|
||||
'overage_limit': user.patchOverageLimit,
|
||||
},
|
||||
});
|
||||
return ExitCode.success.code;
|
||||
}
|
||||
|
||||
logger
|
||||
..info('ID: ${user.id}')
|
||||
..info('Email: ${user.email}');
|
||||
if (user.displayName != null) {
|
||||
logger.info('Display name: ${user.displayName}');
|
||||
}
|
||||
logger
|
||||
..info('Plan: $plan')
|
||||
..info('Overage limit: ${user.patchOverageLimit ?? 'none'}');
|
||||
|
||||
return ExitCode.success.code;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export 'account/account.dart';
|
||||
export 'cache/cache.dart';
|
||||
export 'create_command.dart';
|
||||
export 'doctor_command.dart';
|
||||
|
||||
@@ -70,6 +70,7 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner<int> {
|
||||
help: 'The build of the local engine to use as the host platform.',
|
||||
);
|
||||
|
||||
addCommand(AccountCommand());
|
||||
addCommand(CacheCommand());
|
||||
addCommand(CreateCommand());
|
||||
addCommand(DoctorCommand());
|
||||
|
||||
@@ -279,6 +279,49 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('getCurrentUser', () {
|
||||
test('exits with code 70 when fetching the user fails', () async {
|
||||
const error = 'something went wrong';
|
||||
when(() => codePushClient.getCurrentUser()).thenThrow(error);
|
||||
|
||||
await expectLater(
|
||||
() async => runWithOverrides(codePushClientWrapper.getCurrentUser),
|
||||
exitsWithCode(ExitCode.software),
|
||||
);
|
||||
verify(() => progress.fail(error)).called(1);
|
||||
});
|
||||
|
||||
test('exits with code 70 when no current user is found', () async {
|
||||
when(
|
||||
() => codePushClient.getCurrentUser(),
|
||||
).thenAnswer((_) async => null);
|
||||
|
||||
await expectLater(
|
||||
() async => runWithOverrides(codePushClientWrapper.getCurrentUser),
|
||||
exitsWithCode(ExitCode.software),
|
||||
);
|
||||
verify(() => logger.err('Could not find current user.')).called(1);
|
||||
});
|
||||
|
||||
test('returns the current user on success', () async {
|
||||
const expectedUser = PrivateUser(
|
||||
id: 1,
|
||||
email: 'user@example.com',
|
||||
jwtIssuer: 'https://accounts.google.com',
|
||||
);
|
||||
when(
|
||||
() => codePushClient.getCurrentUser(),
|
||||
).thenAnswer((_) async => expectedUser);
|
||||
|
||||
final user = await runWithOverrides(
|
||||
codePushClientWrapper.getCurrentUser,
|
||||
);
|
||||
|
||||
expect(user, equals(expectedUser));
|
||||
verify(() => progress.complete()).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('getOrganizationMemberships', () {
|
||||
test(
|
||||
'exits with code 70 when getting organization memberships fails',
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
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/account/orgs_command.dart';
|
||||
import 'package:shorebird_cli/src/json_output.dart';
|
||||
import 'package:shorebird_cli/src/logging/shorebird_logger.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(OrgsCommand, () {
|
||||
final teamMembership = OrganizationMembership(
|
||||
organization: Organization(
|
||||
id: 1,
|
||||
name: 'Acme Corp',
|
||||
organizationType: OrganizationType.team,
|
||||
createdAt: DateTime(2026, 1, 15),
|
||||
updatedAt: DateTime(2026, 1, 16),
|
||||
),
|
||||
role: Role.admin,
|
||||
);
|
||||
final personalMembership = OrganizationMembership(
|
||||
organization: Organization(
|
||||
id: 2,
|
||||
name: 'user@example.com',
|
||||
organizationType: OrganizationType.personal,
|
||||
createdAt: DateTime(2026, 1, 10),
|
||||
updatedAt: DateTime(2026, 1, 11),
|
||||
),
|
||||
role: Role.owner,
|
||||
);
|
||||
|
||||
late ArgResults argResults;
|
||||
late CodePushClientWrapper codePushClientWrapper;
|
||||
late ShorebirdValidator shorebirdValidator;
|
||||
late ShorebirdLogger logger;
|
||||
late Progress progress;
|
||||
late OrgsCommand command;
|
||||
|
||||
R runWithOverrides<R>(R Function() body) {
|
||||
return runScoped(
|
||||
body,
|
||||
values: {
|
||||
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
|
||||
isJsonModeRef.overrideWith(() => false),
|
||||
loggerRef.overrideWith(() => logger),
|
||||
shorebirdValidatorRef.overrideWith(() => shorebirdValidator),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
argResults = MockArgResults();
|
||||
codePushClientWrapper = MockCodePushClientWrapper();
|
||||
logger = MockShorebirdLogger();
|
||||
progress = MockProgress();
|
||||
shorebirdValidator = MockShorebirdValidator();
|
||||
command = runWithOverrides(OrgsCommand.new)..testArgResults = argResults;
|
||||
|
||||
when(() => logger.progress(any())).thenReturn(progress);
|
||||
when(() => argResults.rest).thenReturn([]);
|
||||
when(
|
||||
() => shorebirdValidator.validatePreconditions(
|
||||
checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
when(
|
||||
() => codePushClientWrapper.getOrganizationMemberships(),
|
||||
).thenAnswer((_) async => [teamMembership, personalMembership]);
|
||||
});
|
||||
|
||||
test('has correct description', () {
|
||||
expect(
|
||||
command.description,
|
||||
startsWith('List the organizations you belong to.'),
|
||||
);
|
||||
});
|
||||
|
||||
group('when validation fails', () {
|
||||
final exception = UserNotAuthorizedException();
|
||||
|
||||
setUp(() {
|
||||
when(
|
||||
() => shorebirdValidator.validatePreconditions(
|
||||
checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'),
|
||||
),
|
||||
).thenThrow(exception);
|
||||
});
|
||||
|
||||
test('returns the precondition failure exit code', () async {
|
||||
final result = await runWithOverrides(command.run);
|
||||
expect(result, equals(exception.exitCode.code));
|
||||
});
|
||||
});
|
||||
|
||||
test('requires user to be authenticated', () async {
|
||||
await runWithOverrides(command.run);
|
||||
verify(
|
||||
() => shorebirdValidator.validatePreconditions(
|
||||
checkUserIsAuthenticated: true,
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
group('human-readable output', () {
|
||||
test('prints one line per organization', () async {
|
||||
final result = await runWithOverrides(command.run);
|
||||
expect(result, equals(ExitCode.success.code));
|
||||
final lines = verify(
|
||||
() => logger.info(captureAny()),
|
||||
).captured.cast<String>();
|
||||
expect(lines, hasLength(2));
|
||||
expect(lines[0], allOf(contains('Acme Corp'), contains('admin')));
|
||||
expect(
|
||||
lines[1],
|
||||
allOf(contains('user@example.com'), contains('owner')),
|
||||
);
|
||||
});
|
||||
|
||||
group('when there are no organizations', () {
|
||||
setUp(() {
|
||||
when(
|
||||
() => codePushClientWrapper.getOrganizationMemberships(),
|
||||
).thenAnswer((_) async => []);
|
||||
});
|
||||
|
||||
test('prints an empty-state message', () async {
|
||||
final result = await runWithOverrides(command.run);
|
||||
expect(result, equals(ExitCode.success.code));
|
||||
verify(() => logger.info('No organizations found.')).called(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('when API fetch fails', () {
|
||||
setUp(() {
|
||||
when(
|
||||
() => codePushClientWrapper.getOrganizationMemberships(),
|
||||
).thenThrow(ProcessExit(ExitCode.software.code));
|
||||
});
|
||||
|
||||
test('in human-readable mode, rethrows ProcessExit', () async {
|
||||
await expectLater(
|
||||
() => runWithOverrides(command.run),
|
||||
throwsA(isA<ProcessExit>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('in --json mode, emits JSON error envelope', () async {
|
||||
final captured = <String>[];
|
||||
final result = await captureStdout(
|
||||
() => runScoped(
|
||||
command.run,
|
||||
values: {
|
||||
codePushClientWrapperRef.overrideWith(
|
||||
() => codePushClientWrapper,
|
||||
),
|
||||
isJsonModeRef.overrideWith(() => true),
|
||||
loggerRef.overrideWith(() => logger),
|
||||
shorebirdValidatorRef.overrideWith(() => shorebirdValidator),
|
||||
},
|
||||
),
|
||||
captured: captured,
|
||||
);
|
||||
expect(result, equals(ExitCode.software.code));
|
||||
expect(captured, hasLength(1));
|
||||
final decoded = jsonDecode(captured.first) as Map<String, dynamic>;
|
||||
expect(decoded['status'], 'error');
|
||||
});
|
||||
});
|
||||
|
||||
group('--json', () {
|
||||
R runJsonMode<R>(R Function() body) {
|
||||
return runScoped(
|
||||
body,
|
||||
values: {
|
||||
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
|
||||
isJsonModeRef.overrideWith(() => true),
|
||||
loggerRef.overrideWith(() => logger),
|
||||
shorebirdValidatorRef.overrideWith(() => shorebirdValidator),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test('emits JSON success with flat organization fields', () async {
|
||||
final captured = <String>[];
|
||||
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<String, dynamic>;
|
||||
expect(decoded['status'], 'success');
|
||||
final data = decoded['data'] as Map<String, dynamic>;
|
||||
final orgs = data['organizations'] as List<dynamic>;
|
||||
expect(orgs, hasLength(2));
|
||||
final firstOrg = orgs.first as Map<String, dynamic>;
|
||||
expect(firstOrg['id'], 1);
|
||||
expect(firstOrg['name'], 'Acme Corp');
|
||||
expect(firstOrg['type'], 'team');
|
||||
expect(firstOrg['role'], 'admin');
|
||||
});
|
||||
|
||||
test('does not leak timestamps or protocol-internal fields', () async {
|
||||
final captured = <String>[];
|
||||
await captureStdout(
|
||||
() => runJsonMode(command.run),
|
||||
captured: captured,
|
||||
);
|
||||
final decoded = jsonDecode(captured.first) as Map<String, dynamic>;
|
||||
final orgs =
|
||||
((decoded['data'] as Map<String, dynamic>)['organizations']
|
||||
as List<dynamic>)
|
||||
.cast<Map<String, dynamic>>();
|
||||
for (final org in orgs) {
|
||||
expect(org.containsKey('created_at'), isFalse);
|
||||
expect(org.containsKey('updated_at'), isFalse);
|
||||
expect(org.containsKey('organization_type'), isFalse);
|
||||
expect(org.containsKey('organization'), isFalse);
|
||||
}
|
||||
});
|
||||
|
||||
test('emits empty array when there are no organizations', () async {
|
||||
when(
|
||||
() => codePushClientWrapper.getOrganizationMemberships(),
|
||||
).thenAnswer((_) async => []);
|
||||
final captured = <String>[];
|
||||
final result = await captureStdout(
|
||||
() => runJsonMode(command.run),
|
||||
captured: captured,
|
||||
);
|
||||
expect(result, equals(ExitCode.success.code));
|
||||
final decoded = jsonDecode(captured.first) as Map<String, dynamic>;
|
||||
final data = decoded['data'] as Map<String, dynamic>;
|
||||
expect(data['organizations'], isEmpty);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
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/account/whoami_command.dart';
|
||||
import 'package:shorebird_cli/src/json_output.dart';
|
||||
import 'package:shorebird_cli/src/logging/shorebird_logger.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(WhoamiCommand, () {
|
||||
const user = PrivateUser(
|
||||
id: 1,
|
||||
email: 'user@example.com',
|
||||
displayName: 'Example User',
|
||||
hasActiveSubscription: true,
|
||||
jwtIssuer: 'https://accounts.google.com',
|
||||
patchOverageLimit: 10000,
|
||||
);
|
||||
|
||||
late ArgResults argResults;
|
||||
late CodePushClientWrapper codePushClientWrapper;
|
||||
late ShorebirdValidator shorebirdValidator;
|
||||
late ShorebirdLogger logger;
|
||||
late Progress progress;
|
||||
late WhoamiCommand command;
|
||||
|
||||
R runWithOverrides<R>(R Function() body) {
|
||||
return runScoped(
|
||||
body,
|
||||
values: {
|
||||
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
|
||||
isJsonModeRef.overrideWith(() => false),
|
||||
loggerRef.overrideWith(() => logger),
|
||||
shorebirdValidatorRef.overrideWith(() => shorebirdValidator),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
argResults = MockArgResults();
|
||||
codePushClientWrapper = MockCodePushClientWrapper();
|
||||
logger = MockShorebirdLogger();
|
||||
progress = MockProgress();
|
||||
shorebirdValidator = MockShorebirdValidator();
|
||||
command = runWithOverrides(WhoamiCommand.new)
|
||||
..testArgResults = argResults;
|
||||
|
||||
when(() => logger.progress(any())).thenReturn(progress);
|
||||
when(() => argResults.rest).thenReturn([]);
|
||||
when(
|
||||
() => shorebirdValidator.validatePreconditions(
|
||||
checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
when(
|
||||
() => codePushClientWrapper.getCurrentUser(),
|
||||
).thenAnswer((_) async => user);
|
||||
});
|
||||
|
||||
test('has correct description', () {
|
||||
expect(
|
||||
command.description,
|
||||
startsWith('Show the currently authenticated Shorebird user.'),
|
||||
);
|
||||
});
|
||||
|
||||
group('when validation fails', () {
|
||||
final exception = UserNotAuthorizedException();
|
||||
|
||||
setUp(() {
|
||||
when(
|
||||
() => shorebirdValidator.validatePreconditions(
|
||||
checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'),
|
||||
),
|
||||
).thenThrow(exception);
|
||||
});
|
||||
|
||||
test('returns the precondition failure exit code', () async {
|
||||
final result = await runWithOverrides(command.run);
|
||||
expect(result, equals(exception.exitCode.code));
|
||||
});
|
||||
});
|
||||
|
||||
test('requires user to be authenticated', () async {
|
||||
await runWithOverrides(command.run);
|
||||
verify(
|
||||
() => shorebirdValidator.validatePreconditions(
|
||||
checkUserIsAuthenticated: true,
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
group('human-readable output', () {
|
||||
test('prints account fields', () async {
|
||||
final result = await runWithOverrides(command.run);
|
||||
expect(result, equals(ExitCode.success.code));
|
||||
verify(
|
||||
() => logger.info(any(that: contains('user@example.com'))),
|
||||
).called(1);
|
||||
verify(
|
||||
() => logger.info(any(that: contains('Example User'))),
|
||||
).called(1);
|
||||
verify(
|
||||
() => logger.info(any(that: contains('Plan: paid'))),
|
||||
).called(1);
|
||||
verify(
|
||||
() => logger.info(any(that: contains('Overage limit: 10000'))),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
group('when display name is null', () {
|
||||
const userNoName = PrivateUser(
|
||||
id: 2,
|
||||
email: 'noname@example.com',
|
||||
jwtIssuer: 'https://accounts.google.com',
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
when(
|
||||
() => codePushClientWrapper.getCurrentUser(),
|
||||
).thenAnswer((_) async => userNoName);
|
||||
});
|
||||
|
||||
test('omits the display name line', () async {
|
||||
final result = await runWithOverrides(command.run);
|
||||
expect(result, equals(ExitCode.success.code));
|
||||
verifyNever(
|
||||
() => logger.info(any(that: contains('Display name'))),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('when user is on the free plan', () {
|
||||
const userNoSub = PrivateUser(
|
||||
id: 3,
|
||||
email: 'noplan@example.com',
|
||||
jwtIssuer: 'https://accounts.google.com',
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
when(
|
||||
() => codePushClientWrapper.getCurrentUser(),
|
||||
).thenAnswer((_) async => userNoSub);
|
||||
});
|
||||
|
||||
test('prints plan as free', () async {
|
||||
final result = await runWithOverrides(command.run);
|
||||
expect(result, equals(ExitCode.success.code));
|
||||
verify(
|
||||
() => logger.info(any(that: contains('Plan: free'))),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('prints overage limit as none when unset', () async {
|
||||
final result = await runWithOverrides(command.run);
|
||||
expect(result, equals(ExitCode.success.code));
|
||||
verify(
|
||||
() => logger.info(any(that: contains('Overage limit: none'))),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('when API fetch fails', () {
|
||||
setUp(() {
|
||||
when(
|
||||
() => codePushClientWrapper.getCurrentUser(),
|
||||
).thenThrow(ProcessExit(ExitCode.software.code));
|
||||
});
|
||||
|
||||
test('in human-readable mode, rethrows ProcessExit', () async {
|
||||
await expectLater(
|
||||
() => runWithOverrides(command.run),
|
||||
throwsA(isA<ProcessExit>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('in --json mode, emits JSON error envelope', () async {
|
||||
final captured = <String>[];
|
||||
final result = await captureStdout(
|
||||
() => runScoped(
|
||||
command.run,
|
||||
values: {
|
||||
codePushClientWrapperRef.overrideWith(
|
||||
() => codePushClientWrapper,
|
||||
),
|
||||
isJsonModeRef.overrideWith(() => true),
|
||||
loggerRef.overrideWith(() => logger),
|
||||
shorebirdValidatorRef.overrideWith(() => shorebirdValidator),
|
||||
},
|
||||
),
|
||||
captured: captured,
|
||||
);
|
||||
expect(result, equals(ExitCode.software.code));
|
||||
expect(captured, hasLength(1));
|
||||
final decoded = jsonDecode(captured.first) as Map<String, dynamic>;
|
||||
expect(decoded['status'], 'error');
|
||||
});
|
||||
});
|
||||
|
||||
group('--json', () {
|
||||
R runJsonMode<R>(R Function() body) {
|
||||
return runScoped(
|
||||
body,
|
||||
values: {
|
||||
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
|
||||
isJsonModeRef.overrideWith(() => true),
|
||||
loggerRef.overrideWith(() => logger),
|
||||
shorebirdValidatorRef.overrideWith(() => shorebirdValidator),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test('emits JSON success with projected user fields', () async {
|
||||
final captured = <String>[];
|
||||
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<String, dynamic>;
|
||||
expect(decoded['status'], 'success');
|
||||
final data = decoded['data'] as Map<String, dynamic>;
|
||||
final userData = data['user'] as Map<String, dynamic>;
|
||||
expect(userData['id'], 1);
|
||||
expect(userData['email'], 'user@example.com');
|
||||
expect(userData['display_name'], 'Example User');
|
||||
expect(userData['plan'], 'paid');
|
||||
expect(userData['overage_limit'], 10000);
|
||||
});
|
||||
|
||||
test('does not leak protocol-internal fields', () async {
|
||||
final captured = <String>[];
|
||||
await captureStdout(
|
||||
() => runJsonMode(command.run),
|
||||
captured: captured,
|
||||
);
|
||||
final decoded = jsonDecode(captured.first) as Map<String, dynamic>;
|
||||
final userData =
|
||||
(decoded['data'] as Map<String, dynamic>)['user']
|
||||
as Map<String, dynamic>;
|
||||
expect(userData.containsKey('stripe_customer_id'), isFalse);
|
||||
expect(userData.containsKey('jwt_issuer'), isFalse);
|
||||
expect(userData.containsKey('has_active_subscription'), isFalse);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user