From 14ab5352a3076c0c806a9df70e9d3254970f21bb Mon Sep 17 00:00:00 2001 From: dab246 Date: Wed, 12 Nov 2025 18:24:51 +0700 Subject: [PATCH] TF-4145 Use `generateWebLink` for paywall url --- core/lib/utils/web_link_generator.dart | 36 +++++----- core/test/utils/web_link_generator_test.dart | 70 ++++++++++++++----- .../presentation/paywall_controller.dart | 31 ++++++-- 3 files changed, 96 insertions(+), 41 deletions(-) diff --git a/core/lib/utils/web_link_generator.dart b/core/lib/utils/web_link_generator.dart index e01275a58..5798a0759 100644 --- a/core/lib/utils/web_link_generator.dart +++ b/core/lib/utils/web_link_generator.dart @@ -2,16 +2,14 @@ import 'package:core/core.dart'; /// Utility class for generating web links in flat subdomain format. class WebLinkGenerator { - const WebLinkGenerator(); - /// Ensures that a path starts with `/`. - String ensureFirstSlash(String? path) { + static String ensureFirstSlash(String? path) { if (path == null || path.isEmpty) return '/'; return path.startsWith('/') ? path : '/$path'; } /// Validates if a given FQDN looks valid (has at least 2 parts and non-empty). - bool isValidFqdn(String fqdn) { + static bool isValidFqdn(String fqdn) { if (fqdn.trim().isEmpty || !fqdn.contains('.')) return false; final parts = fqdn.split('.').where((p) => p.trim().isNotEmpty).toList(); return parts.length >= 2; @@ -20,9 +18,9 @@ class WebLinkGenerator { /// Generates a web link based on a workplace FQDN and app slug. /// /// Throws [ArgumentError] when input is invalid. - String generateWebLink({ + static String generateWebLink({ required String workplaceFqdn, - required String slug, + String? slug, String? pathname, String? hash, List>? searchParams, @@ -31,16 +29,22 @@ class WebLinkGenerator { throw ArgumentError('Invalid workplace FQDN: $workplaceFqdn'); } - final hostParts = workplaceFqdn - .split('.') - .where((p) => p.trim().isNotEmpty) - .toList(); + // Start from the base host + String newHost = workplaceFqdn; - hostParts[0] = '${hostParts[0]}-$slug'; - final newHost = hostParts.join('.'); + // Only modify the host when slug is non-null and not empty + if (slug != null && slug.trim().isNotEmpty) { + final hostParts = + workplaceFqdn.split('.').where((p) => p.trim().isNotEmpty).toList(); + + // Insert slug as a prefix to the first part (flat subdomain) + hostParts[0] = '${hostParts[0]}-$slug'; + newHost = hostParts.join('.'); + } final safePath = ensureFirstSlash(pathname); - final safeHash = (hash == null || hash.isEmpty) ? null : ensureFirstSlash(hash); + final safeHash = + (hash == null || hash.isEmpty) ? null : ensureFirstSlash(hash); final queryParams = {}; for (final param in searchParams ?? const []) { @@ -61,16 +65,14 @@ class WebLinkGenerator { /// A safe version of [generateWebLink] that **never throws**. /// /// Returns `''` (empty string) when an error occurs. - String safeGenerateWebLink({ + static String safeGenerateWebLink({ required String workplaceFqdn, - required String slug, + String? slug, String? pathname, String? hash, List>? searchParams, }) { try { - if (slug.trim().isEmpty) return ''; - return generateWebLink( workplaceFqdn: workplaceFqdn, slug: slug, diff --git a/core/test/utils/web_link_generator_test.dart b/core/test/utils/web_link_generator_test.dart index 7884d162c..d93d217aa 100644 --- a/core/test/utils/web_link_generator_test.dart +++ b/core/test/utils/web_link_generator_test.dart @@ -2,11 +2,9 @@ import 'package:core/utils/web_link_generator.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - group('WebLinkGenerator (with workplaceFqdn)', () { - const generator = WebLinkGenerator(); - + group('WebLinkGenerator', () { test('should generate correct link for flat subdomain', () { - final result = generator.generateWebLink( + final result = WebLinkGenerator.generateWebLink( workplaceFqdn: 'alice.example.app', searchParams: [ ['sharecode', 'sharingIsCaring'], @@ -24,7 +22,7 @@ void main() { }); test('should prepend slash to path and hash if missing', () { - final result = generator.generateWebLink( + final result = WebLinkGenerator.generateWebLink( workplaceFqdn: 'bob.example.tools', pathname: 'files', hash: 'folder/123', @@ -35,7 +33,7 @@ void main() { }); test('should work with no search params', () { - final result = generator.generateWebLink( + final result = WebLinkGenerator.generateWebLink( workplaceFqdn: 'charlie.domain.com', slug: 'settings', ); @@ -43,9 +41,9 @@ void main() { expect(result, 'https://charlie-settings.domain.com/'); }); - test('should throw when workplaceFqdn is empty', () { + test('✅ should throw when workplaceFqdn is empty', () { expect( - () => generator.generateWebLink( + () => WebLinkGenerator.generateWebLink( workplaceFqdn: '', slug: 'notes', ), @@ -55,7 +53,7 @@ void main() { test('should throw when workplaceFqdn only contains TLD', () { expect( - () => generator.generateWebLink( + () => WebLinkGenerator.generateWebLink( workplaceFqdn: 'com', slug: 'notes', ), @@ -65,7 +63,7 @@ void main() { test('should throw when workplaceFqdn has no dot', () { expect( - () => generator.generateWebLink( + () => WebLinkGenerator.generateWebLink( workplaceFqdn: 'localhost', slug: 'notes', ), @@ -74,7 +72,7 @@ void main() { }); test('should handle malformed FQDN gracefully (multiple dots)', () { - final result = generator.generateWebLink( + final result = WebLinkGenerator.generateWebLink( workplaceFqdn: '....example..app', slug: 'notes', ); @@ -84,7 +82,7 @@ void main() { }); test('should ignore malformed searchParams', () { - final result = generator.generateWebLink( + final result = WebLinkGenerator.generateWebLink( workplaceFqdn: 'dave.example.app', slug: 'drive', searchParams: [ @@ -97,7 +95,7 @@ void main() { }); test('should not add fragment when hash is empty string', () { - final result = generator.generateWebLink( + final result = WebLinkGenerator.generateWebLink( workplaceFqdn: 'eve.example.app', slug: 'settings', hash: '', @@ -106,8 +104,35 @@ void main() { expect(result, 'https://eve-settings.example.app/'); }); + test('should keep original host when slug is null', () { + final result = WebLinkGenerator.generateWebLink( + workplaceFqdn: 'alice.example.app', + slug: null, + ); + + expect(result, 'https://alice.example.app/'); + }); + + test('should keep original host when slug is empty', () { + final result = WebLinkGenerator.generateWebLink( + workplaceFqdn: 'alice.example.app', + slug: '', + ); + + expect(result, 'https://alice.example.app/'); + }); + + test('should keep original host when slug is only whitespace', () { + final result = WebLinkGenerator.generateWebLink( + workplaceFqdn: 'bob.example.app', + slug: ' ', + ); + + expect(result, 'https://bob.example.app/'); + }); + test('safeGenerateWebLink should return empty string on invalid FQDN', () { - final result = generator.safeGenerateWebLink( + final result = WebLinkGenerator.safeGenerateWebLink( workplaceFqdn: '', slug: 'notes', ); @@ -115,22 +140,31 @@ void main() { expect(result, ''); }); - test('safeGenerateWebLink should return empty string on missing slug', () { - final result = generator.safeGenerateWebLink( + test('safeGenerateWebLink should return valid URL on missing slug', () { + final result = WebLinkGenerator.safeGenerateWebLink( workplaceFqdn: 'alice.example.app', slug: '', ); - expect(result, ''); + expect(result, 'https://alice.example.app/'); }); test('safeGenerateWebLink should still return valid URL when input ok', () { - final result = generator.safeGenerateWebLink( + final result = WebLinkGenerator.safeGenerateWebLink( workplaceFqdn: 'bob.example.app', slug: 'drive', ); expect(result, 'https://bob-drive.example.app/'); }); + + test('safeGenerateWebLink should handle slug = null gracefully', () { + final result = WebLinkGenerator.safeGenerateWebLink( + workplaceFqdn: 'example.com', + slug: null, + ); + + expect(result, 'https://example.com/'); + }); }); } diff --git a/lib/features/paywall/presentation/paywall_controller.dart b/lib/features/paywall/presentation/paywall_controller.dart index 3e2d87af7..717489b22 100644 --- a/lib/features/paywall/presentation/paywall_controller.dart +++ b/lib/features/paywall/presentation/paywall_controller.dart @@ -1,6 +1,8 @@ import 'package:core/presentation/extensions/color_extension.dart'; import 'package:core/presentation/state/failure.dart'; import 'package:core/presentation/state/success.dart'; +import 'package:core/utils/app_logger.dart'; +import 'package:core/utils/web_link_generator.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:tmail_ui_user/features/base/base_controller.dart'; @@ -39,10 +41,7 @@ class PaywallController extends BaseController { paywallUrlPattern = newPattern; if (isRetryGetPaywallUrl) { isRetryGetPaywallUrl = false; - final qualifiedPaywall = paywallUrlPattern!.getQualifiedUrl( - ownerEmail: ownEmailAddress, - domainName: RouteUtils.getRootDomain(), - ); + final qualifiedPaywall = getPaywallUrl(paywallUrlPattern!); AppUtils.launchLink(qualifiedPaywall); } } @@ -88,13 +87,33 @@ class PaywallController extends BaseController { _showMessagePaywallUrlNotAvailable(context: context); return; } + final qualifiedPaywall = getPaywallUrl(paywallUrlPattern!); + AppUtils.launchLink(qualifiedPaywall); + } - final qualifiedPaywall = paywallUrlPattern!.getQualifiedUrl( + String getPaywallUrl(PaywallUrlPattern pattern) { + final defaultUrl = pattern.getQualifiedUrl( ownerEmail: ownEmailAddress, domainName: RouteUtils.getRootDomain(), ); - AppUtils.launchLink(qualifiedPaywall); + try { + final workplaceFqdn = twakeAppManager.oidcUserInfo?.workplaceFqdn?.trim(); + if (workplaceFqdn == null || workplaceFqdn.isEmpty) { + return defaultUrl; + } + + final paywallUrl = WebLinkGenerator.safeGenerateWebLink( + workplaceFqdn: workplaceFqdn, + pathname: 'paywall', + ); + + // Fallback if generated URL is empty or invalid + return paywallUrl.isNotEmpty ? paywallUrl : defaultUrl; + } catch (e) { + logError('$runtimeType::getPaywallUrl: Failed to generate paywall URL: $e'); + return defaultUrl; + } } void _handleRetryGetPaywallUrl() {