`•` was missing from the entity table, and that was not cosmetic. DoorDash separates an item's name from its options with a bullet and parseDoorDashLineItems splits on the literal "•" — so left encoded, the split never happened and the line collapsed into the description: "Bucket and Side Pack (Meal Deals) • Hot Bucket • Chips" with options []. The entity showed on screen and the structure behind it was gone. Numeric entities are now decoded generically rather than one at a time, which is how $ came to be listed individually while its neighbours were not, and & resolves last so a literal "•" stays as written instead of turning into a bullet. Repaired the 47 stored rows by re-parsing the captured email behind each one rather than string-replacing the entity, since a replacement would have fixed the display and left options [] underneath. Rehearsed first: all 47 re-parsed, all 47 gained options, 0 line items lost, 0 amounts changed. Old values kept in dump/rollback-line-items-20260728-223737.json. Pre-existing — 22 rows predate today — but the pre-cutover backfill more than doubled the affected rows, which is what surfaced it. Verified in the Order details panel, not in SQL.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -488,3 +488,36 @@ describe("payment legs", () => {
|
|||||||
expect(validateOrderTotals(o, html("ut-paypal")).ok).toBe(true);
|
expect(validateOrderTotals(o, html("ut-paypal")).ok).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("HTML entities in line items", () => {
|
||||||
|
it("splits options on an encoded bullet instead of swallowing it", () => {
|
||||||
|
// "<b>Sweet & Sour Crunch</b> (…)<br><font>• Sweet & Sour
|
||||||
|
// Crunch 12 Pieces</font>". • was not in the decode table, and
|
||||||
|
// parseDoorDashLineItems splits on the literal "•" — so the option never
|
||||||
|
// separated and the entity was rendered raw in the Order details panel.
|
||||||
|
const o = parseOrderHTML(
|
||||||
|
html("dd-bull-entity"),
|
||||||
|
meta({ subject: "Order Confirmation for Siddharth from Red Rooster" })
|
||||||
|
);
|
||||||
|
const all = JSON.stringify(o.line_items);
|
||||||
|
expect(all).not.toContain("•");
|
||||||
|
expect(all).not.toContain("&");
|
||||||
|
|
||||||
|
const item = o.line_items.find((i) => i.description.startsWith("Sweet"));
|
||||||
|
expect(item).toBeDefined();
|
||||||
|
// The ampersand decodes, and the bullet becomes a boundary, not text.
|
||||||
|
expect(item!.description).toContain("Sweet & Sour Crunch");
|
||||||
|
expect(item!.description).not.toContain("•");
|
||||||
|
expect(item!.options.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a literal &bull; alone rather than turning it into a bullet", () => {
|
||||||
|
// Why & resolves last: decoding it first would rewrite text that was
|
||||||
|
// deliberately escaped.
|
||||||
|
const o = parseOrderHTML(
|
||||||
|
html("dd-01").replace("Mad Mex", "A&amp;bull;B"),
|
||||||
|
meta()
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(o)).not.toContain("A•B".replace("•", "•"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+24
-3
@@ -101,14 +101,35 @@ export class OrderParseError extends Error {
|
|||||||
|
|
||||||
const stripTags = (s: string) => s.replace(/<[^>]+>/g, " ");
|
const stripTags = (s: string) => s.replace(/<[^>]+>/g, " ");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity decoding, ordered so `&` resolves last.
|
||||||
|
*
|
||||||
|
* `•` was missing, and it is not a cosmetic omission: DoorDash separates
|
||||||
|
* an item's name from its options with a bullet, and parseDoorDashLineItems
|
||||||
|
* splits on the literal "•". Left encoded, the split never happens and the
|
||||||
|
* whole line collapses into the description — "Fire Extinguisher (Chicken
|
||||||
|
* Burgers) • Regular" instead of a name plus one option. So the entity
|
||||||
|
* showed up on screen AND the structure behind it was lost.
|
||||||
|
*
|
||||||
|
* Numeric entities are decoded generically rather than one at a time, which is
|
||||||
|
* how $ came to be listed individually while its neighbours were not.
|
||||||
|
*
|
||||||
|
* `&` goes last because decoding it first turns a literal "&bull;" —
|
||||||
|
* text that should stay as written — into a bullet.
|
||||||
|
*/
|
||||||
const decodeEntities = (s: string) =>
|
const decodeEntities = (s: string) =>
|
||||||
s
|
s
|
||||||
.replace(/ /gi, " ")
|
.replace(/ /gi, " ")
|
||||||
.replace(/&/gi, "&")
|
|
||||||
.replace(/'|'/gi, "'")
|
.replace(/'|'/gi, "'")
|
||||||
.replace(/"/gi, '"')
|
.replace(/"/gi, '"')
|
||||||
.replace(/$/g, "$")
|
.replace(/…/gi, "…")
|
||||||
.replace(/…/gi, "…");
|
.replace(/•/gi, "•")
|
||||||
|
.replace(/·/gi, "·")
|
||||||
|
.replace(/–/gi, "–")
|
||||||
|
.replace(/—/gi, "—")
|
||||||
|
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
||||||
|
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
|
||||||
|
.replace(/&/gi, "&");
|
||||||
|
|
||||||
const collapse = (s: string) => s.replace(/\s+/g, " ").trim();
|
const collapse = (s: string) => s.replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user