@tellescope/sdk 1.255.19 → 1.256.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.d.ts +6 -0
  2. package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.d.ts.map +1 -0
  3. package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.js +224 -0
  4. package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.js.map +1 -0
  5. package/lib/cjs/tests/api_tests/custom_dashboards.test.d.ts.map +1 -1
  6. package/lib/cjs/tests/api_tests/custom_dashboards.test.js +99 -8
  7. package/lib/cjs/tests/api_tests/custom_dashboards.test.js.map +1 -1
  8. package/lib/cjs/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
  9. package/lib/cjs/tests/api_tests/default_summary_journey.test.js.map +1 -0
  10. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
  11. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
  12. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js +278 -0
  13. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
  14. package/lib/cjs/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
  15. package/lib/cjs/tests/api_tests/openloop_webhooks.test.js +212 -33
  16. package/lib/cjs/tests/api_tests/openloop_webhooks.test.js.map +1 -1
  17. package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
  18. package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
  19. package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
  20. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
  21. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
  22. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +242 -0
  23. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
  24. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
  25. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
  26. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +443 -0
  27. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
  28. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
  29. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
  30. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +231 -0
  31. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
  32. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -1
  33. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +524 -294
  34. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -1
  35. package/lib/cjs/tests/api_tests/start_ai_conversation.test.d.ts +6 -0
  36. package/lib/cjs/tests/api_tests/start_ai_conversation.test.d.ts.map +1 -0
  37. package/lib/cjs/tests/api_tests/start_ai_conversation.test.js +351 -0
  38. package/lib/cjs/tests/api_tests/start_ai_conversation.test.js.map +1 -0
  39. package/lib/cjs/tests/tests.d.ts.map +1 -1
  40. package/lib/cjs/tests/tests.js +477 -260
  41. package/lib/cjs/tests/tests.js.map +1 -1
  42. package/lib/esm/tests/api_tests/beluga_trigger_refill.test.d.ts +6 -0
  43. package/lib/esm/tests/api_tests/beluga_trigger_refill.test.d.ts.map +1 -0
  44. package/lib/esm/tests/api_tests/beluga_trigger_refill.test.js +220 -0
  45. package/lib/esm/tests/api_tests/beluga_trigger_refill.test.js.map +1 -0
  46. package/lib/esm/tests/api_tests/custom_dashboards.test.d.ts.map +1 -1
  47. package/lib/esm/tests/api_tests/custom_dashboards.test.js +99 -8
  48. package/lib/esm/tests/api_tests/custom_dashboards.test.js.map +1 -1
  49. package/lib/esm/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
  50. package/lib/esm/tests/api_tests/default_summary_journey.test.js.map +1 -0
  51. package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
  52. package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
  53. package/lib/esm/tests/api_tests/file_download_unicode_names.test.js +248 -0
  54. package/lib/esm/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
  55. package/lib/esm/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
  56. package/lib/esm/tests/api_tests/openloop_webhooks.test.js +213 -34
  57. package/lib/esm/tests/api_tests/openloop_webhooks.test.js.map +1 -1
  58. package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
  59. package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
  60. package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
  61. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
  62. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
  63. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +238 -0
  64. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
  65. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
  66. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
  67. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +436 -0
  68. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
  69. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
  70. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
  71. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +224 -0
  72. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
  73. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -1
  74. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +524 -294
  75. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -1
  76. package/lib/esm/tests/api_tests/start_ai_conversation.test.d.ts +6 -0
  77. package/lib/esm/tests/api_tests/start_ai_conversation.test.d.ts.map +1 -0
  78. package/lib/esm/tests/api_tests/start_ai_conversation.test.js +347 -0
  79. package/lib/esm/tests/api_tests/start_ai_conversation.test.js.map +1 -0
  80. package/lib/esm/tests/tests.d.ts.map +1 -1
  81. package/lib/esm/tests/tests.js +477 -260
  82. package/lib/esm/tests/tests.js.map +1 -1
  83. package/lib/tsconfig.tsbuildinfo +1 -1
  84. package/package.json +10 -10
  85. package/src/tests/api_tests/beluga_trigger_refill.test.ts +152 -0
  86. package/src/tests/api_tests/custom_dashboards.test.ts +92 -0
  87. package/src/tests/api_tests/file_download_unicode_names.test.ts +223 -0
  88. package/src/tests/api_tests/openloop_webhooks.test.ts +138 -0
  89. package/src/tests/api_tests/phone_calls_cold_transfer.test.ts +54 -0
  90. package/src/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.ts +215 -0
  91. package/src/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.ts +292 -0
  92. package/src/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.ts +179 -0
  93. package/src/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.ts +215 -0
  94. package/src/tests/api_tests/start_ai_conversation.test.ts +256 -0
  95. package/src/tests/tests.ts +220 -1
  96. package/test_generated.pdf +0 -0
