@tellescope/sdk 1.255.8 → 1.255.10
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.
- package/lib/cjs/enduser.d.ts +1 -0
- package/lib/cjs/enduser.d.ts.map +1 -1
- package/lib/cjs/sdk.d.ts +3 -2
- package/lib/cjs/sdk.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/calendar_event_limits.test.js +1 -1
- package/lib/cjs/tests/api_tests/calendar_event_limits.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/dont_sync_to_elation_form_submission.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/dont_sync_to_elation_form_submission.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/dont_sync_to_elation_form_submission.test.js +166 -0
- package/lib/cjs/tests/api_tests/dont_sync_to_elation_form_submission.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/enduser_cross_access_isolation.test.js +8 -8
- package/lib/cjs/tests/api_tests/enduser_cross_access_isolation.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/nutrition_tracking.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/nutrition_tracking.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/nutrition_tracking.test.js +483 -0
- package/lib/cjs/tests/api_tests/nutrition_tracking.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/webhook_error_handling.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/webhook_error_handling.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/webhook_error_handling.test.js +324 -0
- package/lib/cjs/tests/api_tests/webhook_error_handling.test.js.map +1 -0
- package/lib/cjs/tests/setup.d.ts +11 -1
- package/lib/cjs/tests/setup.d.ts.map +1 -1
- package/lib/cjs/tests/setup.js +19 -1
- package/lib/cjs/tests/setup.js.map +1 -1
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +207 -192
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/sdk.d.ts +2 -2
- package/lib/esm/tests/api_tests/calendar_event_limits.test.js +2 -2
- package/lib/esm/tests/api_tests/calendar_event_limits.test.js.map +1 -1
- package/lib/esm/tests/api_tests/custom_dashboards.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/custom_dashboards.test.js +259 -33
- package/lib/esm/tests/api_tests/custom_dashboards.test.js.map +1 -1
- package/lib/esm/tests/api_tests/dont_sync_to_elation_form_submission.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/dont_sync_to_elation_form_submission.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/dont_sync_to_elation_form_submission.test.js +162 -0
- package/lib/esm/tests/api_tests/dont_sync_to_elation_form_submission.test.js.map +1 -0
- package/lib/esm/tests/api_tests/enduser_cross_access_isolation.test.js +9 -9
- package/lib/esm/tests/api_tests/enduser_cross_access_isolation.test.js.map +1 -1
- package/lib/esm/tests/api_tests/medication_added_trigger.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/medication_added_trigger.test.js +206 -136
- package/lib/esm/tests/api_tests/medication_added_trigger.test.js.map +1 -1
- package/lib/esm/tests/api_tests/nutrition_tracking.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/nutrition_tracking.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/nutrition_tracking.test.js +479 -0
- package/lib/esm/tests/api_tests/nutrition_tracking.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts +10 -0
- package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js +184 -41
- package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js.map +1 -1
- package/lib/esm/tests/api_tests/webhook_error_handling.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/webhook_error_handling.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/webhook_error_handling.test.js +317 -0
- package/lib/esm/tests/api_tests/webhook_error_handling.test.js.map +1 -0
- package/lib/esm/tests/setup.d.ts +11 -1
- package/lib/esm/tests/setup.d.ts.map +1 -1
- package/lib/esm/tests/setup.js +17 -0
- package/lib/esm/tests/setup.js.map +1 -1
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +233 -206
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/src/tests/api_tests/calendar_event_limits.test.ts +2 -2
- package/src/tests/api_tests/dont_sync_to_elation_form_submission.test.ts +91 -0
- package/src/tests/api_tests/enduser_cross_access_isolation.test.ts +6 -6
- package/src/tests/api_tests/nutrition_tracking.test.ts +425 -0
- package/src/tests/api_tests/webhook_error_handling.test.ts +190 -0
- package/src/tests/setup.ts +13 -0
- package/src/tests/tests.ts +29 -18
- package/test_generated.pdf +0 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { Session } from "../../sdk"
|
|
4
|
+
import { log_header, async_test } from "@tellescope/testing"
|
|
5
|
+
import { FormResponse } from "@tellescope/types-client"
|
|
6
|
+
import { setup_tests } from "../setup"
|
|
7
|
+
|
|
8
|
+
const host = process.env.API_URL || "http://localhost:8080"
|
|
9
|
+
|
|
10
|
+
export const dont_sync_to_elation_form_submission_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
11
|
+
log_header("Submit Form Response dontSyncToElation Tests")
|
|
12
|
+
|
|
13
|
+
// Elation-note-configured form: without dontSyncToElation, submission would trigger a visit-note push
|
|
14
|
+
const form = await sdk.api.forms.createOne({
|
|
15
|
+
title: 'dontSyncToElation Test Form',
|
|
16
|
+
isNonVisitElationNote: true,
|
|
17
|
+
})
|
|
18
|
+
const field = await sdk.api.form_fields.createOne({
|
|
19
|
+
formId: form.id,
|
|
20
|
+
type: 'string',
|
|
21
|
+
title: 'Consent',
|
|
22
|
+
previousFields: [{ type: 'root', info: {} }],
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const enduser = await sdk.api.endusers.createOne({ fname: 'dont-sync-elation' })
|
|
26
|
+
|
|
27
|
+
const responses = [{
|
|
28
|
+
fieldId: field.id, fieldTitle: 'Consent',
|
|
29
|
+
answer: { type: 'string' as const, value: 'verbal consent recorded' },
|
|
30
|
+
}]
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
// ── Submission with dontSyncToElation: true is accepted and recorded ──
|
|
34
|
+
const { accessCode: accessCodeSkip } = await sdk.api.form_responses.prepare_form_response({
|
|
35
|
+
enduserId: enduser.id, formId: form.id,
|
|
36
|
+
})
|
|
37
|
+
await async_test(
|
|
38
|
+
"submit_form_response accepts dontSyncToElation: true",
|
|
39
|
+
() => sdk.api.form_responses.submit_form_response({
|
|
40
|
+
accessCode: accessCodeSkip,
|
|
41
|
+
responses,
|
|
42
|
+
dontSyncToElation: true,
|
|
43
|
+
}),
|
|
44
|
+
{ onResult: ({ formResponse }: { formResponse: FormResponse }) => (
|
|
45
|
+
!!formResponse.submittedAt
|
|
46
|
+
&& formResponse.responses?.length === 1
|
|
47
|
+
)}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
// ── Regression: submission without the flag still succeeds ──
|
|
51
|
+
const { accessCode: accessCodeDefault } = await sdk.api.form_responses.prepare_form_response({
|
|
52
|
+
enduserId: enduser.id, formId: form.id,
|
|
53
|
+
})
|
|
54
|
+
await async_test(
|
|
55
|
+
"submit_form_response without dontSyncToElation still succeeds",
|
|
56
|
+
() => sdk.api.form_responses.submit_form_response({
|
|
57
|
+
accessCode: accessCodeDefault,
|
|
58
|
+
responses,
|
|
59
|
+
}),
|
|
60
|
+
{ onResult: ({ formResponse }: { formResponse: FormResponse }) => (
|
|
61
|
+
!!formResponse.submittedAt
|
|
62
|
+
&& formResponse.responses?.length === 1
|
|
63
|
+
)}
|
|
64
|
+
)
|
|
65
|
+
} finally {
|
|
66
|
+
try { await sdk.api.endusers.deleteOne(enduser.id) } catch (err) { /* ignore */ }
|
|
67
|
+
try { await sdk.api.forms.deleteOne(form.id) } catch (err) { /* ignore */ }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Allow running this test file independently
|
|
72
|
+
if (require.main === module) {
|
|
73
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
74
|
+
const sdk = new Session({ host })
|
|
75
|
+
const sdkNonAdmin = new Session({ host })
|
|
76
|
+
|
|
77
|
+
const runTests = async () => {
|
|
78
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
79
|
+
await dont_sync_to_elation_form_submission_tests({ sdk, sdkNonAdmin })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
runTests()
|
|
83
|
+
.then(() => {
|
|
84
|
+
console.log("✅ dontSyncToElation form submission tests completed successfully")
|
|
85
|
+
process.exit(0)
|
|
86
|
+
})
|
|
87
|
+
.catch((error) => {
|
|
88
|
+
console.error("❌ dontSyncToElation form submission tests failed:", error)
|
|
89
|
+
process.exit(1)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from "@tellescope/testing"
|
|
10
10
|
import { schema } from "@tellescope/schema"
|
|
11
11
|
import { ModelName } from "@tellescope/types-models"
|
|
12
|
-
import { setup_tests } from "../setup"
|
|
12
|
+
import { authenticate_enduser_via_token, setup_tests } from "../setup"
|
|
13
13
|
|
|
14
14
|
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
15
15
|
const businessId = '60398b1131a295e64f084ff6'
|
|
@@ -333,11 +333,11 @@ export const enduser_cross_access_isolation_tests = async (
|
|
|
333
333
|
const sdkB = new EnduserSession({ host, businessId })
|
|
334
334
|
|
|
335
335
|
try {
|
|
336
|
-
// Sanity check: each enduser session can
|
|
337
|
-
// for negative assertions, but a failed sdkB
|
|
338
|
-
// data setup itself is malformed.
|
|
339
|
-
await sdkA
|
|
340
|
-
await sdkB
|
|
336
|
+
// Sanity check: each enduser session can be established. We only use sdkA
|
|
337
|
+
// for negative assertions, but a failed sdkB session would mean the test
|
|
338
|
+
// data setup itself is malformed. (Token-based to spare the IP-rate-limited /login-enduser.)
|
|
339
|
+
await authenticate_enduser_via_token(sdk, sdkA, { id: enduserA.id })
|
|
340
|
+
await authenticate_enduser_via_token(sdk, sdkB, { id: enduserB.id })
|
|
341
341
|
|
|
342
342
|
// Regression guard: hashedPassword must never appear in updateOne responses,
|
|
343
343
|
// for either enduser self-update or admin-side update of an enduser.
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { Session, EnduserSession } from "../../sdk"
|
|
4
|
+
import {
|
|
5
|
+
async_test,
|
|
6
|
+
log_header,
|
|
7
|
+
} from "@tellescope/testing"
|
|
8
|
+
import { ObservationComponent } from "@tellescope/types-models"
|
|
9
|
+
import { authenticate_enduser_via_token, setup_tests } from "../setup"
|
|
10
|
+
|
|
11
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
12
|
+
const businessId = '60398b1131a295e64f084ff6'
|
|
13
|
+
|
|
14
|
+
const ENDUSER_PASSWORD = 'NutritionTestPassword!123'
|
|
15
|
+
|
|
16
|
+
const NUTRITION_TARGETS = {
|
|
17
|
+
dailyCalorieTarget: 2200,
|
|
18
|
+
dailyProteinTarget: 150,
|
|
19
|
+
dailyCarbTarget: 250,
|
|
20
|
+
dailyFatTarget: 70,
|
|
21
|
+
dailyFiberTarget: 30,
|
|
22
|
+
dailyWaterTarget: 2500,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MACRO_COMPONENTS: ObservationComponent[] = [
|
|
26
|
+
{ code: { text: 'Calories' }, valueQuantity: { value: 450, unit: 'kcal' } },
|
|
27
|
+
{ code: { text: 'Protein' }, valueQuantity: { value: 42, unit: 'g' } },
|
|
28
|
+
{ code: { text: 'Carbohydrates' }, valueQuantity: { value: 30, unit: 'g' } },
|
|
29
|
+
{ code: { text: 'Fat' }, valueQuantity: { value: 18, unit: 'g' } },
|
|
30
|
+
{
|
|
31
|
+
code: {
|
|
32
|
+
text: 'Cheddar cheese',
|
|
33
|
+
coding: [{ system: 'https://fdc.nal.usda.gov', code: '328637', display: 'Cheese, cheddar' }],
|
|
34
|
+
},
|
|
35
|
+
valueQuantity: { value: 100, unit: 'g' },
|
|
36
|
+
},
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
const USDA_NOT_CONFIGURED_MESSAGE = 'USDA food search is not configured'
|
|
40
|
+
|
|
41
|
+
// contract shape returned by the USDA proxy — nothing else may leak through
|
|
42
|
+
const EXPECTED_FOOD_KEYS = ['fdcId', 'description', 'calories', 'protein', 'carbs', 'fat', 'fiber']
|
|
43
|
+
const MACRO_KEYS = ['calories', 'protein', 'carbs', 'fat', 'fiber'] as const
|
|
44
|
+
|
|
45
|
+
const is_valid_food_summary = (f: any) => (
|
|
46
|
+
typeof f?.fdcId === 'number' && Number.isInteger(f.fdcId) && f.fdcId > 0
|
|
47
|
+
&& typeof f?.description === 'string' && f.description.length > 0
|
|
48
|
+
&& Object.keys(f).every(k => EXPECTED_FOOD_KEYS.includes(k))
|
|
49
|
+
&& MACRO_KEYS.every(m => f[m] === undefined || (typeof f[m] === 'number' && isFinite(f[m]) && f[m] >= 0))
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
// values are per 100g: grams of a macro can't exceed 100, energy tops out near pure fat (~900 kcal)
|
|
53
|
+
const is_plausible_per_100g = (f: any) => (
|
|
54
|
+
(f.calories === undefined || f.calories <= 950)
|
|
55
|
+
&& (['protein', 'carbs', 'fat', 'fiber'] as const).every(m => f[m] === undefined || f[m] <= 100)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
// same food fetched via different USDA response shapes should yield the same numbers
|
|
59
|
+
const approx_equal = (a?: number, b?: number) => (
|
|
60
|
+
(a === undefined && b === undefined)
|
|
61
|
+
|| (typeof a === 'number' && typeof b === 'number' && Math.abs(a - b) <= Math.max(1, 0.1 * Math.max(a, b)))
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
const usda_proxy_tests = async ({ sdk, enduserSDK }: { sdk: Session, enduserSDK: EnduserSession }) => {
|
|
65
|
+
log_header("USDA Food Search Proxy")
|
|
66
|
+
|
|
67
|
+
// The USDA key lives in the server's zz_secrets - not visible to this process.
|
|
68
|
+
// Skip-detection is behavioral: attempt the search, skip on "not configured".
|
|
69
|
+
let searchResult: { data: any }
|
|
70
|
+
try {
|
|
71
|
+
searchResult = await enduserSDK.api.integrations.proxy_read({
|
|
72
|
+
integration: 'USDA',
|
|
73
|
+
type: 'search',
|
|
74
|
+
query: JSON.stringify({ keywords: 'cheddar cheese' }),
|
|
75
|
+
})
|
|
76
|
+
} catch (err: any) {
|
|
77
|
+
if (err?.message?.includes?.(USDA_NOT_CONFIGURED_MESSAGE)) {
|
|
78
|
+
console.log("⏭️ Skipping USDA proxy tests (no { type: 'usda' } zz_secrets doc configured)")
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
throw err
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await async_test(
|
|
85
|
+
'USDA search (enduser): non-empty foods with fdcId/description, totalHits > 0',
|
|
86
|
+
async () => searchResult,
|
|
87
|
+
{ onResult: (r: any) => (
|
|
88
|
+
Array.isArray(r?.data?.foods)
|
|
89
|
+
&& r.data.foods.length > 0
|
|
90
|
+
&& r.data.foods.every((f: any) => typeof f.fdcId === 'number' && typeof f.description === 'string')
|
|
91
|
+
&& r.data.foods.every((f: any) => (
|
|
92
|
+
['calories', 'protein', 'carbs', 'fat', 'fiber'] as const
|
|
93
|
+
).every(macro => f[macro] === undefined || typeof f[macro] === 'number'))
|
|
94
|
+
&& r.data.totalHits > 0
|
|
95
|
+
) },
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const firstFood = searchResult.data.foods[0]
|
|
99
|
+
await async_test(
|
|
100
|
+
'USDA food detail (enduser): matching single food by fdcId',
|
|
101
|
+
() => enduserSDK.api.integrations.proxy_read({
|
|
102
|
+
integration: 'USDA',
|
|
103
|
+
type: 'food',
|
|
104
|
+
id: String(firstFood.fdcId),
|
|
105
|
+
}),
|
|
106
|
+
{ onResult: (r: any) => (
|
|
107
|
+
r?.data?.fdcId === firstFood.fdcId
|
|
108
|
+
&& typeof r.data.description === 'string'
|
|
109
|
+
) },
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
await async_test(
|
|
113
|
+
'USDA search (staff session) also succeeds',
|
|
114
|
+
() => sdk.api.integrations.proxy_read({
|
|
115
|
+
integration: 'USDA',
|
|
116
|
+
type: 'search',
|
|
117
|
+
query: JSON.stringify({ keywords: 'cheddar cheese' }),
|
|
118
|
+
}),
|
|
119
|
+
{ onResult: (r: any) => Array.isArray(r?.data?.foods) && r.data.foods.length > 0 },
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
await async_test(
|
|
123
|
+
'USDA search: missing keywords rejected',
|
|
124
|
+
() => enduserSDK.api.integrations.proxy_read({
|
|
125
|
+
integration: 'USDA',
|
|
126
|
+
type: 'search',
|
|
127
|
+
query: JSON.stringify({}),
|
|
128
|
+
}),
|
|
129
|
+
{ shouldError: true, onError: () => true },
|
|
130
|
+
)
|
|
131
|
+
await async_test(
|
|
132
|
+
'USDA search: 1-char keywords rejected',
|
|
133
|
+
() => enduserSDK.api.integrations.proxy_read({
|
|
134
|
+
integration: 'USDA',
|
|
135
|
+
type: 'search',
|
|
136
|
+
query: JSON.stringify({ keywords: 'c' }),
|
|
137
|
+
}),
|
|
138
|
+
{ shouldError: true, onError: () => true },
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
// ---- rigorous output-format validation ----
|
|
142
|
+
await async_test(
|
|
143
|
+
'USDA format: every search result matches the contract shape exactly (no extra fields)',
|
|
144
|
+
async () => searchResult,
|
|
145
|
+
{ onResult: (r: any) => r.data.foods.every((f: any) => is_valid_food_summary(f) && is_plausible_per_100g(f)) },
|
|
146
|
+
)
|
|
147
|
+
await async_test(
|
|
148
|
+
'USDA format: no api_key or raw USDA payload fields leak into the response',
|
|
149
|
+
async () => searchResult,
|
|
150
|
+
{ onResult: (r: any) => {
|
|
151
|
+
const serialized = JSON.stringify(r.data)
|
|
152
|
+
return !serialized.includes('api_key') && !serialized.includes('foodNutrients') && !serialized.includes('nutrientNumber')
|
|
153
|
+
} },
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
// extraction must actually populate macros across the result set (catches a broken
|
|
157
|
+
// nutrient-number mapping, which the undefined-tolerant shape checks above would miss)
|
|
158
|
+
await async_test(
|
|
159
|
+
'USDA format: macro extraction populated for nearly all results',
|
|
160
|
+
async () => searchResult,
|
|
161
|
+
{ onResult: (r: any) => {
|
|
162
|
+
const foods = r.data.foods
|
|
163
|
+
const fullyPopulated = foods.filter((f: any) => MACRO_KEYS.every(m => typeof f[m] === 'number'))
|
|
164
|
+
return foods.length > 0 && fullyPopulated.length >= foods.length * 0.8
|
|
165
|
+
} },
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
// at least one result must match full-fat cheddar's real per-100g profile
|
|
169
|
+
// (search includes reduced-fat branded variants, so assert existence rather than checking an arbitrary pick)
|
|
170
|
+
const fullFatCheddar = searchResult.data.foods.find((f: any) => (
|
|
171
|
+
f.description?.toLowerCase().includes('cheddar')
|
|
172
|
+
&& f.calories >= 300 && f.calories <= 500
|
|
173
|
+
&& f.protein >= 15 && f.protein <= 35
|
|
174
|
+
&& f.fat >= 25 && f.fat <= 45
|
|
175
|
+
&& f.carbs >= 0 && f.carbs <= 15
|
|
176
|
+
&& (f.fiber === undefined || (f.fiber >= 0 && f.fiber <= 5))
|
|
177
|
+
))
|
|
178
|
+
await async_test(
|
|
179
|
+
'USDA format: a result matches full-fat cheddar\'s realistic per-100g values',
|
|
180
|
+
async () => fullFatCheddar,
|
|
181
|
+
{ onResult: (f: any) => !!f },
|
|
182
|
+
)
|
|
183
|
+
await async_test(
|
|
184
|
+
'USDA format: food detail (abridged shape) yields same values as search (search shape) for same fdcId',
|
|
185
|
+
() => enduserSDK.api.integrations.proxy_read({
|
|
186
|
+
integration: 'USDA',
|
|
187
|
+
type: 'food',
|
|
188
|
+
id: String(fullFatCheddar?.fdcId),
|
|
189
|
+
}),
|
|
190
|
+
{ onResult: (r: any) => (
|
|
191
|
+
is_valid_food_summary(r?.data)
|
|
192
|
+
&& r.data.fdcId === fullFatCheddar?.fdcId
|
|
193
|
+
&& MACRO_KEYS.every(m => approx_equal(r.data[m], fullFatCheddar?.[m]))
|
|
194
|
+
) },
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
await async_test(
|
|
198
|
+
'USDA format: pageSize honored (5 -> exactly 5 valid foods)',
|
|
199
|
+
() => enduserSDK.api.integrations.proxy_read({
|
|
200
|
+
integration: 'USDA',
|
|
201
|
+
type: 'search',
|
|
202
|
+
query: JSON.stringify({ keywords: 'apple', pageSize: 5 }),
|
|
203
|
+
}),
|
|
204
|
+
{ onResult: (r: any) => (
|
|
205
|
+
r?.data?.foods?.length === 5
|
|
206
|
+
&& r.data.totalHits >= 5
|
|
207
|
+
&& r.data.foods.every((f: any) => is_valid_food_summary(f) && is_plausible_per_100g(f))
|
|
208
|
+
) },
|
|
209
|
+
)
|
|
210
|
+
await async_test(
|
|
211
|
+
'USDA format: oversized pageSize clamped to at most 50',
|
|
212
|
+
() => enduserSDK.api.integrations.proxy_read({
|
|
213
|
+
integration: 'USDA',
|
|
214
|
+
type: 'search',
|
|
215
|
+
query: JSON.stringify({ keywords: 'banana', pageSize: 10000 }),
|
|
216
|
+
}),
|
|
217
|
+
{ onResult: (r: any) => r?.data?.foods?.length > 0 && r.data.foods.length <= 50 },
|
|
218
|
+
)
|
|
219
|
+
await async_test(
|
|
220
|
+
'USDA format: non-numeric food id rejected',
|
|
221
|
+
() => enduserSDK.api.integrations.proxy_read({
|
|
222
|
+
integration: 'USDA',
|
|
223
|
+
type: 'food',
|
|
224
|
+
id: '../foods/search',
|
|
225
|
+
}),
|
|
226
|
+
{ shouldError: true, onError: () => true },
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export const nutrition_tracking_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
231
|
+
log_header("Nutrition Tracking (weight & macro backend support)")
|
|
232
|
+
|
|
233
|
+
let enduserAId: string | undefined
|
|
234
|
+
let enduserBId: string | undefined
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
// Setup: throwaway endusers with portal credentials
|
|
238
|
+
const ts = Date.now()
|
|
239
|
+
const enduserAEmail = `nutrition-a-${ts}@test.tellescope.com`
|
|
240
|
+
const enduserBEmail = `nutrition-b-${ts}@test.tellescope.com`
|
|
241
|
+
|
|
242
|
+
const enduserA = await sdk.api.endusers.createOne({ email: enduserAEmail, fname: 'Nutrition', lname: 'TesterA' })
|
|
243
|
+
enduserAId = enduserA.id
|
|
244
|
+
await sdk.api.endusers.set_password({ id: enduserA.id, password: ENDUSER_PASSWORD })
|
|
245
|
+
|
|
246
|
+
const enduserB = await sdk.api.endusers.createOne({ email: enduserBEmail, fname: 'Nutrition', lname: 'TesterB' })
|
|
247
|
+
enduserBId = enduserB.id
|
|
248
|
+
await sdk.api.endusers.set_password({ id: enduserB.id, password: ENDUSER_PASSWORD })
|
|
249
|
+
|
|
250
|
+
const enduserSDK = new EnduserSession({ host, businessId })
|
|
251
|
+
await authenticate_enduser_via_token(sdk, enduserSDK, { email: enduserAEmail })
|
|
252
|
+
|
|
253
|
+
const enduserSDKB = new EnduserSession({ host, businessId })
|
|
254
|
+
await authenticate_enduser_via_token(sdk, enduserSDKB, { email: enduserBEmail })
|
|
255
|
+
|
|
256
|
+
// ---- Requirement 3: daily nutrition targets (enduser-readable and -writable) ----
|
|
257
|
+
await async_test(
|
|
258
|
+
'targets: enduser sets all six daily nutrition targets on own record',
|
|
259
|
+
() => enduserSDK.api.endusers.updateOne(enduserA.id, { ...NUTRITION_TARGETS }),
|
|
260
|
+
{ onResult: e => Object.entries(NUTRITION_TARGETS).every(([field, value]) => (e as any)[field] === value) },
|
|
261
|
+
)
|
|
262
|
+
await async_test(
|
|
263
|
+
'targets: enduser reads all six targets back (no redaction)',
|
|
264
|
+
() => enduserSDK.api.endusers.getOne(enduserA.id),
|
|
265
|
+
{ onResult: e => Object.entries(NUTRITION_TARGETS).every(([field, value]) => (e as any)[field] === value) },
|
|
266
|
+
)
|
|
267
|
+
await async_test(
|
|
268
|
+
'targets: staff can read targets',
|
|
269
|
+
() => sdk.api.endusers.getOne(enduserA.id),
|
|
270
|
+
{ onResult: e => Object.entries(NUTRITION_TARGETS).every(([field, value]) => (e as any)[field] === value) },
|
|
271
|
+
)
|
|
272
|
+
await async_test(
|
|
273
|
+
'targets: staff can write targets',
|
|
274
|
+
() => sdk.api.endusers.updateOne(enduserA.id, { dailyCalorieTarget: 2000, dailyWaterTarget: 3000 }),
|
|
275
|
+
{ onResult: e => e.dailyCalorieTarget === 2000 && e.dailyWaterTarget === 3000 },
|
|
276
|
+
)
|
|
277
|
+
await async_test(
|
|
278
|
+
'targets: negative values rejected',
|
|
279
|
+
() => enduserSDK.api.endusers.updateOne(enduserA.id, { dailyProteinTarget: -10 }),
|
|
280
|
+
{ shouldError: true, onError: () => true },
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
// ---- Requirement 2: EnduserObservation.components round-trip ----
|
|
284
|
+
const withComponents = await enduserSDK.api.enduser_observations.createOne({
|
|
285
|
+
enduserId: enduserA.id,
|
|
286
|
+
status: 'final',
|
|
287
|
+
category: 'vital-signs',
|
|
288
|
+
type: 'food',
|
|
289
|
+
measurement: { value: 450, unit: 'kcal' },
|
|
290
|
+
components: MACRO_COMPONENTS,
|
|
291
|
+
})
|
|
292
|
+
await async_test(
|
|
293
|
+
'components: enduser round-trips observation with macros + coded food item',
|
|
294
|
+
() => enduserSDK.api.enduser_observations.getOne(withComponents.id),
|
|
295
|
+
{ onResult: o => (
|
|
296
|
+
o.components?.length === MACRO_COMPONENTS.length
|
|
297
|
+
&& o.components[1].code.text === 'Protein'
|
|
298
|
+
&& o.components[1].valueQuantity.value === 42
|
|
299
|
+
&& o.components[1].valueQuantity.unit === 'g'
|
|
300
|
+
&& o.components[4].code.coding?.[0]?.system === 'https://fdc.nal.usda.gov'
|
|
301
|
+
&& o.components[4].code.coding?.[0]?.code === '328637'
|
|
302
|
+
&& o.components[4].code.coding?.[0]?.display === 'Cheese, cheddar'
|
|
303
|
+
&& o.measurement.value === 450
|
|
304
|
+
) },
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
const withoutComponents = await enduserSDK.api.enduser_observations.createOne({
|
|
308
|
+
enduserId: enduserA.id,
|
|
309
|
+
status: 'final',
|
|
310
|
+
category: 'vital-signs',
|
|
311
|
+
type: 'weight',
|
|
312
|
+
measurement: { value: 180, unit: 'lbs' },
|
|
313
|
+
})
|
|
314
|
+
await async_test(
|
|
315
|
+
'components: observation without components still works (back-compat)',
|
|
316
|
+
() => enduserSDK.api.enduser_observations.getOne(withoutComponents.id),
|
|
317
|
+
{ onResult: o => o.components === undefined && o.measurement.value === 180 },
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
// ---- Requirement 4: creatorOnly update/delete ----
|
|
321
|
+
await async_test(
|
|
322
|
+
'creatorOnly: enduser updates own observation',
|
|
323
|
+
() => enduserSDK.api.enduser_observations.updateOne(withoutComponents.id, { measurement: { value: 178, unit: 'lbs' } }),
|
|
324
|
+
{ onResult: o => o.measurement.value === 178 },
|
|
325
|
+
)
|
|
326
|
+
await async_test(
|
|
327
|
+
'creatorOnly: enduser deletes own observation',
|
|
328
|
+
() => enduserSDK.api.enduser_observations.deleteOne(withoutComponents.id),
|
|
329
|
+
{ shouldError: false, onResult: () => true },
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
// Negative: staff-created observation for the enduser remains locked
|
|
333
|
+
const staffCreated = await sdk.api.enduser_observations.createOne({
|
|
334
|
+
enduserId: enduserA.id,
|
|
335
|
+
status: 'final',
|
|
336
|
+
category: 'vital-signs',
|
|
337
|
+
type: 'weight',
|
|
338
|
+
measurement: { value: 182, unit: 'lbs' },
|
|
339
|
+
})
|
|
340
|
+
await async_test(
|
|
341
|
+
'creatorOnly: enduser cannot update staff-created observation',
|
|
342
|
+
() => enduserSDK.api.enduser_observations.updateOne(staffCreated.id, { measurement: { value: 1, unit: 'lbs' } }),
|
|
343
|
+
{ shouldError: true, onError: () => true },
|
|
344
|
+
)
|
|
345
|
+
await async_test(
|
|
346
|
+
'creatorOnly: enduser cannot delete staff-created observation',
|
|
347
|
+
() => enduserSDK.api.enduser_observations.deleteOne(staffCreated.id),
|
|
348
|
+
{ shouldError: true, onError: () => true },
|
|
349
|
+
)
|
|
350
|
+
await async_test(
|
|
351
|
+
'creatorOnly: enduser can still read staff-created observation',
|
|
352
|
+
() => enduserSDK.api.enduser_observations.getOne(staffCreated.id),
|
|
353
|
+
{ onResult: o => o.id === staffCreated.id && o.measurement.value === 182 },
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
// Staff sessions unaffected by creatorOnly (applies to enduser sessions only)
|
|
357
|
+
await async_test(
|
|
358
|
+
'staff unchanged: staff updates enduser-created observation',
|
|
359
|
+
() => sdk.api.enduser_observations.updateOne(withComponents.id, { measurement: { value: 500, unit: 'kcal' } }),
|
|
360
|
+
{ onResult: o => o.measurement.value === 500 },
|
|
361
|
+
)
|
|
362
|
+
await async_test(
|
|
363
|
+
'staff unchanged: staff deletes enduser-created observation',
|
|
364
|
+
() => sdk.api.enduser_observations.deleteOne(withComponents.id),
|
|
365
|
+
{ shouldError: false, onResult: () => true },
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
// Cross-enduser: B cannot update/delete A's observation
|
|
369
|
+
const enduserACreated = await enduserSDK.api.enduser_observations.createOne({
|
|
370
|
+
enduserId: enduserA.id,
|
|
371
|
+
status: 'final',
|
|
372
|
+
category: 'vital-signs',
|
|
373
|
+
type: 'weight',
|
|
374
|
+
measurement: { value: 179, unit: 'lbs' },
|
|
375
|
+
})
|
|
376
|
+
await async_test(
|
|
377
|
+
"cross-enduser: enduser B cannot update enduser A's observation",
|
|
378
|
+
() => enduserSDKB.api.enduser_observations.updateOne(enduserACreated.id, { measurement: { value: 1, unit: 'lbs' } }),
|
|
379
|
+
{ shouldError: true, onError: () => true },
|
|
380
|
+
)
|
|
381
|
+
await async_test(
|
|
382
|
+
"cross-enduser: enduser B cannot delete enduser A's observation",
|
|
383
|
+
() => enduserSDKB.api.enduser_observations.deleteOne(enduserACreated.id),
|
|
384
|
+
{ shouldError: true, onError: () => true },
|
|
385
|
+
)
|
|
386
|
+
await async_test(
|
|
387
|
+
"cross-enduser: enduser A's observation still exists (admin verify)",
|
|
388
|
+
() => sdk.api.enduser_observations.getOne(enduserACreated.id),
|
|
389
|
+
{ onResult: o => o.id === enduserACreated.id && o.measurement.value === 179 },
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
// ---- Requirement 1: USDA food search proxy (behavioral skip when not configured) ----
|
|
393
|
+
await usda_proxy_tests({ sdk, enduserSDK })
|
|
394
|
+
} finally {
|
|
395
|
+
// observations cascade-delete with their enduser
|
|
396
|
+
if (enduserAId) {
|
|
397
|
+
try { await sdk.api.endusers.deleteOne(enduserAId) } catch {}
|
|
398
|
+
}
|
|
399
|
+
if (enduserBId) {
|
|
400
|
+
try { await sdk.api.endusers.deleteOne(enduserBId) } catch {}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Allow running this test file independently
|
|
406
|
+
if (require.main === module) {
|
|
407
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
408
|
+
const sdk = new Session({ host })
|
|
409
|
+
const sdkNonAdmin = new Session({ host })
|
|
410
|
+
|
|
411
|
+
const runTests = async () => {
|
|
412
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
413
|
+
await nutrition_tracking_tests({ sdk, sdkNonAdmin })
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
runTests()
|
|
417
|
+
.then(() => {
|
|
418
|
+
console.log("✅ Nutrition tracking test suite completed successfully")
|
|
419
|
+
process.exit(0)
|
|
420
|
+
})
|
|
421
|
+
.catch((error) => {
|
|
422
|
+
console.error("❌ Nutrition tracking test suite failed:", error)
|
|
423
|
+
process.exit(1)
|
|
424
|
+
})
|
|
425
|
+
}
|