@tellescope/sdk 1.253.0 → 1.254.0

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 (98) hide show
  1. package/lib/cjs/sdk.d.ts +4 -0
  2. package/lib/cjs/sdk.d.ts.map +1 -1
  3. package/lib/cjs/tests/api_tests/_tmp_bc_verify.test.d.ts.map +1 -0
  4. package/lib/cjs/tests/api_tests/_tmp_blank_status_verify.test.d.ts.map +1 -0
  5. package/lib/cjs/tests/api_tests/_tmp_followup_verify.test.d.ts.map +1 -0
  6. package/lib/cjs/tests/api_tests/_tmp_nonmatch_verify.test.d.ts.map +1 -0
  7. package/lib/cjs/tests/api_tests/beluga_manual_sync.test.d.ts +6 -0
  8. package/lib/cjs/tests/api_tests/beluga_manual_sync.test.d.ts.map +1 -0
  9. package/lib/cjs/tests/api_tests/beluga_manual_sync.test.js +256 -0
  10. package/lib/cjs/tests/api_tests/beluga_manual_sync.test.js.map +1 -0
  11. package/lib/cjs/tests/api_tests/field_redaction.test.d.ts.map +1 -1
  12. package/lib/cjs/tests/api_tests/field_redaction.test.js +112 -99
  13. package/lib/cjs/tests/api_tests/field_redaction.test.js.map +1 -1
  14. package/lib/cjs/tests/api_tests/gcal_sync_retry.test.d.ts +43 -0
  15. package/lib/cjs/tests/api_tests/gcal_sync_retry.test.d.ts.map +1 -0
  16. package/lib/cjs/tests/api_tests/gcal_sync_retry.test.js +168 -0
  17. package/lib/cjs/tests/api_tests/gcal_sync_retry.test.js.map +1 -0
  18. package/lib/cjs/tests/api_tests/journey_error_branching.test.js +7 -2
  19. package/lib/cjs/tests/api_tests/journey_error_branching.test.js.map +1 -1
  20. package/lib/cjs/tests/api_tests/resource_access_tags.test.d.ts +22 -0
  21. package/lib/cjs/tests/api_tests/resource_access_tags.test.d.ts.map +1 -0
  22. package/lib/cjs/tests/api_tests/resource_access_tags.test.js +488 -0
  23. package/lib/cjs/tests/api_tests/resource_access_tags.test.js.map +1 -0
  24. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts +23 -0
  25. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -0
  26. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +325 -0
  27. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -0
  28. package/lib/cjs/tests/api_tests/time_tracks.test.d.ts +8 -0
  29. package/lib/cjs/tests/api_tests/time_tracks.test.d.ts.map +1 -1
  30. package/lib/cjs/tests/api_tests/time_tracks.test.js +638 -1
  31. package/lib/cjs/tests/api_tests/time_tracks.test.js.map +1 -1
  32. package/lib/cjs/tests/api_tests/user_portal_settings.test.d.ts.map +1 -1
  33. package/lib/cjs/tests/api_tests/user_portal_settings.test.js +104 -28
  34. package/lib/cjs/tests/api_tests/user_portal_settings.test.js.map +1 -1
  35. package/lib/cjs/tests/tests.d.ts.map +1 -1
  36. package/lib/cjs/tests/tests.js +878 -200
  37. package/lib/cjs/tests/tests.js.map +1 -1
  38. package/lib/esm/sdk.d.ts +4 -0
  39. package/lib/esm/sdk.d.ts.map +1 -1
  40. package/lib/esm/tests/api_tests/_tmp_bc_verify.test.d.ts +2 -0
  41. package/lib/esm/tests/api_tests/_tmp_bc_verify.test.d.ts.map +1 -0
  42. package/lib/esm/tests/api_tests/_tmp_bc_verify.test.js +120 -0
  43. package/lib/esm/tests/api_tests/_tmp_bc_verify.test.js.map +1 -0
  44. package/lib/esm/tests/api_tests/_tmp_blank_status_verify.test.d.ts +2 -0
  45. package/lib/esm/tests/api_tests/_tmp_blank_status_verify.test.d.ts.map +1 -0
  46. package/lib/esm/tests/api_tests/_tmp_blank_status_verify.test.js +200 -0
  47. package/lib/esm/tests/api_tests/_tmp_blank_status_verify.test.js.map +1 -0
  48. package/lib/esm/tests/api_tests/_tmp_followup_verify.test.d.ts +2 -0
  49. package/lib/esm/tests/api_tests/_tmp_followup_verify.test.d.ts.map +1 -0
  50. package/lib/esm/tests/api_tests/_tmp_followup_verify.test.js +194 -0
  51. package/lib/esm/tests/api_tests/_tmp_followup_verify.test.js.map +1 -0
  52. package/lib/esm/tests/api_tests/_tmp_nonmatch_verify.test.d.ts +2 -0
  53. package/lib/esm/tests/api_tests/_tmp_nonmatch_verify.test.d.ts.map +1 -0
  54. package/lib/esm/tests/api_tests/_tmp_nonmatch_verify.test.js +115 -0
  55. package/lib/esm/tests/api_tests/_tmp_nonmatch_verify.test.js.map +1 -0
  56. package/lib/esm/tests/api_tests/beluga_manual_sync.test.d.ts +6 -0
  57. package/lib/esm/tests/api_tests/beluga_manual_sync.test.d.ts.map +1 -0
  58. package/lib/esm/tests/api_tests/beluga_manual_sync.test.js +252 -0
  59. package/lib/esm/tests/api_tests/beluga_manual_sync.test.js.map +1 -0
  60. package/lib/esm/tests/api_tests/field_redaction.test.d.ts.map +1 -1
  61. package/lib/esm/tests/api_tests/field_redaction.test.js +112 -99
  62. package/lib/esm/tests/api_tests/field_redaction.test.js.map +1 -1
  63. package/lib/esm/tests/api_tests/gcal_sync_retry.test.d.ts +43 -0
  64. package/lib/esm/tests/api_tests/gcal_sync_retry.test.d.ts.map +1 -0
  65. package/lib/esm/tests/api_tests/gcal_sync_retry.test.js +164 -0
  66. package/lib/esm/tests/api_tests/gcal_sync_retry.test.js.map +1 -0
  67. package/lib/esm/tests/api_tests/journey_error_branching.test.js +7 -2
  68. package/lib/esm/tests/api_tests/journey_error_branching.test.js.map +1 -1
  69. package/lib/esm/tests/api_tests/resource_access_tags.test.d.ts +22 -0
  70. package/lib/esm/tests/api_tests/resource_access_tags.test.d.ts.map +1 -0
  71. package/lib/esm/tests/api_tests/resource_access_tags.test.js +484 -0
  72. package/lib/esm/tests/api_tests/resource_access_tags.test.js.map +1 -0
  73. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts +23 -0
  74. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -0
  75. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +321 -0
  76. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -0
  77. package/lib/esm/tests/api_tests/time_tracks.test.d.ts +8 -0
  78. package/lib/esm/tests/api_tests/time_tracks.test.d.ts.map +1 -1
  79. package/lib/esm/tests/api_tests/time_tracks.test.js +635 -0
  80. package/lib/esm/tests/api_tests/time_tracks.test.js.map +1 -1
  81. package/lib/esm/tests/api_tests/user_portal_settings.test.d.ts.map +1 -1
  82. package/lib/esm/tests/api_tests/user_portal_settings.test.js +104 -28
  83. package/lib/esm/tests/api_tests/user_portal_settings.test.js.map +1 -1
  84. package/lib/esm/tests/tests.d.ts.map +1 -1
  85. package/lib/esm/tests/tests.js +879 -201
  86. package/lib/esm/tests/tests.js.map +1 -1
  87. package/lib/tsconfig.tsbuildinfo +1 -1
  88. package/package.json +10 -10
  89. package/src/tests/api_tests/beluga_manual_sync.test.ts +159 -0
  90. package/src/tests/api_tests/field_redaction.test.ts +13 -0
  91. package/src/tests/api_tests/gcal_sync_retry.test.ts +104 -0
  92. package/src/tests/api_tests/journey_error_branching.test.ts +5 -1
  93. package/src/tests/api_tests/resource_access_tags.test.ts +303 -0
  94. package/src/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.ts +214 -0
  95. package/src/tests/api_tests/time_tracks.test.ts +420 -0
  96. package/src/tests/api_tests/user_portal_settings.test.ts +71 -1
  97. package/src/tests/tests.ts +520 -11
  98. package/test_generated.pdf +0 -0
