Skip to content
Merged
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
52 changes: 50 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,45 @@ 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 use their default storefront. If that is unavailable, at
/// most one valid storefront is selected from `fallback`, preferring the US.
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()
guard trackedStorefrontCodes.isEmpty else {
return trackedStorefrontCodes
}

let defaultStorefront = app.storeApp.defaultStorefront
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if !defaultStorefront.isEmpty {
return [defaultStorefront]
}

var firstValidFallback: String?
for code in fallback {
let normalizedCode = code
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
guard !normalizedCode.isEmpty else { continue }
if normalizedCode == "us" {
return [normalizedCode]
}
if firstValidFallback == nil {
firstValidFallback = normalizedCode
}
}
return firstValidFallback.map { [$0] } ?? []
}

func nextCheckSleepNanoseconds(now: Date? = nil) -> UInt64 {
let referenceDate = now ?? timing.now()
let nextCheckDate = settingsStore.nextRefreshCheckDate(after: referenceDate)
Expand Down
159 changes: 159 additions & 0 deletions OpenASOTests/DailyRefreshSchedulerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
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 ratingsReviewsStorefrontsUseDefaultWhenNoKeywordsTracked() 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()

app.storeApp.defaultStorefront = " GB "
let fallback = ["us", "fr"]
let codes = DailyRefreshScheduler.ratingsReviewsStorefrontCodes(
for: app,
fallback: fallback
)

#expect(codes == ["gb"])
}

@Test
func ratingsReviewsStorefrontsPreferUSCatalogFallback() 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
)
app.storeApp.defaultStorefront = " "
modelContext.insert(app)
try modelContext.save()

let codes = DailyRefreshScheduler.ratingsReviewsStorefrontCodes(
for: app,
fallback: ["", " FR ", "us"]
)

#expect(codes == ["us"])
}

@Test
func ratingsReviewsStorefrontsUseFirstValidCatalogFallbackWhenUSUnavailable() 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
)
app.storeApp.defaultStorefront = " "
modelContext.insert(app)
try modelContext.save()

let codes = DailyRefreshScheduler.ratingsReviewsStorefrontCodes(
for: app,
fallback: ["", " FR ", "gb"]
)

#expect(codes == ["fr"])
}
}

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
)
}
19 changes: 13 additions & 6 deletions OpenASOTests/RankingRefreshCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ struct RankingRefreshCoordinatorTests {
sellerName: "Apple",
defaultPlatform: .iphone
)
secondApp.storeApp.defaultStorefront = "gb"
modelContext.insert(firstApp)
modelContext.insert(firstTrack)
modelContext.insert(secondApp)
Expand Down Expand Up @@ -488,12 +489,18 @@ struct RankingRefreshCoordinatorTests {
#expect(requests.allSatisfy { $0.refreshKeywords && $0.refreshMetrics })
#expect(requests.allSatisfy { $0.refreshRatings && $0.refreshReviews })
#expect(requests.allSatisfy { !$0.recordsRatingsReviewsRefresh })
#expect(requests.allSatisfy {
if case .all(let codes) = $0.storefrontSelection {
return codes == ["gb", "us"]
}
return false
})
if let request = requestsByAppID[842842640],
case .all(let codes) = request.storefrontSelection {
#expect(codes == ["us"])
} else {
Issue.record("Expected the tracked app to use its keyword storefronts.")
}
if let request = requestsByAppID[361309726],
case .all(let codes) = request.storefrontSelection {
#expect(codes == ["gb"])
} else {
Issue.record("Expected the no-keyword app to use its default storefront.")
}
}

@Test
Expand Down
Loading