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 + ) +}