mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

hazards.test.ts

Language TypeScript8.5 KB248 lines

packages/plinth-client/test/hazards.test.ts

  1. /**
  2. * Tests for the three silent-failure modes documented in docs/ARCHITECTURE.md.
  3. * Sample values follow the shapes seen in live responses.
  4. */
  5. import { describe, expect, it } from 'vitest'
  6. import {
  7. assertKnownParams,
  8. assertReadMethod,
  9. FORBIDDEN_METHODS,
  10. GET_ORDERS_PARAMS,
  11. PlinthForbiddenMethodError,
  12. READ_METHODS,
  13. isEmptyOrInvalid,
  14. num,
  15. PlinthUnknownParamError,
  16. phpCollection,
  17. readError,
  18. str,
  19. toPlinthDate,
  20. } from '../src/hazards.js'
  21. import { DROPPED_FIELDS, projectOrder, projectProduct } from '../src/projection.js'
  22. import { PLINTH_RATE_LIMIT } from '../src/index.js'
  23. describe('read-only enforcement', () => {
  24. it('refuses the write methods by name', () => {
  25. // Plinth dispatches by query param, so HTTP verb enforces nothing. A GET with
  26. // method=orders.setStatus writes to production and can email customers.
  27. expect(() => assertReadMethod('orders.setStatus')).toThrow(PlinthForbiddenMethodError)
  28. expect(() => assertReadMethod('inventory.adjust')).toThrow(PlinthForbiddenMethodError)
  29. })
  30. it('says why, including the customer-email risk', () => {
  31. expect(() => assertReadMethod('orders.setStatus')).toThrow(/email customers/)
  32. })
  33. it('refuses unknown methods too, not just the known-bad ones', () => {
  34. expect(() => assertReadMethod('setSomethingNew')).toThrow(PlinthForbiddenMethodError)
  35. expect(() => assertReadMethod('')).toThrow(PlinthForbiddenMethodError)
  36. })
  37. it('allows every read method', () => {
  38. for (const m of READ_METHODS) expect(() => assertReadMethod(m)).not.toThrow()
  39. })
  40. it('has no write method on the allowlist', () => {
  41. for (const w of FORBIDDEN_METHODS) {
  42. expect(READ_METHODS as readonly string[]).not.toContain(w)
  43. }
  44. })
  45. it('is not configurable: no env var can widen it', () => {
  46. // Guards against someone adding an PLINTH_ALLOW_WRITES escape hatch later.
  47. const source = assertReadMethod.toString() + READ_METHODS.join(',')
  48. expect(source).not.toMatch(/process\.env|ALLOW_WRITE/i)
  49. })
  50. })
  51. describe('fail-open parameters', () => {
  52. it('refuses a param Plinth would silently drop', () => {
  53. // The documented trap: phone on customers.fetch returns the FULL
  54. // unfiltered list rather than erroring.
  55. expect(() => assertKnownParams('orders.fetch', { phone: '555' }, GET_ORDERS_PARAMS)).toThrow(
  56. PlinthUnknownParamError,
  57. )
  58. })
  59. it('explains why, since the failure is invisible otherwise', () => {
  60. expect(() => assertKnownParams('orders.fetch', { order_number: 'MD2639777' }, GET_ORDERS_PARAMS)).toThrow(
  61. /UNFILTERED/,
  62. )
  63. })
  64. it('allows every documented param', () => {
  65. expect(() =>
  66. assertKnownParams(
  67. 'orders.fetch',
  68. { order_ref: 38794, status_code: '121', since: '20260801', limit: '50' },
  69. GET_ORDERS_PARAMS,
  70. ),
  71. ).not.toThrow()
  72. })
  73. it('ignores undefined values rather than rejecting them', () => {
  74. expect(() => assertKnownParams('orders.fetch', { customer_ref: undefined }, GET_ORDERS_PARAMS)).not.toThrow()
  75. })
  76. })
  77. describe('PHP collection type switching', () => {
  78. it('reads a populated collection keyed 1..N', () => {
  79. expect(phpCollection({ '1': { id: 'a' }, '2': { id: 'b' } })).toEqual([{ id: 'a' }, { id: 'b' }])
  80. })
  81. it('reads an empty collection sent as an array', () => {
  82. expect(phpCollection([])).toEqual([])
  83. })
  84. it('handles both forms in one response, which Plinth does', () => {
  85. const order = { lines: { '1': { p: 1 }, '2': { p: 2 } }, shipments: [] }
  86. expect(phpCollection(order.lines)).toHaveLength(2)
  87. expect(phpCollection(order.shipments)).toHaveLength(0)
  88. })
  89. it('survives null and undefined', () => {
  90. expect(phpCollection(null)).toEqual([])
  91. expect(phpCollection(undefined)).toEqual([])
  92. })
  93. })
  94. describe('unstable numeric typing', () => {
  95. it('coerces the string and number forms of the same field', () => {
  96. // Real values: tax_total "0.000000" and "0.00"; ship_weight 2.72 vs
  97. // unit_weight "2.724480".
  98. expect(num('0.000000')).toBe(0)
  99. expect(num('2.724480')).toBeCloseTo(2.72448)
  100. expect(num(2.72)).toBe(2.72)
  101. })
  102. it('prevents the string-concatenation bug', () => {
  103. const a = '1450'
  104. const b = 162.3375
  105. // @ts-expect-error demonstrating the bug this exists to prevent
  106. expect(a + b).toBe('1450162.3375')
  107. expect(num(a) + num(b)).toBeCloseTo(1612.3375)
  108. })
  109. it('falls back rather than producing NaN', () => {
  110. expect(num('')).toBe(0)
  111. expect(num(null)).toBe(0)
  112. expect(num('not a number', -1)).toBe(-1)
  113. })
  114. it('treats null and empty string alike, as Plinth does', () => {
  115. expect(str(null)).toBeNull()
  116. expect(str('')).toBeNull()
  117. expect(str(' ')).toBeNull()
  118. expect(str('Northline Print (orders@northlineprint.example)')).toBe('Northline Print (orders@northlineprint.example)')
  119. })
  120. })
  121. describe('date format', () => {
  122. it('converts ISO to yyyymmdd', () => {
  123. expect(toPlinthDate('2026-08-09')).toBe('20260809')
  124. })
  125. it('puts the month before the day', () => {
  126. // yyyyddmm would silently return a different window for any day <= 12.
  127. expect(toPlinthDate('2026-08-09')).not.toBe('20260908')
  128. })
  129. it('rejects anything that is not ISO rather than sending garbage', () => {
  130. expect(() => toPlinthDate('09/08/2026')).toThrow()
  131. })
  132. })
  133. describe('ambiguous errors', () => {
  134. it('recognizes that "Invalid request" also means no results', () => {
  135. expect(isEmptyOrInvalid({ error: 'Invalid request' })).toBe(true)
  136. })
  137. it('reads both error shapes', () => {
  138. expect(readError({ error: 'Bad since date' })).toBe('Bad since date')
  139. expect(readError({ notice: 'No match' })).toBe('No match')
  140. })
  141. it('does not treat a real result as an error', () => {
  142. expect(readError({ order_map: {} })).toBeNull()
  143. expect(isEmptyOrInvalid({ order_map: {} })).toBe(false)
  144. })
  145. it('does not classify rate limiting as empty', () => {
  146. // This one IS transient and should be retried; "Invalid request" must not be.
  147. expect(isEmptyOrInvalid({ error: 'Too many requests. Slow down and retry.' })).toBe(false)
  148. })
  149. })
  150. describe('projection: internal tool, per the data-handling rule, not the Northgate rules', () => {
  151. const order = {
  152. order_ref: 38794,
  153. status_label: 'On Press',
  154. grand_total: 1843.275,
  155. first_name: 'Jordan',
  156. last_name: 'Hale',
  157. company_name: 'Harbor Signs',
  158. email: 'Jordan.Hale@harborsigns.example',
  159. phone: '5550100123',
  160. bill_street: '100 Example Way',
  161. ship_postcode: '00000',
  162. }
  163. it('keeps names and companies, which make a row readable', () => {
  164. const p = projectOrder(order)
  165. expect(p.first_name).toBe('Jordan')
  166. expect(p.company_name).toBe('Harbor Signs')
  167. })
  168. it('drops contact details and addresses no panel renders', () => {
  169. const p = projectOrder(order)
  170. expect(p).not.toHaveProperty('email')
  171. expect(p).not.toHaveProperty('phone')
  172. expect(p).not.toHaveProperty('bill_street')
  173. expect(p).not.toHaveProperty('ship_postcode')
  174. })
  175. it('keeps the operational fields', () => {
  176. const p = projectOrder(order)
  177. expect(p.order_ref).toBe(38794)
  178. expect(p.status_label).toBe('On Press')
  179. expect(p.grand_total).toBeCloseTo(1843.275)
  180. })
  181. it('drops the artwork URL blob for size, not sensitivity', () => {
  182. const product = {
  183. line_ref: 51206,
  184. line_status: 'At Partner Shop',
  185. item_name: '10 ft SEG Backdrop',
  186. printfile_info: { own_artwork: { PROOF_BACK: 'https://...' } },
  187. }
  188. const p = projectProduct(product)
  189. expect(p.line_status).toBe('At Partner Shop')
  190. expect(p).not.toHaveProperty('printfile_info')
  191. expect(DROPPED_FIELDS.artwork).toContain('printfile_info')
  192. })
  193. })
  194. describe('rate limit', () => {
  195. it('throttles below what Plinth enforces', () => {
  196. const perWindow = PLINTH_RATE_LIMIT.refillPerSecond * PLINTH_RATE_LIMIT.documentedLimit.perSeconds
  197. expect(perWindow).toBe(9)
  198. expect(perWindow).toBeLessThan(PLINTH_RATE_LIMIT.documentedLimit.requests)
  199. })
  200. it('bursts no higher than the sustained window allows', () => {
  201. expect(PLINTH_RATE_LIMIT.capacity).toBeLessThan(PLINTH_RATE_LIMIT.documentedLimit.requests)
  202. })
  203. it('is slow enough that a fan-out design would be unusable', () => {
  204. // 300 per-order detail calls would take ~5.6 minutes. This test exists to
  205. // make that concrete: page in bulk, never fan out.
  206. const secondsFor300 = 300 / PLINTH_RATE_LIMIT.refillPerSecond
  207. expect(secondsFor300).toBeGreaterThan(300)
  208. })
  209. it('is fast enough for a paged sweep inside a 15-minute cron', () => {
  210. const secondsFor12Calls = 12 / PLINTH_RATE_LIMIT.refillPerSecond
  211. expect(secondsFor12Calls).toBeLessThan(30)
  212. })
  213. })