diff --git a/app/lib/account/like_backend.dart b/app/lib/account/like_backend.dart index 5cc1873ab9..3d33de72ef 100644 --- a/app/lib/account/like_backend.dart +++ b/app/lib/account/like_backend.dart @@ -34,13 +34,14 @@ class LikeBackend { } /// Returns a list with [LikeData] of all the packages that the given - /// [user] likes. - Future> listPackageLikes(User user) async { - return (await cache.userPackageLikes(user.userId).get(() async { + /// user's likes. + Future> listPackageLikes(String userId) async { + return (await cache.userPackageLikes(userId).get(() async { // TODO(zarah): Introduce pagination and/or migrate this to search. - final query = _db.query(ancestorKey: user.key) - ..order('-created') - ..limit(1000); + final query = + _db.query(ancestorKey: _db.emptyKey.append(User, id: userId)) + ..order('-created') + ..limit(1000); final likes = await query.run().toList(); return likes.map((Like l) => LikeData.fromModel(l)).toList(); }))!; diff --git a/app/lib/admin/backend.dart b/app/lib/admin/backend.dart index 0f0abf8638..048cefcff7 100644 --- a/app/lib/admin/backend.dart +++ b/app/lib/admin/backend.dart @@ -178,7 +178,7 @@ class AdminBackend { Future _removeAndDecrementLikes(User user) async { final pool = Pool(5); final futures = []; - for (final like in await likeBackend.listPackageLikes(user)) { + for (final like in await likeBackend.listPackageLikes(user.userId)) { final f = pool .withResource(() => likeBackend.unlikePackage(user, like.package!)); futures.add(f); diff --git a/app/lib/frontend/handlers/account.dart b/app/lib/frontend/handlers/account.dart index c55470a174..de83bb89d6 100644 --- a/app/lib/frontend/handlers/account.dart +++ b/app/lib/frontend/handlers/account.dart @@ -197,7 +197,7 @@ Future listPackageLikesHandler( shelf.Request request) async { final authenticatedUser = await requireAuthenticatedWebUser(); final user = authenticatedUser.user; - final packages = await likeBackend.listPackageLikes(user); + final packages = await likeBackend.listPackageLikes(user.userId); final List packageLikes = packages .map((like) => PackageLikeResponse( liked: true, package: like.package, created: like.created)) @@ -294,7 +294,7 @@ Future accountMyLikedPackagesPageHandler( final user = (await accountBackend .lookupUserById(requestContext.authenticatedUserId!))!; - final likes = await likeBackend.listPackageLikes(user); + final likes = await likeBackend.listPackageLikes(user.userId); final html = renderMyLikedPackagesPage( user: user, userSessionData: requestContext.sessionData!, diff --git a/app/lib/frontend/handlers/experimental.dart b/app/lib/frontend/handlers/experimental.dart index 2177c6a3b7..01756f1254 100644 --- a/app/lib/frontend/handlers/experimental.dart +++ b/app/lib/frontend/handlers/experimental.dart @@ -10,15 +10,11 @@ typedef PublicFlag = ({String name, String description}); const _publicFlags = { (name: 'example', description: 'Short description'), - ( - name: 'trending-search', - description: 'Show trending packages and search by trending scores' - ), }; final _allFlags = { 'dark-as-default', - 'search-post', + 'my-liked-search', ..._publicFlags.map((x) => x.name), }; @@ -93,7 +89,7 @@ class ExperimentalFlags { bool get isDarkModeDefault => isEnabled('dark-as-default'); - bool get useSearchPost => isEnabled('search-post'); + bool get useMyLikedSearch => isEnabled('my-liked-search'); String encodedAsCookie() => _enabled.join(':'); diff --git a/app/lib/search/mem_index.dart b/app/lib/search/mem_index.dart index 4ff0e2eff8..8dd00a3a7c 100644 --- a/app/lib/search/mem_index.dart +++ b/app/lib/search/mem_index.dart @@ -6,6 +6,7 @@ import 'dart:math' as math; import 'package:_pub_shared/search/search_form.dart'; import 'package:_pub_shared/search/search_request_data.dart'; +import 'package:_pub_shared/search/tags.dart'; import 'package:clock/clock.dart'; import 'package:collection/collection.dart'; import 'package:logging/logging.dart'; @@ -126,6 +127,13 @@ class InMemoryPackageIndex { if (query.offset >= _documents.length) { return PackageSearchResult.empty(); } + // Special case for the account-related tag. + if (query.parsedQuery.tagsPredicate.hasTag(AccountTag.isLikedByMe)) { + return PackageSearchResult.error( + statusCode: 400, + errorMessage: '`is:liked-by-me` is only for authenticated users.', + ); + } return _bitArrayPool.withPoolItem(fn: (array) { return _scorePool.withItemGetter( (scoreFn) { diff --git a/app/lib/search/search_client.dart b/app/lib/search/search_client.dart index ced8690ba9..a85b78744b 100644 --- a/app/lib/search/search_client.dart +++ b/app/lib/search/search_client.dart @@ -5,12 +5,14 @@ import 'dart:async'; import 'dart:convert'; +import 'package:_pub_shared/search/tags.dart'; import 'package:_pub_shared/utils/http.dart'; import 'package:clock/clock.dart'; import 'package:gcloud/service_scope.dart' as ss; -import 'package:pub_dev/frontend/request_context.dart'; import '../../../service/rate_limit/rate_limit.dart'; +import '../../account/like_backend.dart'; +import '../../frontend/request_context.dart'; import '../shared/configuration.dart'; import '../shared/redis_cache.dart' show cache; import '../shared/utils.dart'; @@ -66,15 +68,40 @@ class SearchClient { skipCache = true; } + final hasLikedByMeTag = + query.parsedQuery.tagsPredicate.hasTag(AccountTag.isLikedByMe); + final userId = requestContext.sessionData?.userId; + if (hasLikedByMeTag) { + skipCache = true; + } + + List? packages; + if (userId != null && hasLikedByMeTag) { + final likedPackages = await likeBackend.listPackageLikes(userId); + packages = likedPackages.map((l) => l.package!).toList(); + } + // Returns the status code and the body of the last response, or null on timeout. Future<({int statusCode, String? body})?> doCallHttpServiceEndpoint( {String? prefix}) async { final httpHostPort = prefix ?? activeConfiguration.searchServicePrefix; try { - if (requestContext.experimentalFlags.useSearchPost) { + if (requestContext.experimentalFlags.useMyLikedSearch) { return await withRetryHttpClient( (client) async { - final data = query.toSearchRequestData(); + var data = query.toSearchRequestData(); + if (userId != null && hasLikedByMeTag) { + final newQuery = + data.query?.replaceAll(AccountTag.isLikedByMe, ' ').trim(); + final newTags = data.tags! + .where((e) => e != AccountTag.isLikedByMe) + .toList(); + data = data.replace( + query: newQuery, + tags: newTags, + packages: packages, + ); + } // NOTE: Keeping the query parameter to help investigating logs. final uri = Uri.parse('$httpHostPort/search').replace( queryParameters: { diff --git a/app/test/admin/user_merger_test.dart b/app/test/admin/user_merger_test.dart index 88630f6aeb..205f20d1a4 100644 --- a/app/test/admin/user_merger_test.dart +++ b/app/test/admin/user_merger_test.dart @@ -150,17 +150,17 @@ void main() { final admin = await accountBackend.lookupUserByEmail('admin@pub.dev'); await likeBackend.likePackage(admin, 'oxygen'); expect( - (await likeBackend.listPackageLikes(admin)) + (await likeBackend.listPackageLikes(admin.userId)) .map((e) => e.package) .toList(), ['oxygen']); - expect(await likeBackend.listPackageLikes(user), isEmpty); + expect(await likeBackend.listPackageLikes(user.userId), isEmpty); await _corruptAndFix(); - expect(await likeBackend.listPackageLikes(admin), isEmpty); + expect(await likeBackend.listPackageLikes(admin.userId), isEmpty); expect( - (await likeBackend.listPackageLikes(user)) + (await likeBackend.listPackageLikes(user.userId)) .map((e) => e.package) .toList(), ['oxygen']); diff --git a/pkg/_pub_shared/lib/search/search_request_data.dart b/pkg/_pub_shared/lib/search/search_request_data.dart index 9a8a5f9f78..989348afa2 100644 --- a/pkg/_pub_shared/lib/search/search_request_data.dart +++ b/pkg/_pub_shared/lib/search/search_request_data.dart @@ -88,6 +88,25 @@ class SearchRequestData { map.removeWhere((k, v) => v == null); return map; } + + /// Creates a new instance with fields being replaced (if provided). + SearchRequestData replace({ + String? query, + List? tags, + List? packages, + }) { + return SearchRequestData( + query: query ?? this.query, + minPoints: minPoints, + publisherId: publisherId, + tags: tags ?? this.tags, + packages: packages ?? this.packages, + order: order, + offset: offset, + limit: limit, + textMatchExtent: textMatchExtent, + ); + } } /// The scope (depth) of the text matching. diff --git a/pkg/_pub_shared/lib/search/tags.dart b/pkg/_pub_shared/lib/search/tags.dart index 944571af45..21d8dd2d0e 100644 --- a/pkg/_pub_shared/lib/search/tags.dart +++ b/pkg/_pub_shared/lib/search/tags.dart @@ -151,6 +151,11 @@ abstract class PlatformTagValue { static const String windows = 'windows'; } +/// Tags that control account-related search features. +abstract class AccountTag { + static const isLikedByMe = 'is:liked-by-me'; +} + /// Tags that may be relevant in search for packages that have preview or /// prerelease version published. const _futureVersionTags = { diff --git a/pkg/pub_integration/test/like_test.dart b/pkg/pub_integration/test/like_test.dart index ce86e69835..017d61e1db 100644 --- a/pkg/pub_integration/test/like_test.dart +++ b/pkg/pub_integration/test/like_test.dart @@ -6,6 +6,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:pub_integration/src/fake_test_context_provider.dart'; +import 'package:pub_integration/src/pub_puppeteer_helpers.dart'; import 'package:pub_integration/src/test_browser.dart'; import 'package:test/test.dart'; @@ -31,6 +32,7 @@ void main() { 'defaultUser': 'admin@pub.dev', 'generatedPackages': [ {'name': 'test_pkg'}, + {'name': 'other_pkg'}, ], }, }, @@ -38,6 +40,23 @@ void main() { ); final user = await fakeTestScenario.createTestUser(email: 'user@pub.dev'); + final anon = await fakeTestScenario.createAnonymousTestUser(); + + // checking that regular search returns two packages + await user.withBrowserPage((page) async { + await page.gotoOrigin('/packages?q=pkg'); + final info = await listingPageInfo(page); + expect(info.packageNames.toSet(), {'test_pkg', 'other_pkg'}); + }); + + // checking that anonymous page request gets an error + await anon.withBrowserPage((page) async { + await page.gotoOrigin('/experimental?my-liked-search=1'); + await page.gotoOrigin('/packages?q=pkg+is:liked-by-me'); + expect(await page.content, contains('is only for authenticated users')); + final info = await listingPageInfo(page); + expect(info.packageNames, isEmpty); + }); await user.withBrowserPage((page) async { Future> getCountLabels() async { @@ -53,6 +72,8 @@ void main() { ]; } + await page.gotoOrigin('/experimental?my-liked-search=1'); + await page.gotoOrigin('/packages/test_pkg'); expect(await getCountLabels(), ['0', '0', '']); @@ -60,6 +81,11 @@ void main() { await Future.delayed(Duration(seconds: 1)); expect(await getCountLabels(), ['1', '1', '']); + // checking search with my-liked packages + await page.gotoOrigin('/packages?q=pkg+is:liked-by-me'); + final info = await listingPageInfo(page); + expect(info.packageNames.toSet(), {'test_pkg'}); + // displaying all three await page.gotoOrigin('/packages/test_pkg/score'); expect(await getCountLabels(), ['1', '1', '1']);