Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions OpenASO.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -244,6 +245,7 @@
1C1EE58C790084CF7A83CA8A /* SearchRanking/SearchModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/SearchModels.swift; sourceTree = "<group>"; };
26D4B2C87FDED9CFCFA50D7D /* SearchRanking/RankingRefreshCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/RankingRefreshCoordinator.swift; sourceTree = "<group>"; };
2BC7973645CC5F20071230DE /* RankingRefreshCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RankingRefreshCoordinatorTests.swift; sourceTree = "<group>"; };
F7E6730BB6C6EDAC272B32DA /* DailyRefreshSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyRefreshSchedulerTests.swift; sourceTree = "<group>"; };
2EB55596DB73489FB40D836C /* PreviewAppDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewAppDependencies.swift; sourceTree = "<group>"; };
378F7AF5B5856F0183D027B4 /* AppDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetailView.swift; sourceTree = "<group>"; };
465129A02F247ED683558D8B /* SearchRanking/DefaultAppResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/DefaultAppResolver.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -407,6 +409,7 @@
4980369A7326B82ED173C90B /* MockHTTPClient.swift */,
AF42BE79CA0AE7107E410C7E /* RankingMatcherTests.swift */,
2BC7973645CC5F20071230DE /* RankingRefreshCoordinatorTests.swift */,
F7E6730BB6C6EDAC272B32DA /* DailyRefreshSchedulerTests.swift */,
A60000000000000000000004 /* ReviewTranslationIntegrationTests.swift */,
);
path = OpenASOTests;
Expand Down Expand Up @@ -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;
Expand Down
28 changes: 26 additions & 2 deletions OpenASO/Services/Persistence/DailyRefreshScheduler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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)
Expand Down
110 changes: 110 additions & 0 deletions OpenASOTests/DailyRefreshSchedulerTests.swift
Original file line number Diff line number Diff line change
@@ -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
)
}