Skip to content

Commit 249f801

Browse files
Esther-MacMiniEsther-MacMini
authored andcommitted
fix: reject normalized market timestamps and isolate coverage tests
1 parent d1cbc97 commit 249f801

2 files changed

Lines changed: 55 additions & 5 deletions

File tree

‎parlayapi-coverage/src/coverage.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ function invalid(): never {
1818
throw new Error("Unexpected response shape or scope. No coverage conclusion is available.");
1919
}
2020

21+
function marketTimestamp(value: unknown) {
22+
if (typeof value !== "string") return NaN;
23+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/i.exec(value);
24+
if (!match) return NaN;
25+
const [year, month, day, hour, minute, second] = match.slice(1, 7).map(Number);
26+
const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
27+
const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
28+
if (month < 1 || month > 12 || day < 1 || day > daysInMonth[month - 1] ||
29+
hour > 23 || minute > 59 || second > 59 ||
30+
Number(match[7] ?? 0) > 23 || Number(match[8] ?? 0) > 59) return NaN;
31+
return Date.parse(value);
32+
}
33+
2134
function summarize(groups: Group[], synthetic: boolean, maxAgeSeconds: number) {
2235
const ages = groups.flatMap((g) => g.ageSeconds === null ? [] : [g.ageSeconds]);
2336
return {
@@ -74,8 +87,7 @@ export function summarizeResponse(body: unknown, options: Options, now = Date.no
7487
if (isRecord(outcome) && typeof outcome.name === "string") names.add(outcome.name);
7588
}
7689
complete = complete && [...expected].every((name) => names.has(name));
77-
const timestamp = typeof market.last_update === "string" &&
78-
/(?:Z|[+-]\d\d:\d\d)$/.test(market.last_update) ? Date.parse(market.last_update) : NaN;
90+
const timestamp = marketTimestamp(market.last_update);
7991
const age = (now - timestamp) / 1000;
8092
groups.push({ complete, ageSeconds: Number.isFinite(age) && age >= 0 ? Math.ceil(age) : null });
8193
}

‎parlayapi-coverage/tests/coverage.test.ts‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import assert from "node:assert/strict";
2-
import { after, test } from "node:test";
2+
import { after, afterEach, test } from "node:test";
33
import { context } from "@opentelemetry/api";
44
import { isTracingSuppressed } from "@opentelemetry/core";
55
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
@@ -12,10 +12,12 @@ const originalFetch = globalThis.fetch;
1212
const originalKey = process.env.PARLAY_API_KEY;
1313
const manager = new AsyncLocalStorageContextManager().enable();
1414
context.setGlobalContextManager(manager);
15-
after(() => {
15+
afterEach(() => {
1616
globalThis.fetch = originalFetch;
1717
if (originalKey === undefined) delete process.env.PARLAY_API_KEY;
1818
else process.env.PARLAY_API_KEY = originalKey;
19+
});
20+
after(() => {
1921
context.disable();
2022
manager.disable();
2123
});
@@ -91,6 +93,37 @@ test("missing, future and timezone-free market dates remain unknown", () => {
9193
}
9294
});
9395

96+
test("calendar-invalid and malformed timestamps cannot become fresh through normalization", () => {
97+
for (const value of [
98+
"2026-02-30T00:00:00Z", "2026-02-29T00:00:00Z", "1900-02-29T00:00:00Z",
99+
"2026-04-31T00:00:00Z", "2026-01-00T00:00:00Z", "2026-13-01T00:00:00Z",
100+
"2026-03-01T24:00:00Z", "2026-03-01T00:60:00Z", "2026-03-01T00:00:60Z",
101+
"2026-03-01T00:00:00+24:00", "2026-03-01T00:00:00+00:60",
102+
"March 2, 2026 00:00:00Z", "2026-03-02 00:00:00Z",
103+
]) {
104+
const body = sample();
105+
body[0].bookmakers[0].markets[0].last_update = value;
106+
const parsed = Date.parse(value);
107+
const result = summarizeResponse(body, options, Number.isFinite(parsed) ? parsed + 20_000 : now);
108+
assert.equal(result.unknownMarketAgeGroups, 1, value);
109+
assert.equal(result.completeAndFreshGroups, 0, value);
110+
}
111+
});
112+
113+
test("valid leap dates, fractions and UTC offsets preserve market age", () => {
114+
for (const value of [
115+
"2024-02-29T23:59:59Z", "2000-02-29T00:00:00Z",
116+
"2026-03-02T00:00:00.123Z", "2026-03-02T05:30:00+05:30",
117+
"2026-03-01T19:00:00-05:00",
118+
]) {
119+
const body = sample();
120+
body[0].bookmakers[0].markets[0].last_update = value;
121+
const result = summarizeResponse(body, options, Date.parse(value) + 20_000);
122+
assert.equal(result.oldestKnownMarketAgeSeconds, 20, value);
123+
assert.equal(result.completeAndFreshGroups, 1, value);
124+
}
125+
});
126+
94127
test("wrong sport, bookmaker and market fail closed", () => {
95128
for (const change of ["sport", "book", "market"]) {
96129
const body = sample();
@@ -132,6 +165,7 @@ test("live request is single, private and trace-suppressed through body reading"
132165
});
133166

134167
test("errors, truncation, malformed JSON and oversize bodies are redacted without retry", async () => {
168+
process.env.PARLAY_API_KEY = "PRIVATE_KEY";
135169
for (const response of [
136170
() => new Response("PRIVATE_SECRET", { status: 401 }),
137171
() => new Response("PRIVATE_SECRET", { status: 429 }),
@@ -152,10 +186,14 @@ test("errors, truncation, malformed JSON and oversize bodies are redacted withou
152186
});
153187

154188
test("invalid input and absent environment key never make a request", async () => {
155-
globalThis.fetch = async () => { assert.fail("Invalid input sent a request"); };
189+
process.env.PARLAY_API_KEY = "PRIVATE_KEY";
190+
let requests = 0;
191+
globalThis.fetch = async () => { requests++; return new Response("[]"); };
156192
for (const payload of [null, { apiKey: "PRIVATE_KEY" }, { mode: "live", ...options, sport: "../secret" }, { mode: "live", ...options, bookmaker: "pinnacle,other" }, { mode: "live", ...options, expectedOutcomes: 4 }]) {
157193
await assert.rejects(checkCoverage(payload));
194+
assert.equal(requests, 0);
158195
}
159196
delete process.env.PARLAY_API_KEY;
160197
await assert.rejects(checkCoverage({ mode: "live", ...options }));
198+
assert.equal(requests, 0);
161199
});

0 commit comments

Comments
 (0)