@@ -0,0 +1,214 @@
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 { setup_tests } from "../../setup"
9
+
10
+ const host = process.env.API_URL || 'http://localhost:8080' as const
11
+ const businessId = '60398b1131a295e64f084ff6'
12
+
13
+ const ENDUSER_PASSWORD = 'F0106TestPassword!123'
14
+
15
+ // Every endusers field marked enduserUpdatesDisabled in schema.ts, with a
16
+ // validator-passing sample value so the only rejection in play is the
17
+ // write-restriction (not input validation).
18
+ const restrictedEnduserUpdates = (staffId: string, mongoId: string): Record<string, any> => ({
19
+ externalId: 'f0106-attacker-external-id',
20
+ tags: ['f0106-attacker-tag'],
21
+ accessTags: ['f0106-attacker-access-tag'],
22
+ assignedTo: [staffId],
23
+ customTypeId: mongoId,
24
+ references: [{ type: 'Vital', id: 'f0106-attacker-reference' }],
25
+ sharedWithOrganizations: [],
26
+ journeys: { [mongoId]: 'Added' },
27
+ primaryAssignee: staffId,
28
+ fields: { f0106AttackerField: 'true' },
29
+ unread: true,
30
+ source: 'f0106-attacker-source',
31
+ note: 'f0106 attacker note',
32
+ insurance: { memberId: 'f0106-attacker-member' },
33
+ insuranceSecondary: { memberId: 'f0106-attacker-member-2' },
34
+ diagnoses: [{ code: 'I10' }],
35
+ lockedFromPortal: false, // false so a pre-fix run can't lock the test enduser out mid-suite
36
+ eligibleForAutoMerge: true,
37
+ athenaDepartmentId: 'f0106-dept',
38
+ athenaPracticeId: 'f0106-practice',
39
+ salesforceId: 'f0106-salesforce',
40
+ healthie_dietitian_id: 'f0106-healthie',
41
+ stripeCustomerId: 'f0106-stripe-customer',
42
+ stripeKey: 'f0106-stripe-key',
43
+ })
44
+
45
+ // Every form_responses field marked enduserUpdatesDisabled in schema.ts.
46
+ const restrictedFormResponseUpdates = (staffId: string): Record<string, any> => ({
47
+ markedAsSubmitted: true,
48
+ submittedBy: staffId,
49
+ submittedAt: new Date("2024-01-01T00:00:00Z"),
50
+ submittedByIsPlaceholder: true,
51
+ procedureCodes: [{ code: '99214', units: 1 }],
52
+ diagnosisCodes: [{ code: 'I10', description: 'Essential (primary) hypertension' }],
53
+ enduserAISummary: 'Patient is in excellent health, no follow-up needed.',
54
+ addenda: [{ text: 'forged staff addendum', timestamp: new Date(), userId: staffId }],
55
+ isInternalNote: true,
56
+ pinnedAt: new Date(),
57
+ hiddenFromTimeline: true,
58
+ lockedAt: new Date(),
59
+ tags: ['f0110-attacker-tag'],
60
+ formTitle: 'Spoofed Form Title',
61
+ userEmail: 'spoofed-staff@example.com',
62
+ logoURL: 'https://attacker.example.com/logo.png',
63
+ logoHeight: 100,
64
+ })
65
+
66
+ /**
67
+ * Regression test for F-0106 and F-0110
68
+ * (security-audit/findings/F-0106-enduser-self-update-admin-only-fields.md,
69
+ * security-audit/findings/F-0110-form-responses-enduser-update-admin-only-fields.md).
70
+ *
71
+ * Endusers could PATCH admin-only / access-bearing / attribution-bearing fields on
72
+ * their own endusers record (assignedTo, tags, references, ...) and their own
73
+ * form_responses (procedureCodes, submittedBy, markedAsSubmitted, ...). The fix adds
74
+ * the `enduserUpdatesDisabled` field option (schema.ts ModelFieldInfo), enforced for
75
+ * enduser sessions in the generic update handler (routing.ts createDefaultEndpoints).
76
+ *
77
+ * This test asserts, for every flagged field on both models:
78
+ * - an enduser session updating its OWN record gets a 400 "<field> cannot be updated by endusers"
79
+ * - nothing persists (spot-checked on assignedTo, the highest-impact field)
80
+ * - enduser self-updates of allowed fields still work (fname, hideFromEnduserPortal)
81
+ * - staff sessions can still update the restricted fields
82
+ */
83
+ export const enduser_write_restrictions_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
84
+ log_header("F-0106/F-0110: enduser write restrictions (enduserUpdatesDisabled)")
85
+
86
+ let enduserId: string | undefined
87
+ let formId: string | undefined
88
+ let formResponseId: string | undefined
89
+
90
+ try {
91
+ // Setup: throwaway enduser with portal credentials, plus a form_response they own
92
+ const enduserEmail = `f0106-enduser-${Date.now()}@example.com`
93
+ const enduser = await sdk.api.endusers.createOne({ email: enduserEmail, fname: 'F0106', lname: 'Restricted' })
94
+ enduserId = enduser.id
95
+ await sdk.api.endusers.set_password({ id: enduser.id, password: ENDUSER_PASSWORD })
96
+
97
+ const enduserSDK = new EnduserSession({ host, businessId })
98
+ await enduserSDK.authenticate(enduserEmail, ENDUSER_PASSWORD)
99
+
100
+ const form = await sdk.api.forms.createOne({ title: 'F-0106/F-0110 Write Restriction Test Form' })
101
+ formId = form.id
102
+ const { response: formResponse } = await sdk.api.form_responses.prepare_form_response({
103
+ formId: form.id,
104
+ enduserId: enduser.id,
105
+ })
106
+ formResponseId = formResponse.id
107
+
108
+ // ---- F-0106: enduser self-update of restricted endusers fields must 400 ----
109
+ for (const [field, value] of Object.entries(restrictedEnduserUpdates(sdk.userInfo.id, enduser.id))) {
110
+ await async_test(
111
+ `F-0106: enduser cannot self-update endusers.${field}`,
112
+ () => enduserSDK.api.endusers.updateOne(enduser.id, { [field]: value } as any),
113
+ { shouldError: true, onError: e => e.message === `${field} cannot be updated by endusers` },
114
+ )
115
+ }
116
+
117
+ // F-0106 Variant A persistence check: the rejected assignedTo write must not
118
+ // have granted the staff read-filter match.
119
+ await async_test(
120
+ 'F-0106: rejected assignedTo write did not persist',
121
+ () => sdk.api.endusers.getOne(enduser.id),
122
+ { onResult: e => !e.assignedTo?.length && !e.tags?.length && e.externalId === undefined },
123
+ )
124
+
125
+ // ---- F-0110: enduser self-update of restricted form_responses fields must 400 ----
126
+ for (const [field, value] of Object.entries(restrictedFormResponseUpdates(sdk.userInfo.id))) {
127
+ await async_test(
128
+ `F-0110: enduser cannot self-update form_responses.${field}`,
129
+ () => enduserSDK.api.form_responses.updateOne(formResponse.id, { [field]: value } as any),
130
+ { shouldError: true, onError: e => e.message === `${field} cannot be updated by endusers` },
131
+ )
132
+ }
133
+
134
+ await async_test(
135
+ 'F-0110: rejected writes did not persist (markedAsSubmitted, procedureCodes, submittedBy)',
136
+ () => sdk.api.form_responses.getOne(formResponse.id),
137
+ { onResult: fr => !fr.markedAsSubmitted && !fr.procedureCodes?.length && fr.submittedBy === undefined },
138
+ )
139
+
140
+ // ---- Positive controls: intended enduser self-updates still work ----
141
+ await async_test(
142
+ 'enduser can still update own profile fields (fname/lname)',
143
+ () => enduserSDK.api.endusers.updateOne(enduser.id, { fname: 'StillAllowed', lname: 'Patient' }),
144
+ { onResult: e => e.fname === 'StillAllowed' && e.lname === 'Patient' },
145
+ )
146
+ await async_test(
147
+ 'enduser can still update own form_response (hideFromEnduserPortal — intended per schema)',
148
+ () => enduserSDK.api.form_responses.updateOne(formResponse.id, { hideFromEnduserPortal: true }),
149
+ { onResult: fr => fr.hideFromEnduserPortal === true },
150
+ )
151
+
152
+ // ---- Positive controls: staff sessions are unaffected by enduserUpdatesDisabled ----
153
+ await async_test(
154
+ 'staff can still update restricted endusers fields (tags, assignedTo, externalId, note, source)',
155
+ () => sdk.api.endusers.updateOne(enduser.id, {
156
+ tags: ['staff-set-tag'],
157
+ assignedTo: [sdk.userInfo.id],
158
+ externalId: 'staff-set-external-id',
159
+ note: 'staff-set note',
160
+ source: 'staff-set-source',
161
+ }),
162
+ { onResult: e => (
163
+ !!e.tags?.includes('staff-set-tag')
164
+ && !!e.assignedTo?.includes(sdk.userInfo.id)
165
+ && e.externalId === 'staff-set-external-id'
166
+ ) },
167
+ )
168
+ await async_test(
169
+ 'staff can still update restricted form_responses fields (procedureCodes, diagnosisCodes, tags)',
170
+ () => sdk.api.form_responses.updateOne(formResponse.id, {
171
+ procedureCodes: [{ code: 'G0019', units: 1 }],
172
+ diagnosisCodes: [{ code: 'Z71.3', description: 'Dietary counseling and surveillance' }],
173
+ tags: ['staff-set-tag'],
174
+ }),
175
+ { onResult: fr => (
176
+ fr.procedureCodes?.[0]?.code === 'G0019'
177
+ && fr.diagnosisCodes?.[0]?.code === 'Z71.3'
178
+ && !!fr.tags?.includes('staff-set-tag')
179
+ ) },
180
+ )
181
+ } finally {
182
+ if (formResponseId) {
183
+ try { await sdk.api.form_responses.deleteOne(formResponseId) } catch {}
184
+ }
185
+ if (formId) {
186
+ try { await sdk.api.forms.deleteOne(formId) } catch {}
187
+ }
188
+ if (enduserId) {
189
+ try { await sdk.api.endusers.deleteOne(enduserId) } catch {}
190
+ }
191
+ }
192
+ }
193
+
194
+ // Allow running this test file independently
195
+ if (require.main === module) {
196
+ console.log(`🌐 Using API URL: ${host}`)
197
+ const sdk = new Session({ host })
198
+ const sdkNonAdmin = new Session({ host })
199
+
200
+ const runTests = async () => {
201
+ await setup_tests(sdk, sdkNonAdmin)
202
+ await enduser_write_restrictions_tests({ sdk, sdkNonAdmin })
203
+ }
204
+
205
+ runTests()
206
+ .then(() => {
207
+ console.log("✅ F-0106/F-0110 enduser write-restriction test suite completed successfully")
208
+ process.exit(0)
209
+ })
210
+ .catch((error) => {
211
+ console.error("❌ F-0106/F-0110 enduser write-restriction test suite failed:", error)
212
+ process.exit(1)
213
+ })
214
+ }
@@ -747,6 +747,424 @@ export const time_tracks_edge_case_tests = async ({ sdk, sdkNonAdmin }: { sdk: S
747
747
  }
748
748
  }
