From b2a1cfd96339acfd76600e8a6f9e350b73aa3c1d Mon Sep 17 00:00:00 2001 From: Josh Date: Sun, 14 Jun 2026 11:49:17 -0400 Subject: [PATCH] Scope daily ratings/reviews refresh to each app's tracked storefronts The daily refresh built its request with storefrontSelection: .all(codes:) using the full bundled storefront catalog (~174 countries) for every tracked app. Ratings and reviews are fetched per storefront, so the refresh fanned out across all 174 countries regardless of how many the user actually tracked keywords in. For anyone tracking keywords in more than a few countries this made the daily/long refresh time out. Derive each app's storefront set from the countries it actually tracks keywords in (app.keywordTracks), via a new testable helper DailyRefreshScheduler.ratingsReviewsStorefrontCodes(for:fallback:). Apps with no tracked keywords fall back to the catalog-wide set so current behavior is preserved there. Add DailyRefreshSchedulerTests covering both the scoped and fallback cases. Co-Authored-By: Claude Opus 4.8 --- OpenASO.xcodeproj/project.pbxproj | 4 + .../Persistence/DailyRefreshScheduler.swift | 28 ++++- OpenASOTests/DailyRefreshSchedulerTests.swift | 110 ++++++++++++++++++ 3 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 OpenASOTests/DailyRefreshSchedulerTests.swift diff --git a/OpenASO.xcodeproj/project.pbxproj b/OpenASO.xcodeproj/project.pbxproj index 1922706..67e22d6 100644 --- a/OpenASO.xcodeproj/project.pbxproj +++ b/OpenASO.xcodeproj/project.pbxproj @@ -48,6 +48,7 @@ 33B2BE83B4CFC8970D7A3482 /* SearchRanking/RankingRefreshCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26D4B2C87FDED9CFCFA50D7D /* SearchRanking/RankingRefreshCoordinator.swift */; }; 3A3B766B1E13C1E131179C31 /* DefaultAppResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B16D1F1F736DDAB911B87E77 /* DefaultAppResolverTests.swift */; }; 3FBAFA6ACAF90A7959DC4053 /* RankingRefreshCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BC7973645CC5F20071230DE /* RankingRefreshCoordinatorTests.swift */; }; + 3CA890AE264B6EE10E04FC20 /* DailyRefreshSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7E6730BB6C6EDAC272B32DA /* DailyRefreshSchedulerTests.swift */; }; 43AE454C23461BBBC44D01DA /* RankingMatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF42BE79CA0AE7107E410C7E /* RankingMatcherTests.swift */; }; 453E1E4BE015A629E5B3F7B5 /* Storefront/StorefrontCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = B399F08A360281DA901E4457 /* Storefront/StorefrontCatalog.swift */; }; 4D4350544553545300000002 /* OpenASOMCPServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D4350544553545300000001 /* OpenASOMCPServiceTests.swift */; }; @@ -244,6 +245,7 @@ 1C1EE58C790084CF7A83CA8A /* SearchRanking/SearchModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/SearchModels.swift; sourceTree = ""; }; 26D4B2C87FDED9CFCFA50D7D /* SearchRanking/RankingRefreshCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/RankingRefreshCoordinator.swift; sourceTree = ""; }; 2BC7973645CC5F20071230DE /* RankingRefreshCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RankingRefreshCoordinatorTests.swift; sourceTree = ""; }; + F7E6730BB6C6EDAC272B32DA /* DailyRefreshSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyRefreshSchedulerTests.swift; sourceTree = ""; }; 2EB55596DB73489FB40D836C /* PreviewAppDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewAppDependencies.swift; sourceTree = ""; }; 378F7AF5B5856F0183D027B4 /* AppDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetailView.swift; sourceTree = ""; }; 465129A02F247ED683558D8B /* SearchRanking/DefaultAppResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/DefaultAppResolver.swift; sourceTree = ""; }; @@ -407,6 +409,7 @@ 4980369A7326B82ED173C90B /* MockHTTPClient.swift */, AF42BE79CA0AE7107E410C7E /* RankingMatcherTests.swift */, 2BC7973645CC5F20071230DE /* RankingRefreshCoordinatorTests.swift */, + F7E6730BB6C6EDAC272B32DA /* DailyRefreshSchedulerTests.swift */, A60000000000000000000004 /* ReviewTranslationIntegrationTests.swift */, ); path = OpenASOTests; @@ -961,6 +964,7 @@ 0FCA11A424784491606B13B7 /* MockHTTPClient.swift in Sources */, 43AE454C23461BBBC44D01DA /* RankingMatcherTests.swift in Sources */, 3FBAFA6ACAF90A7959DC4053 /* RankingRefreshCoordinatorTests.swift in Sources */, + 3CA890AE264B6EE10E04FC20 /* DailyRefreshSchedulerTests.swift in Sources */, A60000000000000000000003 /* ReviewTranslationIntegrationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/OpenASO/Services/Persistence/DailyRefreshScheduler.swift b/OpenASO/Services/Persistence/DailyRefreshScheduler.swift index dc71e5c..a196a5e 100644 --- a/OpenASO/Services/Persistence/DailyRefreshScheduler.swift +++ b/OpenASO/Services/Persistence/DailyRefreshScheduler.swift @@ -152,7 +152,16 @@ final class DailyRefreshScheduler { }.filter { !$0.isEmpty })).sorted() return apps.map { app in - AppDetailRefreshRequest( + // Only refresh ratings/reviews for the storefronts this app actually + // tracks keywords in. Falling back to the full catalog made the daily + // refresh fan out across every bundled storefront, which timed out for + // anyone tracking keywords in more than a handful of countries. + let appStorefrontCodes = Self.ratingsReviewsStorefrontCodes( + for: app, + fallback: normalizedStorefrontCodes + ) + + return AppDetailRefreshRequest( app: AppDetailRefreshAppSnapshot( appStoreID: app.appStoreID, bundleID: app.bundleID, @@ -162,7 +171,7 @@ final class DailyRefreshScheduler { defaultPlatform: app.defaultPlatform ), workspace: .keywords, - storefrontSelection: .all(codes: normalizedStorefrontCodes), + storefrontSelection: .all(codes: appStorefrontCodes), trackIdentityKeys: app.keywordTracks.map(\.identityKey), trigger: "daily_refresh", refreshRatings: refreshRatingsReviews, @@ -175,6 +184,21 @@ final class DailyRefreshScheduler { } } + /// The storefronts whose ratings/reviews should be refreshed for `app`. + /// + /// Scoped to the countries the app actually tracks keywords in. Apps with no + /// tracked keywords fall back to `fallback` (the full storefront catalog) so + /// existing behavior is preserved for them. + static func ratingsReviewsStorefrontCodes( + for app: TrackedApp, + fallback: [String] + ) -> [String] { + let trackedStorefrontCodes = Array(Set(app.keywordTracks.map { + $0.storefront.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + }.filter { !$0.isEmpty })).sorted() + return trackedStorefrontCodes.isEmpty ? fallback : trackedStorefrontCodes + } + func nextCheckSleepNanoseconds(now: Date? = nil) -> UInt64 { let referenceDate = now ?? timing.now() let nextCheckDate = settingsStore.nextRefreshCheckDate(after: referenceDate) diff --git a/OpenASOTests/DailyRefreshSchedulerTests.swift b/OpenASOTests/DailyRefreshSchedulerTests.swift new file mode 100644 index 0000000..cb26e49 --- /dev/null +++ b/OpenASOTests/DailyRefreshSchedulerTests.swift @@ -0,0 +1,110 @@ +import Foundation +import SwiftData +import Testing +@testable import OpenASO + +@MainActor +struct DailyRefreshSchedulerTests { + @Test + func ratingsReviewsStorefrontsScopeToTrackedKeywordCountries() throws { + let container = try makeInMemoryContainer() + let modelContext = ModelContext(container) + + let app = TrackedApp( + appStoreID: 842842640, + bundleID: "com.google.Docs", + name: "Google Docs", + sellerName: "Google", + defaultPlatform: .iphone + ) + modelContext.insert(app) + for storefront in ["us", "GB", " ca "] { + let track = try makeTrackedAppKeyword( + term: "pages", + storefront: storefront, + trackedApp: app, + in: modelContext + ) + app.keywordTracks.append(track) + modelContext.insert(track) + } + try modelContext.save() + + let codes = DailyRefreshScheduler.ratingsReviewsStorefrontCodes( + for: app, + fallback: ["us", "gb", "fr", "de", "jp"] + ) + + // Only the (normalized, de-duplicated, sorted) tracked countries — not the fallback. + #expect(codes == ["ca", "gb", "us"]) + } + + @Test + func ratingsReviewsStorefrontsFallBackWhenNoKeywordsTracked() throws { + let container = try makeInMemoryContainer() + let modelContext = ModelContext(container) + + let app = TrackedApp( + appStoreID: 1, + bundleID: "com.example.app", + name: "Example", + sellerName: "Example Inc", + defaultPlatform: .iphone + ) + modelContext.insert(app) + try modelContext.save() + + let fallback = ["us", "gb", "fr"] + let codes = DailyRefreshScheduler.ratingsReviewsStorefrontCodes( + for: app, + fallback: fallback + ) + + #expect(codes == fallback) + } +} + +private func makeInMemoryContainer() throws -> ModelContainer { + let schema = Schema([ + AppFolder.self, + AppKeywordStats.self, + LatestAppRating.self, + AppDailyRating.self, + AppStorefrontReview.self, + StoreApp.self, + AppStorefrontMetadata.self, + AppStoreScreenshot.self, + KeywordQuery.self, + KeywordDailyMetric.self, + KeywordRankingCrawl.self, + KeywordAppRanking.self, + TrackedApp.self, + TrackedAppKeyword.self, + TrackedKeywordDailyRanking.self, + TrackedKeywordRankedResult.self, + Storefront.self + ]) + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [configuration]) +} + +private func makeTrackedAppKeyword( + term: String, + storefront: String, + trackedApp: TrackedApp, + in modelContext: ModelContext +) throws -> TrackedAppKeyword { + let query = try KeywordQuery.fetchOrInsert( + term: term, + storefront: storefront, + platform: .iphone, + in: modelContext + ) + return TrackedAppKeyword( + term: term, + storefront: storefront, + platform: .iphone, + trackedApp: trackedApp, + query: query + ) +}