@@ -0,0 +1,152 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session } from "../../sdk"
4
+ import {
5
+ async_test,
6
+ log_header,
7
+ passOnAnyResult,
8
+ } from "@tellescope/testing"
9
+ import { setup_tests } from "../setup"
10
+
11
+ const host = process.env.API_URL || 'http://localhost:8080' as const
12
+
13
+ // Beluga Trigger Refill journey action tests (CU-86e2gbv7y).
14
+ //
15
+ // Validator-acceptance only: verifies that automation steps configured with the new
16
+ // belugaTriggerRefill action (org-mapping and inline-medIds variants) pass validation, and that
17
+ // invalid shapes are rejected. No live Beluga call is made — external/triggerRefill requires
18
+ // sandbox credentials, and the process endpoint's guard branches (missing integration, missing
19
+ // preferences) mirror the already-tested belugaAutoRx/belugaUpdateVisit handlers.
20
+ export const beluga_trigger_refill_tests = async ({ sdk, sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }) => {
21
+ log_header("Beluga Trigger Refill Journey Action Tests")
22
+
23
+ let journeyId: string | undefined
24
+ let formId: string | undefined
25
+ const stepIds: string[] = []
26
+
27
+ try {
28
+ const journey = await sdk.api.journeys.createOne({ title: 'Beluga Trigger Refill Test Journey' })
29
+ journeyId = journey.id
30
+
31
+ const form = await sdk.api.forms.createOne({ title: 'Beluga Trigger Refill Test Form' })
32
+ formId = form.id
33
+
34
+ // Inline med IDs variant
35
+ await async_test(
36
+ "belugaTriggerRefill step accepted (inline medIds)",
37
+ () => sdk.api.automation_steps.createOne({
38
+ journeyId: journey.id,
39
+ events: [{ type: 'onJourneyStart', info: {} }],
40
+ action: {
41
+ type: 'belugaTriggerRefill',
42
+ info: {
43
+ formId: form.id,
44
+ patientPreferences: [{ medId: 'med-123' }, { medId: 'med-456' }],
45
+ customFieldName: 'Refill Medication',
46
+ },
47
+ },
48
+ }).then(s => { stepIds.push(s.id); return s }),
49
+ passOnAnyResult,
50
+ )
51
+
52
+ // Organization mapping variant
53
+ await async_test(
54
+ "belugaTriggerRefill step accepted (organization mapping)",
55
+ () => sdk.api.automation_steps.createOne({
56
+ journeyId: journey.id,
57
+ events: [{ type: 'onJourneyStart', info: {} }],
58
+ action: {
59
+ type: 'belugaTriggerRefill',
60
+ info: {
61
+ formId: form.id,
62
+ useOrganizationMapping: true,
63
+ },
64
+ },
65
+ }).then(s => { stepIds.push(s.id); return s }),
66
+ passOnAnyResult,
67
+ )
68
+
69
+ // Invalid formId (not a mongo id) rejected
70
+ await async_test(
71
+ "belugaTriggerRefill step rejected (invalid formId)",
72
+ () => sdk.api.automation_steps.createOne({
73
+ journeyId: journey.id,
74
+ events: [{ type: 'onJourneyStart', info: {} }],
75
+ action: {
76
+ type: 'belugaTriggerRefill',
77
+ info: {
78
+ formId: 'not-a-mongo-id',
79
+ patientPreferences: [{ medId: 'med-123' }],
80
+ },
81
+ } as any,
82
+ }),
83
+ { shouldError: true, onError: () => true },
84
+ )
85
+
86
+ // Missing formId rejected
87
+ await async_test(
88
+ "belugaTriggerRefill step rejected (missing formId)",
89
+ () => sdk.api.automation_steps.createOne({
90
+ journeyId: journey.id,
91
+ events: [{ type: 'onJourneyStart', info: {} }],
92
+ action: {
93
+ type: 'belugaTriggerRefill',
94
+ info: {
95
+ patientPreferences: [{ medId: 'med-123' }],
96
+ },
97
+ } as any,
98
+ }),
99
+ { shouldError: true, onError: () => true },
100
+ )
101
+
102
+ // Unexpected fields in a patient preference are stripped by objectValidator, not rejected —
103
+ // assert they never reach the stored config (the step itself is accepted)
104
+ await async_test(
105
+ "belugaTriggerRefill step strips unexpected patientPreferences field",
106
+ () => sdk.api.automation_steps.createOne({
107
+ journeyId: journey.id,
108
+ events: [{ type: 'onJourneyStart', info: {} }],
109
+ action: {
110
+ type: 'belugaTriggerRefill',
111
+ info: {
112
+ formId: form.id,
113
+ patientPreferences: [{ medId: 'med-123', pharmacyId: 'not-allowed' }],
114
+ },
115
+ } as any,
116
+ }).then(s => { stepIds.push(s.id); return s }),
117
+ {
118
+ onResult: s => {
119
+ const preference = (s.action.info as any).patientPreferences?.[0]
120
+ return preference?.medId === 'med-123' && preference?.pharmacyId === undefined
121
+ },
122
+ },
123
+ )
124
+ } finally {
125
+ for (const id of stepIds) {
126
+ await sdk.api.automation_steps.deleteOne(id).catch(console.error)
127
+ }
128
+ if (formId) await sdk.api.forms.deleteOne(formId).catch(console.error)
129
+ if (journeyId) await sdk.api.journeys.deleteOne(journeyId).catch(console.error)
130
+ }
131
+ }
132
+
133
+ if (require.main === module) {
134
+ console.log(`Using API URL: ${host}`)
135
+ const sdk = new Session({ host })
136
+ const sdkNonAdmin = new Session({ host })
137
+
138
+ const runTests = async () => {
139
+ await setup_tests(sdk, sdkNonAdmin)
140
+ await beluga_trigger_refill_tests({ sdk, sdkNonAdmin })
141
+ }
142
+
143
+ runTests()
144
+ .then(() => {
145
+ console.log("✅ Beluga trigger refill test suite completed successfully")
146
+ process.exit(0)
147
+ })
148
+ .catch((error) => {
149
+ console.error("❌ Beluga trigger refill test suite failed:", error)
150
+ process.exit(1)
151
+ })
152
+ }
@@ -408,8 +408,100 @@ export const custom_dashboards_tests = async ({ sdk, sdkNonAdmin }: { sdk: Sessi
408
408
  { shouldError: true, onError: () => true }
409
409
  )
410
410
 
411
+ // Test 10: Dashboard mutations stream through /v1/data-sync
412
+ const syncFrom = new Date()
413
+ const dashboard_sync_records = (results: { modelName: string, recordId: string, data: string }[], recordId: string) => (
414
+ results.filter(r => r.modelName === 'custom_dashboards' && r.recordId === recordId)
415
+ )
416
+
417
+ const syncedDashboard = await sdk.api.custom_dashboards.createOne({
418
+ title: "Sync Create Dashboard",
419
+ blocks: [{ type: "Inbox", colSpan: 12 }],
420
+ })
421
+ await wait(undefined, 100) // sync_record is not awaited by the mutation handler
422
+
423
+ await async_test(
424
+ "Dashboard create streams to sync, admin",
425
+ () => sdk.sync({ from: syncFrom }),
426
+ {
427
+ onResult: ({ results }) => {
428
+ const records = dashboard_sync_records(results, syncedDashboard.id)
429
+ return (
430
+ records.length === 1
431
+ && records[0].data !== 'deleted'
432
+ && JSON.parse(records[0].data).title === "Sync Create Dashboard"
433
+ )
434
+ }
435
+ }
436
+ )
437
+
438
+ await sdk.api.custom_dashboards.updateOne(syncedDashboard.id, { title: "Sync Update Dashboard" })
439
+ await wait(undefined, 100)
440
+
441
+ await async_test(
442
+ "Dashboard update streams to sync, admin",
443
+ () => sdk.sync({ from: syncFrom }),
444
+ {
445
+ onResult: ({ results }) => {
446
+ const records = dashboard_sync_records(results, syncedDashboard.id)
447
+ return (
448
+ records.length === 1
449
+ && records[0].data !== 'deleted'
450
+ && JSON.parse(records[0].data).title === "Sync Update Dashboard"
451
+ )
452
+ }
453
+ }
454
+ )
455
+
456
+ await sdk.api.custom_dashboards.deleteOne(syncedDashboard.id)
457
+ await wait(undefined, 100)
458
+
459
+ await async_test(
460
+ "Dashboard delete streams to sync, admin",
461
+ () => sdk.sync({ from: syncFrom }),
462
+ {
463
+ onResult: ({ results }) => {
464
+ const records = dashboard_sync_records(results, syncedDashboard.id)
465
+ return records.length === 1 && records[0].data === 'deleted'
466
+ }
467
+ }
468
+ )
469
+
470
+ // Restricted user (read: Default) only receives org-wide and assigned dashboards
471
+ const restrictedSyncFrom = new Date()
472
+
473
+ // re-save each fixture so it lands in the restricted user's sync window
474
+ await Promise.all([
475
+ sdk.api.custom_dashboards.updateOne(orgWideDashboard.id, { description: "org-wide sync" }),
476
+ sdk.api.custom_dashboards.updateOne(assignedDashboard.id, { description: "assigned sync" }),
477
+ sdk.api.custom_dashboards.updateOne(hiddenDashboard.id, { description: "hidden sync" }),
478
+ sdk.api.custom_dashboards.updateOne(explicitFalseDashboard.id, { description: "explicit false sync" }),
479
+ ])
480
+ await wait(undefined, 100)
481
+
482
+ await async_test(
483
+ "Restricted user syncs org-wide and assigned dashboards only",
484
+ () => sdkRestricted.sync({ from: restrictedSyncFrom }),
485
+ {
486
+ onResult: ({ results }) => (
487
+ dashboard_sync_records(results, orgWideDashboard.id).length === 1
488
+ && dashboard_sync_records(results, assignedDashboard.id).length === 1
489
+ && dashboard_sync_records(results, hiddenDashboard.id).length === 0
490
+ && dashboard_sync_records(results, explicitFalseDashboard.id).length === 0
491
+ )
492
+ }
493
+ )
494
+
411
495
  // Toggling the flag on takes effect for the restricted user
496
+ const toggleSyncFrom = new Date()
412
497
  await sdk.api.custom_dashboards.updateOne(hiddenDashboard.id, { visibleToAllUsers: true })
498
+ await wait(undefined, 100)
499
+
500
+ await async_test(
501
+ "Restricted user syncs dashboard after visibleToAllUsers enabled",
502
+ () => sdkRestricted.sync({ from: toggleSyncFrom }),
503
+ { onResult: ({ results }) => dashboard_sync_records(results, hiddenDashboard.id).length === 1 }
504
+ )
413
505
 
414
506
  await async_test(
415
507
  "Restricted user can read dashboard after visibleToAllUsers enabled",
@@ -0,0 +1,223 @@
1
+ require('source-map-support').install();
2
+
3
+ import axios from "axios"
4
+ import * as buffer from 'buffer'
5
+ import { Session } from "../../sdk"
6
+ import {
7
+ assert,
8
+ log_header,
9
+ } from "@tellescope/testing"
10
+ import { setup_tests } from "../setup"
11
+
12
+ const host = process.env.API_URL || 'http://localhost:8080' as const
13
+
14
+ /**
15
+ * A presigned download URL signs response-content-disposition, which S3 validates: it rejects any
16
+ * value containing a character above U+00FF with 400 InvalidArgument "Header value cannot be
17
+ * represented using ISO-8859-1", and one with unbalanced quoting with "open quoted span". Because
18
+ * the parameter is part of the signature it cannot be repaired after the fact, so before the fix a
19
+ * file named with an em dash or a decomposed accent got a 200 from our API and a permanent 400 from
20
+ * S3 on every fetch.
21
+ *
22
+ * This test is the end-to-end check: it uploads files with those names and actually fetches the
23
+ * returned URL, which is the only thing that exercises S3's validation.
24
+ */
25
+
26
+ // Built from code points rather than written out, so that this file stays pure ASCII and so that a
27
+ // combining accent (which renders as a mark on the preceding character) is unambiguous in source.
28
+ const c = (n: number) => String.fromCharCode(n)
29
+ const EM_DASH = c(0x2014)
30
+ const EN_DASH = c(0x2013)
31
+ const RIGHT_SINGLE_Q = c(0x2019)
32
+ const COMBINING_ACUTE = c(0x0301)
33
+ const CJK_BAO_GAO = c(0x62a5) + c(0x544a)
34
+
35
+ const IS_ASCII_ONLY = /^[\x20-\x7e]*$/
36
+
37
+ // The first three are the real filenames from the bug report.
38
+ const TEST_NAMES: { name: string, label: string, expectExtValue: boolean }[] = [
39
+ {
40
+ name: `First Aid Classes Information${EM_DASH}NWLI.pdf`,
41
+ label: 'em dash (U+2014)',
42
+ expectExtValue: true,
43
+ },
44
+ {
45
+ name: `CHA${RIGHT_SINGLE_Q}s Waitlist Portal ${EN_DASH} Apply.pdf`,
46
+ label: 'right single quote (U+2019) and en dash (U+2013)',
47
+ expectExtValue: true,
48
+ },
49
+ {
50
+ name: `Ren${'e' + COMBINING_ACUTE} Test .pdf`,
51
+ label: 'decomposed accent (e + U+0301)',
52
+ expectExtValue: true,
53
+ },
54
+ {
55
+ name: `He said "hi".pdf`,
56
+ label: 'a double quote, which S3 rejects as an open quoted span',
57
+ expectExtValue: true,
58
+ },
59
+ {
60
+ name: `${CJK_BAO_GAO}.pdf`,
61
+ label: 'an entirely non-ASCII name, so the fallback cannot come from the name',
62
+ expectExtValue: true,
63
+ },
64
+ {
65
+ name: `plain ascii control.pdf`,
66
+ label: 'control: a plain ASCII name that always worked',
67
+ expectExtValue: true, // the space means filename* is emitted, carrying the real spaces
68
+ },
69
+ ]
70
+
71
+ export const file_download_unicode_names_tests = async ({ sdk }: { sdk: Session, sdkNonAdmin: Session }) => {
72
+ log_header("File Download URL with Unicode Filenames")
73
+
74
+ const fileIds: string[] = []
75
+
76
+ const uploadFile = async (name: string) => {
77
+ const buff = buffer.Buffer.from(`test file content for ${JSON.stringify(name)}`)
78
+ const { presignedUpload, file } = await sdk.api.files.prepare_file_upload({
79
+ name,
80
+ type: 'application/pdf',
81
+ size: buff.byteLength,
82
+ })
83
+ await sdk.UPLOAD(presignedUpload as any, buff)
84
+ await sdk.api.files.confirm_file_upload({ id: file.id })
85
+ fileIds.push(file.id)
86
+ return { file, contents: buff }
87
+ }
88
+
89
+ // validateStatus so that a failure reports S3's actual status and XML <Message>, rather than an
90
+ // opaque axios throw. That is what makes this test diagnostic instead of merely red.
91
+ const fetchURL = (url: string) => axios.get(url, {
92
+ responseType: 'arraybuffer',
93
+ validateStatus: () => true,
94
+ })
95
+
96
+ try {
97
+ for (const { name, label, expectExtValue } of TEST_NAMES) {
98
+ const { file, contents } = await uploadFile(name)
99
+
100
+ const { downloadURL, name: returnedName } = await sdk.api.files.file_download_URL({
101
+ secureName: file.secureName,
102
+ })
103
+
104
+ assert(
105
+ returnedName === name,
106
+ `endpoint returned "${returnedName}" instead of the stored name for ${label}`,
107
+ `stored name round-trips for ${label}`,
108
+ )
109
+
110
+ const response = await fetchURL(downloadURL)
111
+
112
+ // the bug: our API returned 200, S3 returned 400 on every fetch of the URL it gave us
113
+ assert(
114
+ response.status === 200,
115
+ `S3 returned ${response.status} for ${label}: ${buffer.Buffer.from(response.data ?? '').toString().slice(0, 400)}`,
116
+ `S3 returns 200 for ${label}`,
117
+ )
118
+
119
+ assert(
120
+ buffer.Buffer.from(response.data).toString() === contents.toString(),
121
+ `downloaded bytes did not match for ${label}`,
122
+ `downloaded bytes match for ${label}`,
123
+ )
124
+
125
+ const disposition = (response.headers['content-disposition'] ?? '') as string
126
+
127
+ assert(
128
+ IS_ASCII_ONLY.test(disposition),
129
+ `Content-Disposition was not ASCII-only for ${label}: ${disposition}`,
130
+ `Content-Disposition is ASCII-only for ${label}`,
131
+ )
132
+ assert(
133
+ disposition.startsWith('attachment;'),
134
+ `expected an attachment disposition for ${label}, got: ${disposition}`,
135
+ `disposition is attachment for ${label}`,
136
+ )
137
+ assert(
138
+ expectExtValue === disposition.includes(`filename*=UTF-8''`),
139
+ `filename* presence was wrong for ${label}: ${disposition}`,
140
+ `filename* ${expectExtValue ? 'present' : 'absent'} for ${label}`,
141
+ )
142
+ }
143
+
144
+ /* ---------------------------------------------------------------- *
145
+ * The download URL is cached server-side. preferInBrowser changes
146
+ * whether a Content-Disposition is signed into the URL at all, so it
147
+ * has to be part of the cache key - otherwise whichever variant is
148
+ * requested first is served to everyone for ~99 minutes.
149
+ * ---------------------------------------------------------------- */
150
+ const { file: cacheFile, contents: cacheContents } = await uploadFile(
151
+ `Cache Test${EM_DASH}Both Variants.pdf`
152
+ )
153
+
154
+ const asAttachment = await sdk.api.files.file_download_URL({ secureName: cacheFile.secureName })
155
+ const inBrowser = await sdk.api.files.file_download_URL({
156
+ secureName: cacheFile.secureName,
157
+ preferInBrowser: true,
158
+ })
159
+
160
+ assert(
161
+ asAttachment.downloadURL !== inBrowser.downloadURL,
162
+ 'preferInBrowser returned the cached attachment URL, so the cache key ignores it',
163
+ 'preferInBrowser is cached separately from the attachment URL',
164
+ )
165
+ assert(
166
+ asAttachment.downloadURL.includes('response-content-disposition'),
167
+ 'the default URL was signed without a content disposition',
168
+ 'the default URL signs a content disposition',
169
+ )
170
+ assert(
171
+ !inBrowser.downloadURL.includes('response-content-disposition'),
172
+ 'the preferInBrowser URL was signed with a content disposition',
173
+ 'the preferInBrowser URL omits the content disposition',
174
+ )
175
+
176
+ for (const [variant, { downloadURL }] of [['attachment', asAttachment], ['inBrowser', inBrowser]] as const) {
177
+ const response = await fetchURL(downloadURL)
178
+ assert(
179
+ response.status === 200,
180
+ `S3 returned ${response.status} for the ${variant} URL: ${buffer.Buffer.from(response.data ?? '').toString().slice(0, 400)}`,
181
+ `S3 returns 200 for the ${variant} URL`,
182
+ )
183
+ assert(
184
+ buffer.Buffer.from(response.data).toString() === cacheContents.toString(),
185
+ `downloaded bytes did not match for the ${variant} URL`,
186
+ `downloaded bytes match for the ${variant} URL`,
187
+ )
188
+ }
189
+
190
+ // a repeat request with identical arguments must still hit the cache
191
+ const repeated = await sdk.api.files.file_download_URL({ secureName: cacheFile.secureName })
192
+ assert(
193
+ repeated.downloadURL === asAttachment.downloadURL,
194
+ 'an identical repeat request did not hit the cache',
195
+ 'an identical repeat request hits the cache',
196
+ )
197
+ } finally {
198
+ for (const id of fileIds) {
199
+ await sdk.api.files.deleteOne(id).catch(() => {})
200
+ }
201
+ }
202
+ }
203
+
204
+ if (require.main === module) {
205
+ console.log(`Using API URL: ${host}`)
206
+ const sdk = new Session({ host })
207
+ const sdkNonAdmin = new Session({ host })
208
+
209
+ const runTests = async () => {
210
+ await setup_tests(sdk, sdkNonAdmin)
211
+ await file_download_unicode_names_tests({ sdk, sdkNonAdmin })
212
+ }
213
+
214
+ runTests()
215
+ .then(() => {
216
+ console.log("File download unicode names test suite completed successfully")
217
+ process.exit(0)
218
+ })
219
+ .catch((error) => {
220
+ console.error("File download unicode names test suite failed:", error)
221
+ process.exit(1)
222
+ })
223
+ }
@@ -5,6 +5,7 @@ import {
5
5
  assert,
6
6
  async_test,
7
7
  log_header,
8
+ wait,
8
9
  } from "@tellescope/testing"
9
10
  import { setup_tests } from "../setup"
10
11
 
@@ -80,9 +81,12 @@ export const openloop_webhooks_tests = async ({ sdk, sdkNonAdmin }: { sdk: Sessi
80
81
 
81
82
  const healthieId1 = 'test-healthie-ol-1'
82
83
  const healthieId2 = 'test-healthie-ol-2'
84
+ const healthieId3 = 'test-healthie-ol-3'
83
85
 
84
86
  const enduser1 = await sdk.api.endusers.createOne({ source: 'Healthie', externalId: healthieId1 })
85
87
  const enduser2 = await sdk.api.endusers.createOne({ source: 'Healthie', externalId: healthieId2 })
88
+ // dedicated to the frequency-trigger section, so tag assertions stay isolated
89
+ const enduser3 = await sdk.api.endusers.createOne({ source: 'Healthie', externalId: healthieId3 })
86
90
 
87
91
  try {
88
92
  // ===== SECTION A: V1 Validation =====
@@ -272,6 +276,84 @@ export const openloop_webhooks_tests = async ({ sdk, sdkNonAdmin }: { sdk: Sessi
272
276
  { onResult: (r: boolean) => r === true }
273
277
  )
274
278
 
279
+ await async_test(
280
+ 'V1: order_shipped updates frequency when weeksOrdered changes',
281
+ async () => {
282
+ const orderNum = `ol-ship-weeks-${uid()}`
283
+ // OLH confirms with weeksOrdered=w4
284
+ await postV1(makeV1Confirmation({
285
+ patientID: healthieId1,
286
+ orderNumber: orderNum,
287
+ weeksOrdered: 'w4',
288
+ }))
289
+
290
+ // ...then ships the same order with a different weeksOrdered, which is the value to trigger on
291
+ const res = await postV1(makeV1Shipped({
292
+ patientID: healthieId1,
293
+ orderNumber: orderNum,
294
+ weeksOrdered: 'w8',
295
+ }))
296
+ assert(res.status === 200, `Expected 200, got ${res.status}`)
297
+
298
+ const orders = await sdk.api.enduser_orders.getSome({
299
+ filter: { source: 'OpenLoop', externalId: orderNum }
300
+ })
301
+ assert(orders.length === 1, `Expected 1 order, got ${orders.length}`)
302
+ assert(orders[0].frequency === 'w8', `frequency should reflect the shipped event: ${orders[0].frequency}`)
303
+ return true
304
+ },
305
+ { onResult: (r: boolean) => r === true }
306
+ )
307
+
308
+ await async_test(
309
+ 'V1: order_shipped without weeksOrdered preserves confirmed frequency',
310
+ async () => {
311
+ const orderNum = `ol-ship-noweeks-${uid()}`
312
+ await postV1(makeV1Confirmation({
313
+ patientID: healthieId1,
314
+ orderNumber: orderNum,
315
+ weeksOrdered: 'w4',
316
+ }))
317
+
318
+ // makeV1Shipped has no weeksOrdered default, so this ships without restating it
319
+ const res = await postV1(makeV1Shipped({
320
+ patientID: healthieId1,
321
+ orderNumber: orderNum,
322
+ }))
323
+ assert(res.status === 200, `Expected 200, got ${res.status}`)
324
+
325
+ const orders = await sdk.api.enduser_orders.getSome({
326
+ filter: { source: 'OpenLoop', externalId: orderNum }
327
+ })
328
+ assert(orders.length === 1, `Expected 1 order, got ${orders.length}`)
329
+ assert(orders[0].frequency === 'w4', `frequency should not be cleared: ${orders[0].frequency}`)
330
+ return true
331
+ },
332
+ { onResult: (r: boolean) => r === true }
333
+ )
334
+
335
+ await async_test(
336
+ 'V1: order_shipped creates new order with frequency',
337
+ async () => {
338
+ const orderNum = `ol-ship-new-weeks-${uid()}`
339
+ // never confirmed, first seen as shipped
340
+ const res = await postV1(makeV1Shipped({
341
+ patientID: healthieId1,
342
+ orderNumber: orderNum,
343
+ weeksOrdered: 'w12',
344
+ }))
345
+ assert(res.status === 200, `Expected 200, got ${res.status}`)
346
+
347
+ const orders = await sdk.api.enduser_orders.getSome({
348
+ filter: { source: 'OpenLoop', externalId: orderNum }
349
+ })
350
+ assert(orders.length === 1, `Expected 1 order, got ${orders.length}`)
351
+ assert(orders[0].frequency === 'w12', `frequency mismatch: ${orders[0].frequency}`)
352
+ return true
353
+ },
354
+ { onResult: (r: boolean) => r === true }
355
+ )
356
+
275
357
  await async_test(
276
358
  'V1: order_shipped creates new order if none exists',
277
359
  async () => {
@@ -695,11 +777,67 @@ export const openloop_webhooks_tests = async ({ sdk, sdkNonAdmin }: { sdk: Sessi
695
777
  { onResult: (r: boolean) => r === true }
696
778
  )
697
779
 
780
+ // ===== SECTION J: V1 weeksOrdered drives Order Status Equals triggers =====
781
+ // The reason the shipped event's weeksOrdered matters: it is what the frequencies filter matches on.
782
+ log_header("V1 weeksOrdered trigger integration")
783
+
784
+ // Runs last so these OpenLoop/shipped triggers can't tag endusers used by earlier sections.
785
+ const shippedTrigger = await sdk.api.automation_triggers.createOne({
786
+ event: { type: 'Order Status Equals', info: { source: 'OpenLoop', status: 'shipped', frequencies: ['w8'] } },
787
+ action: { type: 'Add Tags', info: { tags: ['OLH Shipped w8'] } },
788
+ status: 'Active',
789
+ title: "OpenLoop shipped with weeksOrdered w8",
790
+ })
791
+ const staleTrigger = await sdk.api.automation_triggers.createOne({
792
+ event: { type: 'Order Status Equals', info: { source: 'OpenLoop', status: 'shipped', frequencies: ['w4'] } },
793
+ action: { type: 'Add Tags', info: { tags: ['OLH Shipped w4'] } },
794
+ status: 'Active',
795
+ title: "OpenLoop shipped with weeksOrdered w4",
796
+ })
797
+
798
+ try {
799
+ await async_test(
800
+ 'V1: shipped weeksOrdered is what Order Status Equals matches on',
801
+ async () => {
802
+ const orderNum = `ol-ship-trigger-${uid()}`
803
+ await postV1(makeV1Confirmation({
804
+ patientID: healthieId3,
805
+ orderNumber: orderNum,
806
+ weeksOrdered: 'w4',
807
+ }))
808
+ const res = await postV1(makeV1Shipped({
809
+ patientID: healthieId3,
810
+ orderNumber: orderNum,
811
+ weeksOrdered: 'w8',
812
+ }))
813
+ assert(res.status === 200, `Expected 200, got ${res.status}`)
814
+
815
+ await wait(undefined, 2000) // allow triggers to happen
816
+
817
+ const updated = await sdk.api.endusers.getOne(enduser3.id)
818
+ assert(
819
+ !!updated.tags?.includes('OLH Shipped w8'),
820
+ `expected the shipped weeksOrdered to fire its trigger, tags: ${JSON.stringify(updated.tags)}`
821
+ )
822
+ assert(
823
+ !updated.tags?.includes('OLH Shipped w4'),
824
+ `the stale confirmed weeksOrdered should not fire, tags: ${JSON.stringify(updated.tags)}`
825
+ )
826
+ return true
827
+ },
828
+ { onResult: (r: boolean) => r === true }
829
+ )
830
+ } finally {
831
+ await sdk.api.automation_triggers.deleteOne(shippedTrigger.id).catch(console.error)
832
+ await sdk.api.automation_triggers.deleteOne(staleTrigger.id).catch(console.error)
833
+ }
834
+
698
835
  console.log("All OpenLoop webhook tests passed!")
699
836
 
700
837
  } finally {
701
838
  await sdk.api.endusers.deleteOne(enduser1.id).catch(console.error)
702
839
  await sdk.api.endusers.deleteOne(enduser2.id).catch(console.error)
840
+ await sdk.api.endusers.deleteOne(enduser3.id).catch(console.error)
703
841
  }
704
842
  }
705
843