Files
miti99bot-js/tests/scripts/backfill-mongo-to-d1.test.js
tiennm99 3f03521e84 feat(scripts): phase 07 — reverse-backfill scripts + delete guard
Pre-execution prerequisites for the Phase 07 cutover. Stage 2 of the
cutover keeps DUAL_WRITE=0 for ~6 days; if anything regresses during
that window the operator MUST be able to roll back to KV/D1 with the
last N days of Mongo-only writes recovered. Pre-building these scripts
(per code-reviewer #4) eliminates "draft a backfill under outage
pressure" — the anti-pattern of writing untested code at 4am.

Reverse-backfill
- scripts/backfill-mongo-to-kv.js: full-scan Mongo collection per module,
  PUT each doc back to CF KV via REST. expiresAt → expirationTtl (clamped
  to 60s minimum per CF KV); already-expired docs are skipped (won't
  resurrect dead state). 50 ops/sec throttle. --dry-run + --module flags.
- scripts/backfill-mongo-to-d1.js: full-scan trading_trades, build INSERT
  SQL preserving legacy_id where present (round-trips D1 autoincrement IDs
  preserved by phase-05 forward backfill). Sequential int generation for
  any docs without legacy_id. Pipes through wrangler d1 execute.
- scripts/lib/migration-helpers.js: cfKvPut helper added.

Delete guard (debugger #12)
- scripts/wrangler-delete-guard.sh: interactive CONFIRM wrapper around
  wrangler kv namespace delete + wrangler d1 delete. Exits 3 when stdin
  is not a tty so it cannot run in CI. Documented: never run in CI.

package.json: backfill:mongo:kv[:dry] + backfill:mongo:d1[:dry] scripts
wired.

Tests: 697 → 733 (+36).
- 7 cfKvPut tests (REST URL, querystring, body, expiration_ttl param).
- 10 reverse-KV TTL math tests (expired sentinel, future seconds, no-TTL,
  CF 60s minimum clamp).
- 9 reverse-D1 SQL construction tests (escaping, legacy_id preservation,
  sequential generation).

Lint clean. No Worker code touched. Stage 1 cutover, 7-day soak,
snapshots, and Stage 3 cleanup (delete CFKVStore + simplify factories +
edit package.json deploy chain) remain operator-driven and will be
committed separately after binding deletion.
2026-04-26 09:29:14 +07:00

123 lines
3.9 KiB
JavaScript

/**
* @file backfill-mongo-to-d1.test.js — unit tests for SQL-statement construction.
*
* Tests `buildInsertSql` and `sqlStr` exported from backfill-mongo-to-d1.js.
* No execSync, no wrangler, no real Mongo connection.
*/
import { describe, expect, it } from "vitest";
import { buildInsertSql, sqlStr } from "../../scripts/backfill-mongo-to-d1.js";
// ─── sqlStr ───────────────────────────────────────────────────────────────────
describe("sqlStr", () => {
it("wraps a plain string in single quotes", () => {
expect(sqlStr("hello")).toBe("'hello'");
});
it("escapes internal single quotes by doubling them", () => {
expect(sqlStr("it's")).toBe("'it''s'");
});
it("escapes multiple single quotes", () => {
expect(sqlStr("a'b'c")).toBe("'a''b''c'");
});
it("returns NULL for null", () => {
expect(sqlStr(null)).toBe("NULL");
});
it("returns NULL for undefined", () => {
expect(sqlStr(undefined)).toBe("NULL");
});
it("handles empty string", () => {
expect(sqlStr("")).toBe("''");
});
it("coerces non-string values via String()", () => {
// @ts-ignore — testing runtime coercion
expect(sqlStr(42)).toBe("'42'");
});
});
// ─── buildInsertSql ───────────────────────────────────────────────────────────
describe("buildInsertSql", () => {
/** @type {Parameters<typeof buildInsertSql>[0]} */
const BASE_ROW = {
id: 1,
user_id: "u123",
symbol: "BTC",
side: "buy",
qty: 0.5,
price_vnd: 1500000,
ts: 1700000000,
};
it("produces a valid INSERT statement", () => {
const sql = buildInsertSql(BASE_ROW);
expect(sql).toMatch(
/^INSERT INTO trading_trades \(id, user_id, symbol, side, qty, price_vnd, ts\) VALUES \(/,
);
expect(sql).toMatch(/\);$/);
});
it("includes all column values in correct order", () => {
const sql = buildInsertSql(BASE_ROW);
expect(sql).toContain("1,"); // id
expect(sql).toContain("'u123'"); // user_id
expect(sql).toContain("'BTC'"); // symbol
expect(sql).toContain("'buy'"); // side
expect(sql).toContain("0.5,"); // qty
expect(sql).toContain("1500000,"); // price_vnd
expect(sql).toContain("1700000000"); // ts
});
it("preserves legacy_id as the INSERT id", () => {
const sql = buildInsertSql({ ...BASE_ROW, id: 42 });
// id=42 should be the first value
expect(sql).toMatch(/VALUES \(42,/);
});
it("escapes single quotes in user_id", () => {
const sql = buildInsertSql({ ...BASE_ROW, user_id: "o'brien" });
expect(sql).toContain("'o''brien'");
});
it("escapes single quotes in symbol", () => {
const sql = buildInsertSql({ ...BASE_ROW, symbol: "it's" });
expect(sql).toContain("'it''s'");
});
it("handles decimal qty correctly", () => {
const sql = buildInsertSql({ ...BASE_ROW, qty: 1.23456789 });
expect(sql).toContain("1.23456789");
});
it("handles zero values without coercion errors", () => {
const sql = buildInsertSql({
id: 0,
user_id: "",
symbol: "",
side: "",
qty: 0,
price_vnd: 0,
ts: 0,
});
expect(sql).toMatch(/VALUES \(0, '', '', '', 0, 0, 0\);/);
});
it("sequential id generation: each row gets unique ascending id", () => {
// Simulate the script logic of incrementing nextId for docs without legacy_id
let nextId = 1;
const rows = [
{ user_id: "a", symbol: "BTC", side: "buy", qty: 1, price_vnd: 100, ts: 1 },
{ user_id: "b", symbol: "ETH", side: "sell", qty: 2, price_vnd: 200, ts: 2 },
];
const stmts = rows.map((r) => buildInsertSql({ ...r, id: nextId++ }));
expect(stmts[0]).toMatch(/VALUES \(1,/);
expect(stmts[1]).toMatch(/VALUES \(2,/);
});
});