@tellescope/sdk 1.255.9 → 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.
Files changed (47) hide show
  1. package/lib/cjs/tests/api_tests/calendar_event_limits.test.js +1 -1
  2. package/lib/cjs/tests/api_tests/calendar_event_limits.test.js.map +1 -1
  3. package/lib/cjs/tests/api_tests/dont_sync_to_elation_form_submission.test.d.ts +6 -0
  4. package/lib/cjs/tests/api_tests/dont_sync_to_elation_form_submission.test.d.ts.map +1 -0
  5. package/lib/cjs/tests/api_tests/dont_sync_to_elation_form_submission.test.js +166 -0
  6. package/lib/cjs/tests/api_tests/dont_sync_to_elation_form_submission.test.js.map +1 -0
  7. package/lib/cjs/tests/api_tests/enduser_cross_access_isolation.test.js +8 -8
  8. package/lib/cjs/tests/api_tests/enduser_cross_access_isolation.test.js.map +1 -1
  9. package/lib/cjs/tests/api_tests/nutrition_tracking.test.d.ts +6 -0
  10. package/lib/cjs/tests/api_tests/nutrition_tracking.test.d.ts.map +1 -0
  11. package/lib/cjs/tests/api_tests/nutrition_tracking.test.js +483 -0
  12. package/lib/cjs/tests/api_tests/nutrition_tracking.test.js.map +1 -0
  13. package/lib/cjs/tests/setup.d.ts +11 -1
  14. package/lib/cjs/tests/setup.d.ts.map +1 -1
  15. package/lib/cjs/tests/setup.js +19 -1
  16. package/lib/cjs/tests/setup.js.map +1 -1
  17. package/lib/cjs/tests/tests.d.ts.map +1 -1
  18. package/lib/cjs/tests/tests.js +183 -172
  19. package/lib/cjs/tests/tests.js.map +1 -1
  20. package/lib/esm/tests/api_tests/calendar_event_limits.test.js +2 -2
  21. package/lib/esm/tests/api_tests/calendar_event_limits.test.js.map +1 -1
  22. package/lib/esm/tests/api_tests/dont_sync_to_elation_form_submission.test.d.ts +6 -0
  23. package/lib/esm/tests/api_tests/dont_sync_to_elation_form_submission.test.d.ts.map +1 -0
  24. package/lib/esm/tests/api_tests/dont_sync_to_elation_form_submission.test.js +162 -0
  25. package/lib/esm/tests/api_tests/dont_sync_to_elation_form_submission.test.js.map +1 -0
  26. package/lib/esm/tests/api_tests/enduser_cross_access_isolation.test.js +9 -9
  27. package/lib/esm/tests/api_tests/enduser_cross_access_isolation.test.js.map +1 -1
  28. package/lib/esm/tests/api_tests/nutrition_tracking.test.d.ts +6 -0
  29. package/lib/esm/tests/api_tests/nutrition_tracking.test.d.ts.map +1 -0
  30. package/lib/esm/tests/api_tests/nutrition_tracking.test.js +479 -0
  31. package/lib/esm/tests/api_tests/nutrition_tracking.test.js.map +1 -0
  32. package/lib/esm/tests/setup.d.ts +11 -1
  33. package/lib/esm/tests/setup.d.ts.map +1 -1
  34. package/lib/esm/tests/setup.js +17 -0
  35. package/lib/esm/tests/setup.js.map +1 -1
  36. package/lib/esm/tests/tests.d.ts.map +1 -1
  37. package/lib/esm/tests/tests.js +184 -173
  38. package/lib/esm/tests/tests.js.map +1 -1
  39. package/lib/tsconfig.tsbuildinfo +1 -1
  40. package/package.json +8 -8
  41. package/src/tests/api_tests/calendar_event_limits.test.ts +2 -2
  42. package/src/tests/api_tests/dont_sync_to_elation_form_submission.test.ts +91 -0
  43. package/src/tests/api_tests/enduser_cross_access_isolation.test.ts +6 -6
  44. package/src/tests/api_tests/nutrition_tracking.test.ts +425 -0
  45. package/src/tests/setup.ts +13 -0
  46. package/src/tests/tests.ts +27 -18
  47. 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 authenticate. We only use sdkA
337
- // for negative assertions, but a failed sdkB auth would mean the test
338
- // data setup itself is malformed.
339
- await sdkA.authenticate(enduserA.email!, password)
340
- await sdkB.authenticate(enduserB.email!, password)
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
+ }
@@ -159,4 +159,17 @@ export const setup_tests = async (sdk: Session, sdkNonAdmin: Session) => {
159
159
  sdk.api.users.updateOne(sdk.userInfo.id, { notificationEmailsDisabled: true }),
160
160
  sdk.api.users.updateOne(sdkNonAdmin.userInfo.id, { notificationEmailsDisabled: true }),
161
161
  ]).catch(console.error)
162
+ }
163
+
164
+ // Establishes an enduser session WITHOUT hitting the IP-rate-limited /login-enduser endpoint
165
+ // (throttleByIp: 20/min + 100/hr per IP — the full suite shares one IP and can exceed both).
166
+ // Use for test setup; reserve enduserSDK.authenticate for tests OF the login flow itself.
167
+ export const authenticate_enduser_via_token = async (
168
+ sdk: Session,
169
+ enduserSDK: EnduserSession,
170
+ filter: { id?: string, email?: string, externalId?: string },
171
+ ) => {
172
+ const { authToken, enduser } = await sdk.api.endusers.generate_auth_token(filter)
173
+ await enduserSDK.handle_new_session({ authToken, enduser })
174
+ return enduser
162
175
  }