749
749
 
750
+ // ============================================================
751
+ // Group F: Rejection Notification + Correct & Resubmit
752
+ // ============================================================
753
+ export const time_tracks_resubmit_tests = async ({ sdk, sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }) => {
754
+ log_header("Time Tracks - Rejection Notification + Resubmit Tests")
755
+
756
+ const trackIds: string[] = []
757
+ const nonAdminTrackIds: string[] = []
758
+ const notificationIds: string[] = []
759
+
760
+ // matches the webapp's Correct & Resubmit payload
761
+ const resubmit_payload = (track: { totalDurationInMS?: number }, byUserId: string, newDurationMS: number) => ({
762
+ correctedAt: new Date(),
763
+ correctedByUserId: byUserId,
764
+ correctionNote: "Corrected after rejection",
765
+ originalTotalDurationInMS: track.totalDurationInMS || 0,
766
+ totalDurationInMS: newDurationMS,
767
+ lockedAt: new Date(),
768
+ lockedByUserId: byUserId,
769
+ reviewedAt: '',
770
+ reviewedByUserId: '',
771
+ reviewNote: '',
772
+ })
773
+
774
+ try {
775
+ const now = new Date()
776
+ const oneHourAgo = new Date(now.getTime() - 3600000)
777
+ const twoHoursAgo = new Date(now.getTime() - 7200000)
778
+
779
+ const create_historical = (title: string) => sdk.api.time_tracks.createOne({
780
+ title,
781
+ isHistorical: true,
782
+ closedAt: now,
783
+ lockedAt: now,
784
+ lockedByUserId: sdk.userInfo.id,
785
+ totalDurationInMS: 3600000,
786
+ timestamps: [
787
+ { type: 'start', timestamp: twoHoursAgo },
788
+ { type: 'pause', timestamp: oneHourAgo },
789
+ ],
790
+ } as any)
791
+
792
+ // F1: Rejection creates a notification for the owner
793
+ log("F1: Rejection creates timeTrackRejected notification for owner...")
794
+ const track = await create_historical("Track for Rejection Notification")
795
+ trackIds.push(track.id)
796
+
797
+ const rejectionNote = "Duration looks wrong, please correct"
798
+ await sdk.api.time_tracks.updateOne(track.id, {
799
+ reviewedAt: new Date(),
800
+ reviewedByUserId: sdkNonAdmin.userInfo.id,
801
+ reviewApproved: false,
802
+ reviewNote: rejectionNote,
803
+ } as any)
804
+
805
+ await wait(undefined, 2000) // allow side-effect handler to run
806
+
807
+ const notifications = await sdk.api.user_notifications.getSome({
808
+ filter: { userId: sdk.userInfo.id, type: 'timeTrackRejected' }
809
+ })
810
+ const rejectionNotification = notifications.find(n =>
811
+ n.relatedRecords?.find(r => r.type === 'time_track' && r.id === track.id)
812
+ )
813
+ assert(!!rejectionNotification, "F1: timeTrackRejected notification should exist for owner")
814
+ assert(rejectionNotification!.message.includes(rejectionNote), "F1: notification message should include rejection note")
815
+ notificationIds.push(rejectionNotification!.id)
816
+ log("F1: Rejection notification created for owner")
817
+
818
+ // F2: Resubmit happy path
819
+ log("F2: Owner resubmits rejected entry...")
820
+ const resubmitted = await sdk.api.time_tracks.updateOne(track.id,
821
+ resubmit_payload(track, sdk.userInfo.id, 1800000) as any
822
+ )
823
+ assert(!resubmitted.reviewedAt, "F2: reviewedAt should be cleared after resubmit")
824
+
825
+ const refetched = await sdk.api.time_tracks.getOne(track.id)
826
+ assert(!refetched.reviewedAt, "F2: reviewedAt should be falsy on refetch")
827
+ assert(refetched.reviewHistory?.length === 1, `F2: reviewHistory should have 1 entry, got ${refetched.reviewHistory?.length}`)
828
+ assert(refetched.reviewHistory?.[0].reviewApproved === false, "F2: reviewHistory should preserve reviewApproved false")
829
+ assert(refetched.reviewHistory?.[0].reviewNote === rejectionNote, "F2: reviewHistory should preserve original reviewNote")
830
+ assert(refetched.reviewHistory?.[0].reviewedByUserId === sdkNonAdmin.userInfo.id, "F2: reviewHistory should preserve reviewedByUserId")
831
+ assert(!!refetched.reviewHistory?.[0].resubmittedAt, "F2: reviewHistory should record resubmittedAt")
832
+ assert(refetched.reviewHistory?.[0].resubmittedByUserId === sdk.userInfo.id, "F2: reviewHistory should record resubmittedByUserId")
833
+ log("F2: Resubmit succeeded with reviewHistory audit trail")
834
+
835
+ // F3: Resubmit without clearing reviewedAt - expect 400
836
+ log("F3: Resubmit without reviewedAt: '' (should fail)...")
837
+ const track3 = await create_historical("Track for F3")
838
+ trackIds.push(track3.id)
839
+ await sdk.api.time_tracks.updateOne(track3.id, {
840
+ reviewedAt: new Date(),
841
+ reviewedByUserId: sdkNonAdmin.userInfo.id,
842
+ reviewApproved: false,
843
+ } as any)
844
+
845
+ const { reviewedAt: _omitted, ...payloadWithoutClear } = resubmit_payload(track3, sdk.userInfo.id, 1800000)
846
+ await assert_throws(
847
+ () => sdk.api.time_tracks.updateOne(track3.id, payloadWithoutClear as any),
848
+ "F3: resubmit without clearing reviewedAt"
849
+ )
850
+ log("F3: Correctly rejected resubmit without reviewedAt: ''")
851
+
852
+ // F4: Resubmit with self-approval - expect 400
853
+ log("F4: Resubmit with reviewApproved: true (should fail)...")
854
+ await assert_throws(
855
+ () => sdk.api.time_tracks.updateOne(track3.id, {
856
+ ...resubmit_payload(track3, sdk.userInfo.id, 1800000),
857
+ reviewApproved: true,
858
+ } as any),
859
+ "F4: resubmit with self-approval"
860
+ )
861
+ log("F4: Correctly rejected self-approval during resubmit")
862
+
863
+ // F5: Correction payload on locked, non-rejected entry - expect 400
864
+ log("F5: Correction payload on locked non-rejected entry (should fail)...")
865
+ const track5 = await create_historical("Track for F5")
866
+ trackIds.push(track5.id)
867
+ await assert_throws(
868
+ () => sdk.api.time_tracks.updateOne(track5.id, resubmit_payload(track5, sdk.userInfo.id, 1800000) as any),
869
+ "F5: correction on locked non-rejected entry"
870
+ )
871
+ log("F5: Correctly rejected correction on locked non-rejected entry")
872
+
873
+ // F6: Non-owner resubmit attempt - expect 400
874
+ log("F6: Non-owner resubmit attempt (should fail)...")
875
+ const nonAdminTrack = await sdkNonAdmin.api.time_tracks.createOne({
876
+ title: "Non-Admin Track for F6",
877
+ isHistorical: true,
878
+ closedAt: now,
879
+ lockedAt: now,
880
+ lockedByUserId: sdkNonAdmin.userInfo.id,
881
+ totalDurationInMS: 3600000,
882
+ timestamps: [
883
+ { type: 'start', timestamp: twoHoursAgo },
884
+ { type: 'pause', timestamp: oneHourAgo },
885
+ ],
886
+ } as any)
887
+ nonAdminTrackIds.push(nonAdminTrack.id)
888
+
889
+ await sdk.api.time_tracks.updateOne(nonAdminTrack.id, {
890
+ reviewedAt: new Date(),
891
+ reviewedByUserId: sdk.userInfo.id,
892
+ reviewApproved: false,
893
+ reviewNote: "Rejected for F6",
894
+ } as any)
895
+
896
+ await assert_throws(
897
+ () => sdk.api.time_tracks.updateOne(nonAdminTrack.id, resubmit_payload(nonAdminTrack, sdk.userInfo.id, 1800000) as any),
898
+ "F6: non-owner resubmit"
899
+ )
900
+ log("F6: Correctly rejected non-owner resubmit")
901
+
902
+ // F7: Second rejection + resubmit appends to reviewHistory
903
+ log("F7: Reject again after resubmit, resubmit again...")
904
+ await sdk.api.time_tracks.updateOne(track.id, {
905
+ reviewedAt: new Date(),
906
+ reviewedByUserId: sdkNonAdmin.userInfo.id,
907
+ reviewApproved: false,
908
+ reviewNote: "Still not right",
909
+ } as any)
910
+
911
+ const rejectedAgain = await sdk.api.time_tracks.getOne(track.id)
912
+ await sdk.api.time_tracks.updateOne(track.id,
913
+ resubmit_payload(rejectedAgain, sdk.userInfo.id, 2700000) as any
914
+ )
915
+
916
+ const afterSecondResubmit = await sdk.api.time_tracks.getOne(track.id)
917
+ assert(afterSecondResubmit.reviewHistory?.length === 2,
918
+ `F7: reviewHistory should have 2 entries, got ${afterSecondResubmit.reviewHistory?.length}`)
919
+ assert(afterSecondResubmit.reviewHistory?.[1].reviewNote === "Still not right",
920
+ "F7: second reviewHistory entry should preserve second rejection note")
921
+ log("F7: Second rejection/resubmit cycle appended to reviewHistory")
922
+
923
+ // F8: Status filters treat reviewedAt: '' as Pending
924
+ log("F8: Status mdbFilters handle reviewedAt: ''...")
925
+ const pendingMatches = await sdk.api.time_tracks.getSome({
926
+ mdbFilter: { $or: [{ reviewedAt: { $exists: false } }, { reviewedAt: '' }] }
927
+ })
928
+ assert(!!pendingMatches.find(t => t.id === track.id), "F8: Pending filter should match resubmitted track")
929
+
930
+ const rejectedMatches = await sdk.api.time_tracks.getSome({
931
+ mdbFilter: { reviewedAt: { $exists: true, $ne: '' }, reviewApproved: false }
932
+ })
933
+ assert(!rejectedMatches.find(t => t.id === track.id), "F8: Rejected filter should exclude resubmitted track")
934
+ log("F8: Status filters behave correctly with cleared reviewedAt")
935
+
936
+ log("All rejection notification + resubmit tests passed!")
937
+
938
+ } finally {
939
+ for (const id of trackIds) {
940
+ try { await sdk.api.time_tracks.deleteOne(id) } catch (e) {}
941
+ }
942
+ for (const id of nonAdminTrackIds) {
943
+ try { await sdkNonAdmin.api.time_tracks.deleteOne(id) } catch (e) {}
944
+ }
945
+ for (const id of notificationIds) {
946
+ try { await sdk.api.user_notifications.deleteOne(id) } catch (e) {}
947
+ }
948
+ // clean up any remaining timeTrackRejected notifications created by these tests
949
+ try {
950
+ const remaining = await sdk.api.user_notifications.getSome({
951
+ filter: { userId: sdk.userInfo.id, type: 'timeTrackRejected' }
952
+ })
953
+ for (const n of remaining) {
954
+ try { await sdk.api.user_notifications.deleteOne(n.id) } catch (e) {}
955
+ }
956
+ } catch (e) {}
957
+ }
958
+ }
959
+
960
+ // ============================================================
961
+ // Group F: Appointment Duration from Tracked Time
962
+ // ============================================================
963
+ export const time_tracks_appointment_duration_tests = async ({ sdk, sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }) => {
964
+ log_header("Time Tracks - Appointment Duration from Tracked Time Tests")
965
+
966
+ const businessId = sdk.userInfo.businessId
967
+ const trackIds: string[] = []
968
+ const formResponseIds: string[] = []
969
+ const eventIds: string[] = []
970
+ let formId: string | undefined
971
+ let enduserId: string | undefined
972
+
973
+ try {
974
+ // Enable time tracking + appointment duration setting
975
+ log("F0: Enabling timeTracking.setAppointmentDurationOnTimeTrackClose...")
976
+ await sdk.api.organizations.updateOne(businessId, {
977
+ settings: { timeTracking: { enabled: true, setAppointmentDurationOnTimeTrackClose: true } }
978
+ })
979
+
980
+ // Shared records
981
+ const enduser = await sdk.api.endusers.createOne({ email: `tt-duration-${Date.now()}@test.com` })
982
+ enduserId = enduser.id
983
+ const form = await sdk.api.forms.createOne({ title: 'Appointment Duration Form' })
984
+ formId = form.id
985
+
986
+ const event = await sdk.api.calendar_events.createOne({
987
+ title: 'Appointment for Tracked Time',
988
+ durationInMinutes: 30,
989
+ startTimeInMS: Date.now(),
990
+ attendees: [{ type: 'enduser', id: enduser.id }],
991
+ })
992
+ eventIds.push(event.id)
993
+
994
+ const response1 = await sdk.api.form_responses.createOne({
995
+ formId: form.id,
996
+ enduserId: enduser.id,
997
+ formTitle: form.title,
998
+ calendarEventId: event.id,
999
+ })
1000
+ formResponseIds.push(response1.id)
1001
+
1002
+ // Test F1: Close a linked time track -> actualDuration set from tracked time
1003
+ log("F1: Closing linked time track sets appointment actualDuration...")
1004
+ const track1 = await sdk.api.time_tracks.createOne({
1005
+ title: "Tracked Form Completion",
1006
+ activity: { type: 'form_response', id: response1.id },
1007
+ } as any)
1008
+ trackIds.push(track1.id)
1009
+
1010
+ await wait(undefined, 2000)
1011
+ await sdk.api.time_tracks.updateOne(track1.id, {
1012
+ closedAt: new Date(),
1013
+ totalDurationInMS: 1800000, // 30 minutes
1014
+ } as any)
1015
+ await wait(undefined, 1500) // allow side effects to run
1016
+
1017
+ const eventAfterClose = await sdk.api.calendar_events.getOne(event.id)
1018
+ assert(eventAfterClose.actualDuration === 30,
1019
+ `F1: actualDuration should be 30, got ${eventAfterClose.actualDuration}`)
1020
+ log("F1: actualDuration set from closed time track")
1021
+
1022
+ // Test F2: Second closed track on same appointment -> durations are summed
1023
+ log("F2: Second closed track sums durations...")
1024
+ const response2 = await sdk.api.form_responses.createOne({
1025
+ formId: form.id,
1026
+ enduserId: enduser.id,
1027
+ formTitle: form.title,
1028
+ calendarEventId: event.id,
1029
+ })
1030
+ formResponseIds.push(response2.id)
1031
+
1032
+ const track2 = await sdk.api.time_tracks.createOne({
1033
+ title: "Second Tracked Form Completion",
1034
+ activity: { type: 'form_response', id: response2.id },
1035
+ } as any)
1036
+ trackIds.push(track2.id)
1037
+
1038
+ await sdk.api.time_tracks.updateOne(track2.id, {
1039
+ closedAt: new Date(),
1040
+ totalDurationInMS: 600000, // 10 minutes
1041
+ } as any)
1042
+ await wait(undefined, 1500)
1043
+
1044
+ const eventAfterSecond = await sdk.api.calendar_events.getOne(event.id)
1045
+ assert(eventAfterSecond.actualDuration === 40,
1046
+ `F2: actualDuration should be 40 (30 + 10), got ${eventAfterSecond.actualDuration}`)
1047
+ log("F2: actualDuration correctly summed across tracks")
1048
+
1049
+ // Test F3: Linking activity after close triggers recompute
1050
+ log("F3: Post-close activity link triggers recompute...")
1051
+ const response3 = await sdk.api.form_responses.createOne({
1052
+ formId: form.id,
1053
+ enduserId: enduser.id,
1054
+ formTitle: form.title,
1055
+ calendarEventId: event.id,
1056
+ })
1057
+ formResponseIds.push(response3.id)
1058
+
1059
+ const track3 = await sdk.api.time_tracks.createOne({
1060
+ title: "Track Linked After Close",
1061
+ } as any)
1062
+ trackIds.push(track3.id)
1063
+
1064
+ await sdk.api.time_tracks.updateOne(track3.id, {
1065
+ closedAt: new Date(),
1066
+ totalDurationInMS: 300000, // 5 minutes
1067
+ } as any)
1068
+ await wait(undefined, 1500)
1069
+
1070
+ // no activity yet -> actualDuration unchanged
1071
+ const eventBeforeLink = await sdk.api.calendar_events.getOne(event.id)
1072
+ assert(eventBeforeLink.actualDuration === 40,
1073
+ `F3: actualDuration should still be 40 before activity link, got ${eventBeforeLink.actualDuration}`)
1074
+
1075
+ await sdk.api.time_tracks.updateOne(track3.id, {
1076
+ activity: { type: 'form_response', id: response3.id },
1077
+ } as any)
1078
+ await wait(undefined, 1500)
1079
+
1080
+ const eventAfterLink = await sdk.api.calendar_events.getOne(event.id)
1081
+ assert(eventAfterLink.actualDuration === 45,
1082
+ `F3: actualDuration should be 45 (30 + 10 + 5) after post-close link, got ${eventAfterLink.actualDuration}`)
1083
+ log("F3: Post-close activity link recomputed actualDuration")
1084
+
1085
+ // Test F4: Correction recomputes actualDuration
1086
+ log("F4: Correction recomputes actualDuration...")
1087
+ await sdk.api.time_tracks.updateOne(track2.id, {
1088
+ correctedAt: new Date(),
1089
+ correctedByUserId: sdk.userInfo.id,
1090
+ correctionNote: "Adjusting tracked time",
1091
+ originalTotalDurationInMS: 600000,
1092
+ totalDurationInMS: 1200000, // corrected to 20 minutes
1093
+ lockedAt: new Date(),
1094
+ lockedByUserId: sdk.userInfo.id,
1095
+ } as any)
1096
+ await wait(undefined, 1500)
1097
+
1098
+ const eventAfterCorrection = await sdk.api.calendar_events.getOne(event.id)
1099
+ assert(eventAfterCorrection.actualDuration === 55,
1100
+ `F4: actualDuration should be 55 (30 + 20 + 5) after correction, got ${eventAfterCorrection.actualDuration}`)
1101
+ log("F4: Correction recomputed actualDuration")
1102
+
1103
+ // Test F5: Setting disabled -> actualDuration not set
1104
+ log("F5: Setting disabled leaves actualDuration unset...")
1105
+ await sdk.api.organizations.updateOne(businessId, {
1106
+ settings: { timeTracking: { enabled: true, setAppointmentDurationOnTimeTrackClose: false } }
1107
+ })
1108
+
1109
+ const event2 = await sdk.api.calendar_events.createOne({
1110
+ title: 'Appointment Without Duration Sync',
1111
+ durationInMinutes: 30,
1112
+ startTimeInMS: Date.now(),
1113
+ attendees: [{ type: 'enduser', id: enduser.id }],
1114
+ })
1115
+ eventIds.push(event2.id)
1116
+
1117
+ const response4 = await sdk.api.form_responses.createOne({
1118
+ formId: form.id,
1119
+ enduserId: enduser.id,
1120
+ formTitle: form.title,
1121
+ calendarEventId: event2.id,
1122
+ })
1123
+ formResponseIds.push(response4.id)
1124
+
1125
+ const track4 = await sdk.api.time_tracks.createOne({
1126
+ title: "Track With Setting Disabled",
1127
+ activity: { type: 'form_response', id: response4.id },
1128
+ } as any)
1129
+ trackIds.push(track4.id)
1130
+
1131
+ await sdk.api.time_tracks.updateOne(track4.id, {
1132
+ closedAt: new Date(),
1133
+ totalDurationInMS: 1800000,
1134
+ } as any)
1135
+ await wait(undefined, 1500)
1136
+
1137
+ const event2AfterClose = await sdk.api.calendar_events.getOne(event2.id)
1138
+ assert(!event2AfterClose.actualDuration,
1139
+ `F5: actualDuration should stay unset when setting is disabled, got ${event2AfterClose.actualDuration}`)
1140
+ log("F5: actualDuration correctly unset with setting disabled")
1141
+
1142
+ log("All appointment duration tests passed!")
1143
+
1144
+ } finally {
1145
+ for (const id of trackIds) {
1146
+ try { await sdk.api.time_tracks.deleteOne(id) } catch (e) {}
1147
+ }
1148
+ for (const id of formResponseIds) {
1149
+ try { await sdk.api.form_responses.deleteOne(id) } catch (e) {}
1150
+ }
1151
+ for (const id of eventIds) {
1152
+ try { await sdk.api.calendar_events.deleteOne(id) } catch (e) {}
1153
+ }
1154
+ if (formId) {
1155
+ try { await sdk.api.forms.deleteOne(formId) } catch (e) {}
1156
+ }
1157
+ if (enduserId) {
1158
+ try { await sdk.api.endusers.deleteOne(enduserId) } catch (e) {}
1159
+ }
1160
+ try {
1161
+ await sdk.api.organizations.updateOne(businessId, {
1162
+ settings: { timeTracking: { setAppointmentDurationOnTimeTrackClose: false } }
1163
+ })
1164
+ } catch (e) {}
1165
+ }
1166
+ }
1167
+
750
1168
  // Allow running this test file independently
751
1169
  if (require.main === module) {
752
1170
  const sdk = new Session({ host })
@@ -764,6 +1182,8 @@ if (require.main === module) {
764
1182
  await time_tracks_review_tests({ sdk, sdkNonAdmin })
765
1183
  await time_tracks_lock_tests({ sdk, sdkNonAdmin })
766
1184
  await time_tracks_edge_case_tests({ sdk, sdkNonAdmin })
1185
+ await time_tracks_resubmit_tests({ sdk, sdkNonAdmin })
1186
+ await time_tracks_appointment_duration_tests({ sdk, sdkNonAdmin })
767
1187
  }
768
1188
 
769
1189
  runTests()