diff --git a/gui/packages/ubuntupro/integration_test/ubuntu_pro_for_wsl_test.dart b/gui/packages/ubuntupro/integration_test/ubuntu_pro_for_wsl_test.dart index ed8c0975e..7c74671be 100644 --- a/gui/packages/ubuntupro/integration_test/ubuntu_pro_for_wsl_test.dart +++ b/gui/packages/ubuntupro/integration_test/ubuntu_pro_for_wsl_test.dart @@ -47,7 +47,7 @@ void main() { overrides: { 'USERPROFILE': tmpHome!.path, 'LOCALAPPDATA': tmpLocalAppData!.path, - 'UP4W_ALLOW_STORE_PURCHASE': '1', + 'UP4W_INTEGRATION_TESTING': 'true', }, ); }); diff --git a/gui/packages/ubuntupro/lib/app.dart b/gui/packages/ubuntupro/lib/app.dart index 69d91bcfb..086cfca33 100644 --- a/gui/packages/ubuntupro/lib/app.dart +++ b/gui/packages/ubuntupro/lib/app.dart @@ -9,6 +9,7 @@ import 'package:yaru/yaru.dart'; import 'core/agent_api_client.dart'; import 'core/agent_connection.dart'; import 'core/agent_monitor.dart'; +import 'core/settings.dart'; import 'pages/landscape/landscape_page.dart'; import 'pages/startup/startup_page.dart'; import 'pages/subscribe_now/subscribe_now_page.dart'; @@ -16,8 +17,9 @@ import 'pages/subscription_status/subscription_status_page.dart'; import 'routes.dart'; class Pro4WSLApp extends StatelessWidget { - const Pro4WSLApp(this.agentMonitor, {super.key}); + const Pro4WSLApp(this.agentMonitor, this.settings, {super.key}); final AgentStartupMonitor agentMonitor; + final Settings settings; @override Widget build(BuildContext context) { @@ -62,6 +64,7 @@ class Pro4WSLApp extends StatelessWidget { ), Routes.subscribeNow: WizardRoute( builder: SubscribeNowPage.create, + userData: settings.isStorePurchaseAllowed, onNext: (_) { final src = context.read>(); final landscape = src.value.landscapeSource; @@ -72,19 +75,29 @@ class Pro4WSLApp extends StatelessWidget { return null; }, ), - Routes.configureLandscape: - const WizardRoute(builder: LandscapePage.create), - Routes.subscriptionStatus: WizardRoute( - builder: SubscriptionStatusPage.create, - onReplace: (_) => Routes.subscribeNow, - onBack: (_) => Routes.subscribeNow, - ), - Routes.configureLandscapeLate: WizardRoute( - builder: (context) => LandscapePage.create( - context, - isLate: true, + if (settings.isLandscapeConfigurationEnabled) ...{ + Routes.configureLandscape: + const WizardRoute(builder: LandscapePage.create), + Routes.subscriptionStatus: WizardRoute( + builder: SubscriptionStatusPage.create, + onReplace: (_) => Routes.subscribeNow, + onBack: (_) => Routes.subscribeNow, + userData: true, ), - ), + Routes.configureLandscapeLate: WizardRoute( + builder: (context) => LandscapePage.create( + context, + isLate: true, + ), + ), + } else ...{ + Routes.subscriptionStatus: WizardRoute( + builder: SubscriptionStatusPage.create, + onReplace: (_) => Routes.subscribeNow, + onBack: (_) => Routes.subscribeNow, + userData: false, + ), + }, }, ); }, diff --git a/gui/packages/ubuntupro/lib/core/settings.dart b/gui/packages/ubuntupro/lib/core/settings.dart new file mode 100644 index 000000000..05780bce9 --- /dev/null +++ b/gui/packages/ubuntupro/lib/core/settings.dart @@ -0,0 +1,82 @@ +import 'package:flutter/foundation.dart'; +import 'package:win32/win32.dart'; +import 'package:win32_registry/win32_registry.dart'; + +/// Manages the settings for the user interface. +class Settings { + /// Creates a new instance of [Settings] initialized with options read from [repository], + /// which is loaded, read from and closed. + Settings(SettingsRepository repository) { + if (!repository.load()) return; + + final purchase = repository.readInt(kAllowStorePurchase) == 1 + ? Options.withStorePurchase + : Options.none; + final landscape = repository.readInt(kShowLandscapeConfig) == 1 + ? Options.withLandscapeConfiguration + : Options.none; + + repository.close(); + + _options = purchase | landscape; + } + + /// Creates a new instance of [Settings] with the specified [options], thus no need to read from the repository. + /// Useful for integration testing. + Settings.withOptions(this._options); + + Options _options = Options.none; + + bool get isLandscapeConfigurationEnabled => + _options & Options.withLandscapeConfiguration; + bool get isStorePurchaseAllowed => _options & Options.withStorePurchase; + +// constants for the key names only exposed for testing. + @visibleForTesting + static const kAllowStorePurchase = 'AllowStorePurchase'; + @visibleForTesting + static const kShowLandscapeConfig = 'ShowLandscapeConfig'; +} + +/// Settings options modelled as an enum with bitwise operations, i.e. flags. +enum Options { + none(0x00), + withLandscapeConfiguration(0x01), + withStorePurchase(0x02), + // all optionss above or'ed. + withAll(0x03); + + final int options; + const Options(this.options); + factory Options._fromInt(int options) => + Options.values.firstWhere((e) => e.options == options); + + bool operator &(Options other) => options & other.options != 0; + Options operator |(Options other) => + Options._fromInt(options | other.options); +} + +// "Abstracts" reading the settings storage (a.k.a the Windows registry). +class SettingsRepository { + RegistryKey? _key; + + void close() => _key?.close(); + int? readInt(String name) { + if (_key == null) return null; + return _key!.getValueAsInt(name); + } + + bool load() { + try { + _key = Registry.openPath(RegistryHive.currentUser, path: _keyPath); + return true; + } on WindowsException { + // missing key is not an error since we expect them to be set in very few cases. + // TODO: Log error cases other than ERROR_FILE_NOT_FOUND. + return false; + } + } +} + +// The registry key we want to read from. +const _keyPath = r'SOFTWARE\Canonical\UbuntuPro\'; diff --git a/gui/packages/ubuntupro/lib/main.dart b/gui/packages/ubuntupro/lib/main.dart index 9414ab266..7e906d7a2 100644 --- a/gui/packages/ubuntupro/lib/main.dart +++ b/gui/packages/ubuntupro/lib/main.dart @@ -6,6 +6,8 @@ import 'app.dart'; import 'constants.dart'; import 'core/agent_api_client.dart'; import 'core/agent_monitor.dart'; +import 'core/environment.dart'; +import 'core/settings.dart'; import 'launch_agent.dart'; Future main() async { @@ -20,7 +22,12 @@ Future main() async { clientFactory: AgentApiClient.new, onClient: registerServiceInstance, ); - runApp(Pro4WSLApp(agentMonitor)); + + final settings = Environment()['UP4W_INTEGRATION_TESTING'] != null + ? Settings.withOptions(Options.withAll) + : Settings(SettingsRepository()); + + runApp(Pro4WSLApp(agentMonitor, settings)); } Future launch() => launchAgent(kAgentRelativePath); diff --git a/gui/packages/ubuntupro/lib/pages/landscape/landscape_page.dart b/gui/packages/ubuntupro/lib/pages/landscape/landscape_page.dart index 01de861e8..04146c03e 100644 --- a/gui/packages/ubuntupro/lib/pages/landscape/landscape_page.dart +++ b/gui/packages/ubuntupro/lib/pages/landscape/landscape_page.dart @@ -131,7 +131,7 @@ class LandscapeInput extends StatelessWidget { ], ), const SizedBox( - height: 32.0, + height: 16.0, ), Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -314,7 +314,7 @@ class _FileFormState extends State<_FileForm> { style: widget.sectionBodyStyle, ), const SizedBox( - height: 16.0, + height: 8.0, ), Row( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/gui/packages/ubuntupro/lib/pages/subscribe_now/subscribe_now_model.dart b/gui/packages/ubuntupro/lib/pages/subscribe_now/subscribe_now_model.dart index 6619c6945..6c7bef446 100644 --- a/gui/packages/ubuntupro/lib/pages/subscribe_now/subscribe_now_model.dart +++ b/gui/packages/ubuntupro/lib/pages/subscribe_now/subscribe_now_model.dart @@ -4,13 +4,13 @@ import 'package:flutter/foundation.dart'; import 'package:p4w_ms_store/p4w_ms_store.dart'; import 'package:url_launcher/url_launcher.dart'; import '/core/agent_api_client.dart'; -import '/core/environment.dart'; import '/core/pro_token.dart'; class SubscribeNowModel { final AgentApiClient client; - bool? _isPurchaseAllowed; - SubscribeNowModel(this.client); + final bool _isPurchaseAllowed; + SubscribeNowModel(this.client, {bool isPurchaseAllowed = false}) + : _isPurchaseAllowed = isPurchaseAllowed; Future applyProToken(ProToken token) { return client.applyProToken(token.value); @@ -43,8 +43,5 @@ class SubscribeNowModel { /// Returns true if the environment variable 'UP4W_ALLOW_STORE_PURCHASE' has been set. /// Since this reading won't change during the app lifetime, even if the user changes /// it's value from outside, the value is cached so we don't check the environment more than once. - bool purchaseAllowed() { - return _isPurchaseAllowed ??= ['true', '1', 'on'] - .contains(Environment()['UP4W_ALLOW_STORE_PURCHASE']?.toLowerCase()); - } + bool get purchaseAllowed => _isPurchaseAllowed; } diff --git a/gui/packages/ubuntupro/lib/pages/subscribe_now/subscribe_now_page.dart b/gui/packages/ubuntupro/lib/pages/subscribe_now/subscribe_now_page.dart index a71311f31..4ab52bef0 100644 --- a/gui/packages/ubuntupro/lib/pages/subscribe_now/subscribe_now_page.dart +++ b/gui/packages/ubuntupro/lib/pages/subscribe_now/subscribe_now_page.dart @@ -31,11 +31,10 @@ class SubscribeNowPage extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.start, children: [ Tooltip( - message: model.purchaseAllowed() - ? '' - : lang.subscribeNowTooltipDisabled, + message: + model.purchaseAllowed ? '' : lang.subscribeNowTooltipDisabled, child: ElevatedButton( - onPressed: model.purchaseAllowed() + onPressed: model.purchaseAllowed ? () async { final subs = await model.purchaseSubscription(); @@ -99,8 +98,14 @@ class SubscribeNowPage extends StatelessWidget { static Widget create(BuildContext context) { final client = getService(); + final storePurchaseIsAllowed = + Wizard.of(context).routeData as bool? ?? false; + return Provider( - create: (context) => SubscribeNowModel(client), + create: (context) => SubscribeNowModel( + client, + isPurchaseAllowed: storePurchaseIsAllowed, + ), child: SubscribeNowPage( onSubscriptionUpdate: (info) { final src = context.read>(); diff --git a/gui/packages/ubuntupro/lib/pages/subscription_status/subscription_status_model.dart b/gui/packages/ubuntupro/lib/pages/subscription_status/subscription_status_model.dart index c88e03c3e..5bead5beb 100644 --- a/gui/packages/ubuntupro/lib/pages/subscription_status/subscription_status_model.dart +++ b/gui/packages/ubuntupro/lib/pages/subscription_status/subscription_status_model.dart @@ -8,27 +8,37 @@ sealed class SubscriptionStatusModel { /// Returns the appropriate view-model subclass based on the subscription source type that was passed. factory SubscriptionStatusModel( ConfigSources src, - AgentApiClient client, - ) { + AgentApiClient client, { + bool canConfigureLandscape = false, + }) { + // Enforce this business logic here, as defaults may change in the future: + // - Org-managed Landscape configurations don't allow user changes via GUI. + if (src.landscapeSource.hasOrganization()) { + canConfigureLandscape = false; + } + final info = src.proSubscription; switch (info.whichSubscriptionType()) { case SubscriptionType.organization: - return OrgSubscriptionStatusModel(src); + return OrgSubscriptionStatusModel() + .._canConfigureLandscape = canConfigureLandscape; case SubscriptionType.user: - return UserSubscriptionStatusModel(src, client); + return UserSubscriptionStatusModel(client) + .._canConfigureLandscape = canConfigureLandscape; case SubscriptionType.microsoftStore: - return StoreSubscriptionStatusModel(src, info.productId); + return StoreSubscriptionStatusModel(info.productId) + .._canConfigureLandscape = canConfigureLandscape; case SubscriptionType.none: case SubscriptionType.notSet: throw UnimplementedError('Unknown subscription type'); } } - SubscriptionStatusModel._(ConfigSources src) - : _canConfigureLandscape = !src.landscapeSource.hasOrganization(); + SubscriptionStatusModel._(); - final bool _canConfigureLandscape; + /// Tells whether we can invoke the Landscape configuration page or not. bool get canConfigureLandscape => _canConfigureLandscape; + bool _canConfigureLandscape = false; } /// Represents an active subscription through Microsoft Store. @@ -37,7 +47,7 @@ class StoreSubscriptionStatusModel extends SubscriptionStatusModel { @visibleForTesting final Uri uri; - StoreSubscriptionStatusModel(super.src, String productID) + StoreSubscriptionStatusModel(String productID) : uri = Uri.https( 'account.microsoft.com', '/services/${productID.toLowerCase()}/details#billing', @@ -51,7 +61,7 @@ class StoreSubscriptionStatusModel extends SubscriptionStatusModel { /// Represents a subscription in which the user manually provided the Pro token. /// The only action supported is Pro-detaching all instances. class UserSubscriptionStatusModel extends SubscriptionStatusModel { - UserSubscriptionStatusModel(super.src, this._client) : super._(); + UserSubscriptionStatusModel(this._client) : super._(); final AgentApiClient _client; @@ -62,5 +72,5 @@ class UserSubscriptionStatusModel extends SubscriptionStatusModel { /// Represents a subscription provided by the user's Organization. /// There is no action supported. class OrgSubscriptionStatusModel extends SubscriptionStatusModel { - OrgSubscriptionStatusModel(super.src) : super._(); + OrgSubscriptionStatusModel() : super._(); } diff --git a/gui/packages/ubuntupro/lib/pages/subscription_status/subscription_status_page.dart b/gui/packages/ubuntupro/lib/pages/subscription_status/subscription_status_page.dart index f7c45938c..f017bb136 100644 --- a/gui/packages/ubuntupro/lib/pages/subscription_status/subscription_status_page.dart +++ b/gui/packages/ubuntupro/lib/pages/subscription_status/subscription_status_page.dart @@ -85,8 +85,14 @@ class SubscriptionStatusPage extends StatelessWidget { /// Initializes the view-model and inject it in the widget tree so the child page can access it via the BuildContext. static Widget create(BuildContext context) { final client = getService(); + final landscapeFeatureIsEnabled = + Wizard.of(context).routeData as bool? ?? false; return ProxyProvider, SubscriptionStatusModel>( - update: (context, src, _) => SubscriptionStatusModel(src.value, client), + update: (context, src, _) => SubscriptionStatusModel( + src.value, + client, + canConfigureLandscape: landscapeFeatureIsEnabled, + ), child: const SubscriptionStatusPage(), ); } diff --git a/gui/packages/ubuntupro/pubspec.lock b/gui/packages/ubuntupro/pubspec.lock index 10dfd2b9e..be0670ec9 100644 --- a/gui/packages/ubuntupro/pubspec.lock +++ b/gui/packages/ubuntupro/pubspec.lock @@ -12,11 +12,9 @@ packages: agentapi: dependency: "direct main" description: - path: "agentapi/dart" - ref: HEAD - resolved-ref: e42ea3c8b6eb3ed019d53270f56d9697de2aabe2 - url: "https://github.com/canonical/ubuntu-pro-for-wsl" - source: git + path: "../../../agentapi/dart" + relative: true + source: path version: "0.0.1" analyzer: dependency: transitive @@ -576,11 +574,9 @@ packages: p4w_ms_store: dependency: "direct main" description: - path: "gui/packages/p4w_ms_store" - ref: HEAD - resolved-ref: e42ea3c8b6eb3ed019d53270f56d9697de2aabe2 - url: "https://github.com/canonical/ubuntu-pro-for-wsl" - source: git + path: "../p4w_ms_store" + relative: true + source: path version: "0.0.1" package_config: dependency: transitive @@ -964,13 +960,21 @@ packages: source: hosted version: "3.0.3" win32: - dependency: transitive + dependency: "direct main" description: name: win32 - sha256: "0a989dc7ca2bb51eac91e8fd00851297cfffd641aa7538b165c62637ca0eaa4a" + sha256: "0eaf06e3446824099858367950a813472af675116bf63f008a4c2a75ae13e9cb" + url: "https://pub.dev" + source: hosted + version: "5.5.0" + win32_registry: + dependency: "direct main" + description: + name: win32_registry + sha256: "10589e0d7f4e053f2c61023a31c9ce01146656a70b7b7f0828c0b46d7da2a9bb" url: "https://pub.dev" source: hosted - version: "5.4.0" + version: "1.1.3" window_manager: dependency: transitive description: diff --git a/gui/packages/ubuntupro/pubspec.yaml b/gui/packages/ubuntupro/pubspec.yaml index 7594254de..55b5f616c 100644 --- a/gui/packages/ubuntupro/pubspec.yaml +++ b/gui/packages/ubuntupro/pubspec.yaml @@ -56,6 +56,8 @@ dependencies: provider: ^6.1.2 ubuntu_service: ^0.3.2 url_launcher: ^6.3.0 + win32: ^5.5.0 + win32_registry: ^1.1.3 windows_single_instance: ^1.0.1 wizard_router: ^1.2.0 yaru: ^4.0.0 diff --git a/gui/packages/ubuntupro/test/core/settings_test.dart b/gui/packages/ubuntupro/test/core/settings_test.dart new file mode 100644 index 000000000..95de37808 --- /dev/null +++ b/gui/packages/ubuntupro/test/core/settings_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:ubuntupro/core/settings.dart'; + +import 'settings_test.mocks.dart'; + +@GenerateMocks([SettingsRepository]) +void main() { + group('with options', () { + test('all', () { + final settings = Settings.withOptions(Options.withAll); + + expect(settings.isLandscapeConfigurationEnabled, isTrue); + expect(settings.isStorePurchaseAllowed, isTrue); + }); + test('Landscape', () { + final settings = Settings.withOptions(Options.withLandscapeConfiguration); + + expect(settings.isLandscapeConfigurationEnabled, isTrue); + expect(settings.isStorePurchaseAllowed, isFalse); + }); + test('purchase', () { + final settings = Settings.withOptions(Options.withStorePurchase); + + expect(settings.isLandscapeConfigurationEnabled, isFalse); + expect(settings.isStorePurchaseAllowed, isTrue); + }); + test('none', () { + final settings = Settings.withOptions(Options.none); + + expect(settings.isLandscapeConfigurationEnabled, isFalse); + expect(settings.isStorePurchaseAllowed, isFalse); + }); + }); + + group('from repository', () { + test('all', () { + final repository = MockSettingsRepository(); + when(repository.load()).thenReturn(true); + when(repository.readInt(Settings.kShowLandscapeConfig)).thenReturn(1); + when(repository.readInt(Settings.kAllowStorePurchase)).thenReturn(1); + + final settings = Settings(repository); + + expect(settings.isLandscapeConfigurationEnabled, isTrue); + expect(settings.isStorePurchaseAllowed, isTrue); + }); + test('Landscape', () { + final repository = MockSettingsRepository(); + when(repository.load()).thenReturn(true); + when(repository.readInt(Settings.kShowLandscapeConfig)).thenReturn(1); + when(repository.readInt(Settings.kAllowStorePurchase)).thenReturn(0); + + final settings = Settings(repository); + + expect(settings.isLandscapeConfigurationEnabled, isTrue); + expect(settings.isStorePurchaseAllowed, isFalse); + }); + test('purchase', () { + final repository = MockSettingsRepository(); + when(repository.load()).thenReturn(true); + when(repository.readInt(Settings.kShowLandscapeConfig)).thenReturn(0); + when(repository.readInt(Settings.kAllowStorePurchase)).thenReturn(1); + + final settings = Settings(repository); + + expect(settings.isLandscapeConfigurationEnabled, isFalse); + expect(settings.isStorePurchaseAllowed, isTrue); + }); + test('none', () { + final repository = MockSettingsRepository(); + when(repository.load()).thenReturn(true); + when(repository.readInt(Settings.kShowLandscapeConfig)).thenReturn(null); + when(repository.readInt(Settings.kAllowStorePurchase)).thenReturn(null); + + final settings = Settings(repository); + + expect(settings.isLandscapeConfigurationEnabled, isFalse); + expect(settings.isStorePurchaseAllowed, isFalse); + }); + test('unset', () { + final repository = MockSettingsRepository(); + when(repository.load()).thenReturn(false); + + final settings = Settings(repository); + + expect(settings.isLandscapeConfigurationEnabled, isFalse); + expect(settings.isStorePurchaseAllowed, isFalse); + }); + }); + + group('repository', () { + test('no crash if not load', () { + final r = SettingsRepository(); + expect(r.readInt('AKey'), isNull); + r.close(); // no crash + }); + + test( + 'no crash on load', + () { + final r = SettingsRepository(); + // We cannot assert many things as the system may have the key. + // I'd rather avoid touching the real registry unless we really believe necessary. + r.load(); + r.close(); // no crash + }, + // depends on the real registry. + testOn: 'windows', + ); + }); +} diff --git a/gui/packages/ubuntupro/test/core/settings_test.mocks.dart b/gui/packages/ubuntupro/test/core/settings_test.mocks.dart new file mode 100644 index 000000000..d04c4f458 --- /dev/null +++ b/gui/packages/ubuntupro/test/core/settings_test.mocks.dart @@ -0,0 +1,54 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in ubuntupro/test/core/settings_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:mockito/mockito.dart' as _i1; +import 'package:ubuntupro/core/settings.dart' as _i2; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [SettingsRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSettingsRepository extends _i1.Mock + implements _i2.SettingsRepository { + MockSettingsRepository() { + _i1.throwOnMissingStub(this); + } + + @override + void close() => super.noSuchMethod( + Invocation.method( + #close, + [], + ), + returnValueForMissingStub: null, + ); + + @override + int? readInt(String? name) => (super.noSuchMethod(Invocation.method( + #readInt, + [name], + )) as int?); + + @override + bool load() => (super.noSuchMethod( + Invocation.method( + #load, + [], + ), + returnValue: false, + ) as bool); +} diff --git a/gui/packages/ubuntupro/test/pages/subcribe_now/subscribe_now_model_test.dart b/gui/packages/ubuntupro/test/pages/subcribe_now/subscribe_now_model_test.dart index a7fe220ea..c312735ea 100644 --- a/gui/packages/ubuntupro/test/pages/subcribe_now/subscribe_now_model_test.dart +++ b/gui/packages/ubuntupro/test/pages/subcribe_now/subscribe_now_model_test.dart @@ -29,7 +29,7 @@ void main() { test('disabled by default', () { final model = SubscribeNowModel(client); - expect(model.purchaseAllowed(), isFalse); + expect(model.purchaseAllowed, isFalse); }); test('expected failure', () async { diff --git a/gui/packages/ubuntupro/test/pages/subcribe_now/subscribe_now_page_test.dart b/gui/packages/ubuntupro/test/pages/subcribe_now/subscribe_now_page_test.dart index 38c2dcaf3..4171debbf 100644 --- a/gui/packages/ubuntupro/test/pages/subcribe_now/subscribe_now_page_test.dart +++ b/gui/packages/ubuntupro/test/pages/subcribe_now/subscribe_now_page_test.dart @@ -7,9 +7,12 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:p4w_ms_store/p4w_ms_store.dart'; import 'package:provider/provider.dart'; +import 'package:ubuntu_service/ubuntu_service.dart'; +import 'package:ubuntupro/core/agent_api_client.dart'; import 'package:ubuntupro/pages/subscribe_now/subscribe_now_model.dart'; import 'package:ubuntupro/pages/subscribe_now/subscribe_now_page.dart'; import 'package:ubuntupro/pages/subscribe_now/subscribe_now_widgets.dart'; +import 'package:wizard_router/wizard_router.dart'; import '../../utils/build_multiprovider_app.dart'; import 'subscribe_now_page_test.mocks.dart'; import 'token_samples.dart' as tks; @@ -25,7 +28,7 @@ void main() { testWidgets('launch web page', (tester) async { final model = MockSubscribeNowModel(); - when(model.purchaseAllowed()).thenReturn(true); + when(model.purchaseAllowed).thenReturn(true); var called = false; when(model.launchProWebPage()).thenAnswer((_) async { called = true; @@ -44,7 +47,7 @@ void main() { group('purchase button enabled by model', () { testWidgets('disabled', (tester) async { final model = MockSubscribeNowModel(); - when(model.purchaseAllowed()).thenReturn(false); + when(model.purchaseAllowed).thenReturn(false); final app = buildApp(model, (_) {}); await tester.pumpWidget(app); final context = tester.element(find.byType(SubscribeNowPage)); @@ -60,7 +63,7 @@ void main() { }); testWidgets('enabled', (tester) async { final model = MockSubscribeNowModel(); - when(model.purchaseAllowed()).thenReturn(true); + when(model.purchaseAllowed).thenReturn(true); final app = buildApp(model, (_) {}); await tester.pumpWidget(app); final context = tester.element(find.byType(SubscribeNowPage)); @@ -78,7 +81,7 @@ void main() { group('subscribe', () { testWidgets('calls back on success', (tester) async { final model = MockSubscribeNowModel(); - when(model.purchaseAllowed()).thenReturn(true); + when(model.purchaseAllowed).thenReturn(true); var called = false; when(model.purchaseSubscription()).thenAnswer((_) async { final info = SubscriptionInfo()..ensureMicrosoftStore(); @@ -101,7 +104,7 @@ void main() { testWidgets('feedback on error', (tester) async { const purchaseError = PurchaseStatus.networkError; final model = MockSubscribeNowModel(); - when(model.purchaseAllowed()).thenReturn(true); + when(model.purchaseAllowed).thenReturn(true); var called = false; when(model.purchaseSubscription()).thenAnswer((_) async { return purchaseError.left(); @@ -124,7 +127,7 @@ void main() { }); testWidgets('feedback when applying token', (tester) async { final model = MockSubscribeNowModel(); - when(model.purchaseAllowed()).thenReturn(true); + when(model.purchaseAllowed).thenReturn(true); when(model.applyProToken(any)).thenAnswer((_) async { return SubscriptionInfo()..ensureUser(); }); @@ -154,7 +157,7 @@ void main() { testWidgets('purchase status enum l10n', (tester) async { final model = MockSubscribeNowModel(); - when(model.purchaseAllowed()).thenReturn(true); + when(model.purchaseAllowed).thenReturn(true); final app = buildApp(model, onSubscribeNoop); await tester.pumpWidget(app); final context = tester.element(find.byType(SubscribeNowPage)); @@ -164,6 +167,28 @@ void main() { expect(() => value.localize(lang), returnsNormally); } }); + + testWidgets('creates a model', (tester) async { + registerServiceInstance(FakeAgentApiClient()); + final app = buildMultiProviderWizardApp( + routes: {'/': const WizardRoute(builder: SubscribeNowPage.create)}, + providers: [ + ChangeNotifierProvider( + create: (_) => ValueNotifier( + ConfigSources(proSubscription: SubscriptionInfo()..ensureUser()), + ), + ), + ], + ); + + await tester.pumpWidget(app); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(SubscribeNowPage)); + final model = Provider.of(context, listen: false); + + expect(model, isNotNull); + }); } Widget buildApp( @@ -181,3 +206,5 @@ Widget buildApp( } void onSubscribeNoop(SubscriptionInfo _) {} + +class FakeAgentApiClient extends Fake implements AgentApiClient {} diff --git a/gui/packages/ubuntupro/test/pages/subcribe_now/subscribe_now_page_test.mocks.dart b/gui/packages/ubuntupro/test/pages/subcribe_now/subscribe_now_page_test.mocks.dart index a3007b068..08a9daa02 100644 --- a/gui/packages/ubuntupro/test/pages/subcribe_now/subscribe_now_page_test.mocks.dart +++ b/gui/packages/ubuntupro/test/pages/subcribe_now/subscribe_now_page_test.mocks.dart @@ -75,6 +75,12 @@ class MockSubscribeNowModel extends _i1.Mock implements _i5.SubscribeNowModel { ), ) as _i2.AgentApiClient); + @override + bool get purchaseAllowed => (super.noSuchMethod( + Invocation.getter(#purchaseAllowed), + returnValue: false, + ) as bool); + @override _i6.Future<_i3.SubscriptionInfo> applyProToken(_i7.ProToken? token) => (super.noSuchMethod( @@ -119,13 +125,4 @@ class MockSubscribeNowModel extends _i1.Mock implements _i5.SubscribeNowModel { )), ) as _i6 .Future<_i4.Either<_i8.PurchaseStatus, _i3.SubscriptionInfo>>); - - @override - bool purchaseAllowed() => (super.noSuchMethod( - Invocation.method( - #purchaseAllowed, - [], - ), - returnValue: false, - ) as bool); } diff --git a/gui/packages/ubuntupro/test/pages/subscription_status/subscription_status_model_test.dart b/gui/packages/ubuntupro/test/pages/subscription_status/subscription_status_model_test.dart index f2f8d1b55..77ff6f06f 100644 --- a/gui/packages/ubuntupro/test/pages/subscription_status/subscription_status_model_test.dart +++ b/gui/packages/ubuntupro/test/pages/subscription_status/subscription_status_model_test.dart @@ -80,7 +80,7 @@ void main() { group('config Landscape:', () { final client = MockAgentApiClient(); - final susbcriptions = [ + final subscriptions = [ SubscriptionInfo()..ensureOrganization(), SubscriptionInfo()..ensureMicrosoftStore(), SubscriptionInfo()..ensureUser(), @@ -91,32 +91,44 @@ void main() { LandscapeSource()..ensureUser(), ]; - String makeSubTestName(LandscapeSource landscape, SubscriptionInfo sub) { + String makeSubTestName( + LandscapeSource landscape, + SubscriptionInfo sub, + bool globallyEnabled, + ) { + if (!globallyEnabled) { + return 'landscape ${landscape.toString().split(':').first} with pro ${sub.toString().split(':').first} => disallowed globally'; + } + final want = landscape.hasOrganization() ? 'disallowed' : 'allowed'; return 'landscape ${landscape.toString().split(':').first} with pro ${sub.toString().split(':').first} => $want'; } - for (final subs in susbcriptions) { - for (final landscape in landscapeSources) { - test(makeSubTestName(landscape, subs), () { - final want = landscape.hasOrganization() ? isFalse : isTrue; - - final model = SubscriptionStatusModel( - ConfigSources( - proSubscription: subs, - landscapeSource: landscape, - ), - client, - ); - expect(model.canConfigureLandscape, want); - }); + for (final enabled in [true, false]) { + for (final subs in subscriptions) { + for (final landscape in landscapeSources) { + test(makeSubTestName(landscape, subs, enabled), () { + final want = + enabled && !landscape.hasOrganization() ? isTrue : isFalse; + + final model = SubscriptionStatusModel( + ConfigSources( + proSubscription: subs, + landscapeSource: landscape, + ), + client, + canConfigureLandscape: enabled, + ); + expect(model.canConfigureLandscape, want); + }); + } } } }); test('ms account link', () { const product = 'id'; - final model = StoreSubscriptionStatusModel(ConfigSources(), product); + final model = StoreSubscriptionStatusModel(product); expect(model.uri.pathSegments, contains(product)); }); @@ -131,7 +143,7 @@ void main() { } return SubscriptionInfo()..ensureNone(); }); - final model = UserSubscriptionStatusModel(ConfigSources(), client); + final model = UserSubscriptionStatusModel(client); // asserts that detachPro calls applyProToken with an empty String. expect(token, isNull); diff --git a/gui/packages/ubuntupro/test/pages/subscription_status/subscription_status_page_test.dart b/gui/packages/ubuntupro/test/pages/subscription_status/subscription_status_page_test.dart index c6d41d550..e2dacc7e6 100644 --- a/gui/packages/ubuntupro/test/pages/subscription_status/subscription_status_page_test.dart +++ b/gui/packages/ubuntupro/test/pages/subscription_status/subscription_status_page_test.dart @@ -100,6 +100,65 @@ void main() { expect(find.text(lang.landscapeConfigureButton), findsOneWidget); }); }); + + group('landscape feature disabled:', () { + testWidgets('user', (tester) async { + final landscape = LandscapeSource()..ensureNone(); + info.ensureUser(); + final app = buildApp( + info, + landscape, + client, + landscapeFeatureIsEnabled: false, + ); + + await tester.pumpWidget(app); + + final context = tester.element(find.byType(SubscriptionStatusPage)); + final lang = AppLocalizations.of(context); + + expect(find.text(lang.detachPro), findsOneWidget); + expect(find.text(lang.landscapeConfigureButton), findsNothing); + }); + + testWidgets('store', (tester) async { + final landscape = LandscapeSource()..ensureUser(); + info.ensureMicrosoftStore(); + final app = buildApp( + info, + landscape, + client, + landscapeFeatureIsEnabled: false, + ); + + await tester.pumpWidget(app); + + final context = tester.element(find.byType(SubscriptionStatusPage)); + final lang = AppLocalizations.of(context); + + expect(find.text(lang.manageSubscription), findsOneWidget); + expect(find.text(lang.landscapeConfigureButton), findsNothing); + }); + + testWidgets('organization', (tester) async { + final landscape = LandscapeSource(); + info.ensureOrganization(); + final app = buildApp( + info, + landscape, + client, + landscapeFeatureIsEnabled: false, + ); + + await tester.pumpWidget(app); + + final context = tester.element(find.byType(SubscriptionStatusPage)); + final lang = AppLocalizations.of(context); + + expect(find.text(lang.orgManaged), findsOneWidget); + expect(find.text(lang.landscapeConfigureButton), findsNothing); + }); + }); }); testWidgets('creates a model', (tester) async { final app = buildMultiProviderWizardApp( @@ -211,8 +270,9 @@ void main() { Widget buildApp( SubscriptionInfo info, LandscapeSource landscape, - AgentApiClient client, -) { + AgentApiClient client, { + bool landscapeFeatureIsEnabled = true, +}) { return buildMultiProviderWizardApp( routes: { '/': WizardRoute( @@ -224,6 +284,7 @@ Widget buildApp( create: (_) => SubscriptionStatusModel( ConfigSources(proSubscription: info, landscapeSource: landscape), client, + canConfigureLandscape: landscapeFeatureIsEnabled, ), ), ],