Repository navigation
Expand file tree
/
Copy pathtest.mjs
More file actions
153 lines (123 loc) · 7.61 KB
/
Copy pathtest.mjs
File metadata and controls
153 lines (123 loc) · 7.61 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
// Parser tests. Run: npm test
// Fixture-based so they pass without network; the live check is `npm run check`.
import { parseRate, pollDecision, caracas, notify, sign, webhookPayload, currencyView } from "../src/index.js";
let pass = 0, fail = 0;
const eq = (name, got, want) => {
const ok = Object.is(got, want);
console.log(`${ok ? " ok" : "FAIL"} ${name}${ok ? "" : ` got ${got}, want ${want}`}`);
ok ? pass++ : fail++;
};
// Real markup shape from bcv.org.ve (whitespace quirks preserved verbatim).
const HTML = `
<div id="euro" class="col-sm-12 col-xs-12 ">
<div class="col-sm-6 col-xs-6 centrado textp"><strong class="strong-tb"> 889,45399204</strong> </div>
</div>
<div id="yuan" class="col-sm-12 col-xs-12">
<div class="col-sm-6 col-xs-6 centrado textp"><strong class="strong-tb"> 114,37343696</strong></div>
</div>
<div id="dolar" class="col-sm-12 col-xs-12 ">
<div class="col-sm-6 col-xs-6 centrado textp"> <strong class="strong-tb">771,07140000</strong> </div>
</div>
<span class="date-display-single" content="2026-08-14T00:00:00-04:00">Viernes, 14 Agosto 2026</span>`;
const fecha = (h) => h.match(/date-display-single[^>]*content="(\d{4}-\d{2}-\d{2})/)?.[1] ?? null;
console.log("parsing");
eq("USD", parseRate(HTML, "dolar"), 771.0714);
eq("EUR (leading space)", parseRate(HTML, "euro"), 889.45399204);
eq("picks the right div, not the first", parseRate(HTML, "yuan"), 114.37343696);
eq("fecha_valor", fecha(HTML), "2026-08-14");
console.log("\nSpanish number format");
// If USD ever crosses 1000, BCV writes 1.234,56 — a naive parseFloat yields 1.234
eq("thousands separator", parseRate(`<div id="dolar"><strong class="strong-tb">1.234,56789000</strong>`, "dolar"), 1234.56789);
console.log("\nguards (must return null so we never overwrite good data)");
eq("missing currency div", parseRate(HTML, "libra"), null);
eq("renamed class", parseRate(`<div id="dolar"><strong class="rate">771,07</strong>`, "dolar"), null);
eq("empty value", parseRate(`<div id="dolar"><strong class="strong-tb"></strong>`, "dolar"), null);
eq("zero rejected", parseRate(`<div id="dolar"><strong class="strong-tb">0,00</strong>`, "dolar"), null);
eq("no fecha", fecha("<span>Viernes</span>"), null);
// The CSS block on the page contains the bare token `strong-tb {` — must not match.
eq("CSS decl not matched", parseRate(`<div id="dolar"></div><style>strong-tb { padding-top: 2px; }</style>`, "dolar"), null);
console.log("\ntimezone");
// 2026-08-14T18:00Z is 14:00 Caracas (UTC-4), a Friday.
eq("UTC->Caracas hour", caracas(Date.parse("2026-08-14T18:00:00Z")).hour, 14);
eq("date rolls back across UTC midnight", caracas(Date.parse("2026-08-15T02:00:00Z")).date, "2026-08-14");
console.log("\npoll decision");
// Helper: a cached record whose scrape is `min` minutes old.
const at = (iso) => Date.parse(iso);
const rec = (fecha, scrapedIso) => ({ fecha_valor: fecha, scraped_at: scrapedIso });
// Friday 14:00 Caracas, fecha_valor still today => publish pending => burst.
eq("in-window, awaiting publish -> poll",
pollDecision(at("2026-08-14T18:00:00Z"), rec("2026-08-14", "2026-08-14T17:59:00Z")).poll, true);
// Same moment, but fecha_valor already advanced to Monday => done for today.
eq("in-window, already published -> idle",
pollDecision(at("2026-08-14T18:00:00Z"), rec("2026-08-17", "2026-08-14T17:59:00Z")).poll, false);
// 09:00 Caracas Friday — before the window opens.
eq("before window -> idle",
pollDecision(at("2026-08-14T13:00:00Z"), rec("2026-08-14", "2026-08-14T12:59:00Z")).poll, false);
// Saturday: fecha_valor is Monday, so awaitingPublish is false automatically.
eq("weekend -> idle",
pollDecision(at("2026-08-15T18:00:00Z"), rec("2026-08-17", "2026-08-15T17:59:00Z")).poll, false);
// Idle states must still poll once the heartbeat is due, or `stale` never clears.
eq("idle but heartbeat due -> poll",
pollDecision(at("2026-08-14T18:00:00Z"), rec("2026-08-17", "2026-08-14T17:30:00Z")).poll, true);
eq("empty KV -> poll", pollDecision(at("2026-08-14T18:00:00Z"), null).poll, true);
// Holiday: BCV never publishes, fecha_valor stays stale. We burst all window
// (harmless) but must not spin forever outside it.
eq("holiday, after window -> heartbeat only",
pollDecision(at("2026-08-15T03:00:00Z"), rec("2026-08-14", "2026-08-15T02:55:00Z")).poll, false);
console.log("\nwebhook");
const REC = { bcv_usd: 771.0714, bcv_eur: 889.45399204, fecha_valor: "2026-08-17", changed_at: "2026-08-14T20:00:00.000Z" };
// Stub fetch so we can assert fanout without network.
const withFetch = async (impl, fn) => {
const real = globalThis.fetch;
globalThis.fetch = impl;
try { return await fn(); } finally { globalThis.fetch = real; }
};
eq("no URLs configured -> no-op", await notify({}, REC), false);
let calls = [];
const ok = async (url, init) => { calls.push({ url, init }); return { ok: true, status: 200 }; };
calls = [];
eq("single URL delivers",
await withFetch(ok, () => notify({ WEBHOOK_URLS: "https://a.test/hook" }, REC)), true);
eq(" posted once", calls.length, 1);
eq(" method POST", calls[0].init.method, "POST");
calls = [];
await withFetch(ok, () => notify({ WEBHOOK_URLS: "https://a.test/h, https://b.test/h" }, REC));
eq("fans out to both URLs (whitespace tolerated)", calls.length, 2);
// A partial failure must report false, so the marker never advances and the
// next cron tick retries. Otherwise a flaky receiver silently misses a rate.
calls = [];
const oneBad = async (url) => (url.includes("b.test") ? { ok: false, status: 500 } : { ok: true, status: 200 });
eq("partial failure -> false (will retry)",
await withFetch(oneBad, () => notify({ WEBHOOK_URLS: "https://a.test/h,https://b.test/h" }, REC)), false);
eq("thrown error -> false (will retry)",
await withFetch(async () => { throw new Error("dns"); }, () => notify({ WEBHOOK_URLS: "https://a.test/h" }, REC)), false);
calls = [];
await withFetch(ok, () => notify({ WEBHOOK_URLS: "https://a.test/h" }, REC));
eq("unsigned when no secret", calls[0].init.headers["x-bcv-signature"], undefined);
calls = [];
await withFetch(ok, () => notify({ WEBHOOK_URLS: "https://a.test/h", WEBHOOK_SECRET: "shh" }, REC));
const sig = calls[0].init.headers["x-bcv-signature"];
eq("signed when secret set", sig, `sha256=${await sign("shh", webhookPayload(REC))}`);
// Signature must cover the exact bytes sent, or receiver verification fails.
eq("signature covers the sent body", sig.slice(7), await sign("shh", calls[0].init.body));
eq("wrong secret produces different sig",
(await sign("wrong", webhookPayload(REC))) === sig.slice(7), false);
// Known-answer test pinning HMAC-SHA256 so the README's receiver snippet is
// provably compatible.
eq("HMAC known vector", await sign("key", "hello"),
"9307b3b915efb5171ff14d8cb55fbcc798c6c0ef1456d66ded1a6aa723a58b7b");
console.log("\nper-currency view (dolarapi-compatible)");
const FULL = { bcv_usd: 771.0714, bcv_eur: 889.45399204, fecha_valor: "2026-08-17",
changed_at: "2026-08-14T20:00:00.000Z", stale: false };
const usd = currencyView(FULL, "usd"), eur = currencyView(FULL, "eur");
eq("usd promedio", usd.promedio, 771.0714);
eq("eur promedio", eur.promedio, 889.45399204);
eq("moneda uppercased", usd.moneda, "USD");
// Consumers keyed on `fuente: "oficial"` must never match a parallel-market rate.
eq("fuente marks it official", usd.fuente, "oficial");
// These two keys are the integration contract — renaming them breaks consumers
// that parse `promedio` + `fechaActualizacion` (e.g. ve.dolarapi.com clients).
eq("fechaActualizacion carries fecha_valor", usd.fechaActualizacion, "2026-08-17");
eq("currencies do not cross-contaminate", usd.promedio === eur.promedio, false);
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);