Skip to content

Commit a385df7

Browse files
fix: address Copilot review — proxy status header and test isolation
- worker.js: add X-Proxy-Status: ok/error header to distinguish "upstream confirmed no cheats" from "transient fetch failure", allowing clients to skip the direct-scrape fallback on confirmed empty results - GameHackingOrgLookup: fetchFromProxy returns [CheatDatabaseEntry]? (nil = proxy error → fallback, [] = proxy ok no results → no fallback) uses X-Proxy-Status header to gate the nil vs [] decision - Tests: add DirectScrapeBlockerProtocol to prevent real gamehacking.org network calls; add cannedHeaders support to ProxyCannedProtocol; rename proxyReturnsEmpty test to reflect no-fallback semantics Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent dfd57e1 commit a385df7

3 files changed

Lines changed: 102 additions & 17 deletions

File tree

PVLibrary/Sources/PVLibrary/Cheat/GameHackingOrgLookup.swift

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,15 @@ public actor GameHackingOrgLookup {
9494

9595
let proxyURL = resolvedProxyURL()
9696
if !proxyURL.isEmpty, Defaults[.useCheatProxy] {
97+
// fetchFromProxy returns nil when the proxy is unreachable or fails (caller should
98+
// fallback to direct scraping), or a non-nil array when the proxy successfully
99+
// responded — including an empty array meaning "no cheats found" (no fallback needed).
97100
let proxyResults = await fetchFromProxy(title: title, systemSlug: systemSlug, proxyBaseURL: proxyURL)
98-
if !proxyResults.isEmpty {
101+
if let proxyResults {
99102
DLOG("GameHackingOrgLookup: proxy returned \(proxyResults.count) codes for '\(title)'")
100103
results = proxyResults
101104
} else {
102-
DLOG("GameHackingOrgLookup: proxy empty, falling back to direct scrape for '\(title)'")
105+
DLOG("GameHackingOrgLookup: proxy failed/unreachable, falling back to direct scrape for '\(title)'")
103106
results = await fetchWithFallback(title: title, systemSlug: systemSlug)
104107
}
105108
} else {
@@ -128,8 +131,14 @@ public actor GameHackingOrgLookup {
128131
/// Fetch cheat entries from the Provenance cheat proxy worker.
129132
///
130133
/// The proxy endpoint is `GET <proxyBaseURL>/cheats?title=<title>&system=<slug>`.
131-
/// Returns an empty array when the proxy is unreachable or returns no results.
132-
private func fetchFromProxy(title: String, systemSlug: String?, proxyBaseURL: String) async -> [CheatDatabaseEntry] {
134+
///
135+
/// Returns:
136+
/// - `nil` when the proxy is unreachable, returns a non-2xx status, or a network/decode
137+
/// error occurs — the caller should fall back to direct scraping.
138+
/// - `[]` when the proxy successfully contacted upstream but found no cheats
139+
/// (signalled by `X-Proxy-Status: ok` in the response) — no fallback needed.
140+
/// - `[entries]` when the proxy found results.
141+
private func fetchFromProxy(title: String, systemSlug: String?, proxyBaseURL: String) async -> [CheatDatabaseEntry]? {
133142
var components = URLComponents(string: proxyBaseURL.hasSuffix("/")
134143
? proxyBaseURL + "cheats"
135144
: proxyBaseURL + "/cheats")
@@ -141,7 +150,7 @@ public actor GameHackingOrgLookup {
141150

142151
guard let url = components?.url else {
143152
WLOG("GameHackingOrgLookup: invalid proxy URL '\(proxyBaseURL)'")
144-
return []
153+
return nil
145154
}
146155

147156
do {
@@ -152,10 +161,22 @@ public actor GameHackingOrgLookup {
152161
guard let http = response as? HTTPURLResponse,
153162
(200..<300).contains(http.statusCode) else {
154163
WLOG("GameHackingOrgLookup: proxy non-200 for '\(title)'")
155-
return []
164+
return nil
156165
}
157166

158167
let raw = try JSONDecoder().decode([ProxyCheatEntry].self, from: data)
168+
if raw.isEmpty {
169+
// Only trust an empty result as "no cheats found" when the proxy confirms it
170+
// successfully contacted upstream via X-Proxy-Status: ok. Without this header
171+
// the empty array may be a transient error — fall back to direct scraping.
172+
let proxyStatus = http.value(forHTTPHeaderField: "X-Proxy-Status")
173+
guard proxyStatus == "ok" else {
174+
DLOG("GameHackingOrgLookup: proxy returned empty without ok status for '\(title)' — falling back")
175+
return nil
176+
}
177+
return []
178+
}
179+
159180
return raw.enumerated().map { index, entry in
160181
CheatDatabaseEntry(
161182
id: Self.idOffset + index,
@@ -172,7 +193,7 @@ public actor GameHackingOrgLookup {
172193
}
173194
} catch {
174195
WLOG("GameHackingOrgLookup: proxy fetch error for '\(title)': \(error)")
175-
return []
196+
return nil
176197
}
177198
}
178199

PVLibrary/Tests/PVLibraryTests/GameHackingOrgLookupTests.swift

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,9 @@ final class GameHackingOrgLookupTests: XCTestCase {
126126
let json = #"[{"name":"Infinite Lives","code":"DEADBEEF00000001","category":"General"}]"#
127127
ProxyCannedProtocol.cannedJSON = Data(json.utf8)
128128
ProxyCannedProtocol.statusCode = 200
129+
ProxyCannedProtocol.cannedHeaders = ["X-Proxy-Status": "ok"]
129130
ProxyCannedProtocol.lastRequest = nil
131+
defer { ProxyCannedProtocol.cannedHeaders = [:] }
130132

131133
Defaults[.useCheatProxy] = true
132134
Defaults[.cheatProxyURL] = "https://test.proxy.pvemu.invalid"
@@ -151,14 +153,27 @@ final class GameHackingOrgLookupTests: XCTestCase {
151153
XCTAssertTrue(entries.first?.isOnlineResult ?? false)
152154
}
153155

154-
func testSearchCheats_proxyReturnsEmpty_fallsThrough() async {
156+
func testSearchCheats_proxyReturnsEmpty_noFallback() async {
157+
// Proxy returns [] with X-Proxy-Status: ok — meaning "upstream confirmed no cheats".
158+
// searchCheats should trust this and NOT fall back to direct scraping.
159+
// DirectScrapeBlockerProtocol is registered to ensure no gamehacking.org request is made.
155160
URLProtocol.registerClass(ProxyCannedProtocol.self)
156-
defer { URLProtocol.unregisterClass(ProxyCannedProtocol.self) }
161+
URLProtocol.registerClass(DirectScrapeBlockerProtocol.self)
162+
defer {
163+
URLProtocol.unregisterClass(ProxyCannedProtocol.self)
164+
URLProtocol.unregisterClass(DirectScrapeBlockerProtocol.self)
165+
}
157166

158-
// Proxy returns empty array — direct scraping also yields nothing (no network in CI)
159167
ProxyCannedProtocol.cannedJSON = Data("[]".utf8)
160168
ProxyCannedProtocol.statusCode = 200
169+
// X-Proxy-Status: ok tells the client the proxy successfully ran and found nothing
170+
ProxyCannedProtocol.cannedHeaders = ["X-Proxy-Status": "ok"]
161171
ProxyCannedProtocol.lastRequest = nil
172+
DirectScrapeBlockerProtocol.requestCount = 0
173+
defer {
174+
ProxyCannedProtocol.cannedHeaders = [:]
175+
DirectScrapeBlockerProtocol.requestCount = 0
176+
}
162177

163178
Defaults[.useCheatProxy] = true
164179
Defaults[.cheatProxyURL] = "https://test.proxy.pvemu.invalid"
@@ -167,20 +182,31 @@ final class GameHackingOrgLookupTests: XCTestCase {
167182
Defaults.reset(.cheatProxyURL)
168183
}
169184

170-
let title = "ProxyEmptyFallback_\(UUID().uuidString)"
185+
let title = "ProxyEmptyNoFallback_\(UUID().uuidString)"
171186
let entries = await GameHackingOrgLookup.shared.searchCheats(title: title, systemSlug: nil)
172-
// Proxy was contacted but returned empty; direct scraping also fails offline — result is empty
187+
188+
// Proxy was contacted and returned empty with ok status — no direct scrape should happen
173189
XCTAssertTrue(entries.isEmpty)
174190
let intercepted = ProxyCannedProtocol.lastRequest?.url?.absoluteString ?? ""
175-
XCTAssertTrue(intercepted.contains("/cheats"), "Proxy should still have been contacted even when empty")
191+
XCTAssertTrue(intercepted.contains("/cheats"), "Proxy should have been contacted")
192+
XCTAssertEqual(DirectScrapeBlockerProtocol.requestCount, 0, "Direct scrape should NOT occur when proxy confirms no cheats")
176193
}
177194

178195
func testSearchCheats_proxyDisabled_doesNotContactProxy() async {
196+
// Proxy is disabled — only the direct scrape path runs.
197+
// DirectScrapeBlockerProtocol intercepts gamehacking.org requests so no real
198+
// network call is made; it returns empty HTML so the scrape yields no results.
179199
URLProtocol.registerClass(ProxyCannedProtocol.self)
180-
defer { URLProtocol.unregisterClass(ProxyCannedProtocol.self) }
200+
URLProtocol.registerClass(DirectScrapeBlockerProtocol.self)
201+
defer {
202+
URLProtocol.unregisterClass(ProxyCannedProtocol.self)
203+
URLProtocol.unregisterClass(DirectScrapeBlockerProtocol.self)
204+
}
181205

182206
ProxyCannedProtocol.cannedJSON = Data()
183207
ProxyCannedProtocol.lastRequest = nil
208+
DirectScrapeBlockerProtocol.requestCount = 0
209+
defer { DirectScrapeBlockerProtocol.requestCount = 0 }
184210

185211
Defaults[.useCheatProxy] = false
186212
Defaults[.cheatProxyURL] = "https://test.proxy.pvemu.invalid"
@@ -202,6 +228,7 @@ final class GameHackingOrgLookupTests: XCTestCase {
202228
private final class ProxyCannedProtocol: URLProtocol {
203229
static var cannedJSON: Data = Data()
204230
static var statusCode: Int = 200
231+
static var cannedHeaders: [String: String] = [:]
205232
static var lastRequest: URLRequest?
206233

207234
override class func canInit(with request: URLRequest) -> Bool {
@@ -212,11 +239,15 @@ private final class ProxyCannedProtocol: URLProtocol {
212239

213240
override func startLoading() {
214241
ProxyCannedProtocol.lastRequest = request
242+
var headers = ["Content-Type": "application/json"]
243+
for (key, value) in ProxyCannedProtocol.cannedHeaders {
244+
headers[key] = value
245+
}
215246
let response = HTTPURLResponse(
216247
url: request.url!,
217248
statusCode: ProxyCannedProtocol.statusCode,
218249
httpVersion: "HTTP/1.1",
219-
headerFields: ["Content-Type": "application/json"]
250+
headerFields: headers
220251
)!
221252
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
222253
client?.urlProtocol(self, didLoad: ProxyCannedProtocol.cannedJSON)
@@ -225,3 +256,30 @@ private final class ProxyCannedProtocol: URLProtocol {
225256

226257
override func stopLoading() {}
227258
}
259+
260+
/// Intercepts requests to gamehacking.org and returns empty HTML, preventing real network calls
261+
/// during tests that exercise the direct-scrape fallback path.
262+
private final class DirectScrapeBlockerProtocol: URLProtocol {
263+
static var requestCount: Int = 0
264+
265+
override class func canInit(with request: URLRequest) -> Bool {
266+
request.url?.host?.contains("gamehacking.org") ?? false
267+
}
268+
269+
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
270+
271+
override func startLoading() {
272+
DirectScrapeBlockerProtocol.requestCount += 1
273+
let response = HTTPURLResponse(
274+
url: request.url!,
275+
statusCode: 200,
276+
httpVersion: "HTTP/1.1",
277+
headerFields: ["Content-Type": "text/html"]
278+
)!
279+
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
280+
client?.urlProtocol(self, didLoad: Data("<html><body></body></html>".utf8))
281+
client?.urlProtocolDidFinishLoading(self)
282+
}
283+
284+
override func stopLoading() {}
285+
}

Scripts/cheat-proxy/worker.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ export default {
7575
try {
7676
const cached = await env[KV_NAMESPACE].get(cacheKey, { type: "json" });
7777
if (cached !== null) {
78-
return jsonResponse(cached, { "X-Cache": "HIT" }, origin, env);
78+
// X-Proxy-Status: ok signals to clients that the proxy successfully
79+
// contacted upstream — an empty array here means "no cheats found",
80+
// not a transient error, so clients should skip the direct-scrape fallback.
81+
return jsonResponse(cached, { "X-Cache": "HIT", "X-Proxy-Status": "ok" }, origin, env);
7982
}
8083
} catch (err) {
8184
console.error("KV get error:", err);
@@ -104,7 +107,10 @@ export default {
104107
);
105108
}
106109

107-
return jsonResponse(results, { "X-Cache": "MISS" }, origin, env);
110+
// X-Proxy-Status: ok when the upstream fetch succeeded (even with 0 results).
111+
// X-Proxy-Status: error when the fetch itself threw (network/parse failure).
112+
// Clients use this to decide whether to fall back to direct scraping.
113+
return jsonResponse(results, { "X-Cache": "MISS", "X-Proxy-Status": fetchSucceeded ? "ok" : "error" }, origin, env);
108114
},
109115
};
110116

0 commit comments

Comments
 (0)