musora-content-services 2.152.1 → 2.154.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 (90) hide show
  1. package/.github/workflows/automated-testing.yml +24 -0
  2. package/CHANGELOG.md +29 -0
  3. package/codecov.yml +5 -0
  4. package/jest.config.js +39 -8
  5. package/package.json +5 -1
  6. package/src/contentTypeConfig.js +13 -14
  7. package/src/index.d.ts +8 -2
  8. package/src/index.js +8 -2
  9. package/src/infrastructure/http/interfaces/RequestOptions.ts +1 -1
  10. package/src/services/awards/internal/award-definitions.js +3 -3
  11. package/src/services/content-org/guided-courses.ts +2 -2
  12. package/src/services/contentProgress.js +35 -54
  13. package/src/services/dateUtils.js +9 -1
  14. package/src/services/forums/posts.ts +2 -2
  15. package/src/services/multi-user-accounts/multi-user-accounts.ts +43 -7
  16. package/src/services/reporting/reporting.ts +3 -4
  17. package/src/services/sanity.js +26 -58
  18. package/src/services/sync/adapters/lokijs.ts +7 -4
  19. package/src/services/sync/fetch.ts +2 -14
  20. package/src/services/sync/repositories/base.ts +4 -0
  21. package/src/services/sync/repositories/content-progress.ts +3 -3
  22. package/src/services/sync/store/index.ts +6 -1
  23. package/src/services/sync/strategies/base.ts +1 -1
  24. package/src/services/sync/telemetry/index.ts +1 -1
  25. package/src/services/urlBuilder.ts +1 -0
  26. package/src/services/user/streakCalculator.ts +1 -1
  27. package/test/SKIPPED_TESTS.md +151 -0
  28. package/test/initializeTests.js +2 -3
  29. package/test/{content.test.js → integration/content.test.js} +7 -23
  30. package/test/integration/contentProgress.test.js +73 -0
  31. package/test/{forum.test.js → integration/forum.test.js} +2 -4
  32. package/test/{sanityQueryService.test.js → integration/sanityQueryService.test.js} +143 -291
  33. package/test/{user → integration/user}/permissions.test.js +5 -4
  34. package/test/live/README.md +29 -0
  35. package/test/setupConsole.js +6 -0
  36. package/test/setupNetworkGuard.js +3 -0
  37. package/test/{HttpClient.test.js → unit/HttpClient.test.js} +5 -5
  38. package/test/{awards → unit/awards}/award-alacarte-observer.test.js +13 -12
  39. package/test/{awards → unit/awards}/award-auto-refresh.test.js +4 -3
  40. package/test/{awards → unit/awards}/award-calculations.test.js +3 -2
  41. package/test/{awards → unit/awards}/award-certificate-display.test.js +12 -11
  42. package/test/{awards → unit/awards}/award-collection-edge-cases.test.js +12 -11
  43. package/test/{awards → unit/awards}/award-collection-filtering.test.js +12 -11
  44. package/test/{awards → unit/awards}/award-completion-flow.test.js +15 -14
  45. package/test/{awards → unit/awards}/award-exclusion-handling.test.js +20 -19
  46. package/test/{awards → unit/awards}/award-multi-lesson.test.js +14 -13
  47. package/test/{awards → unit/awards}/award-observer-integration.test.js +14 -13
  48. package/test/{awards → unit/awards}/award-query-messages.test.js +30 -21
  49. package/test/{awards → unit/awards}/award-user-collection.test.js +11 -8
  50. package/test/{awards → unit/awards}/duplicate-prevention.test.js +12 -11
  51. package/test/unit/awards/helpers/index.js +3 -0
  52. package/test/{awards → unit/awards}/helpers/mock-setup.js +1 -1
  53. package/test/{awards → unit/awards}/helpers/progress-emitter.js +2 -2
  54. package/test/{awards → unit/awards}/message-generator.test.js +1 -1
  55. package/test/unit/contentLikes.test.js +62 -0
  56. package/test/unit/contentProgress.test.js +75 -0
  57. package/test/{dataContext.test.js → unit/dataContext.test.js} +2 -2
  58. package/test/unit/dateUtils.test.js +188 -0
  59. package/test/{imageSRCBuilder.test.js → unit/imageSRCBuilder.test.js} +2 -2
  60. package/test/{imageSRCVerify.test.js → unit/imageSRCVerify.test.js} +1 -1
  61. package/test/{lib → unit/lib}/filter.test.ts +10 -4
  62. package/test/{lib → unit/lib}/lastUpdated.test.js +6 -6
  63. package/test/{lib → unit/lib}/query.test.ts +1 -1
  64. package/test/{notifications.test.js → unit/notifications.test.js} +51 -39
  65. package/test/{progressRows.test.js → unit/progressRows.test.js} +57 -35
  66. package/test/unit/sanityQueryService.test.js +180 -0
  67. package/test/{streakMessage.test.js → unit/streakMessage.test.js} +18 -27
  68. package/test/unit/sync/adapters/idb-errors.test.ts +144 -0
  69. package/test/unit/sync/adapters/sqlite-errors.test.ts +173 -0
  70. package/test/unit/sync/helpers/TestModel.ts +44 -0
  71. package/test/unit/sync/helpers/index.ts +172 -0
  72. package/test/unit/sync/repositories/content-likes.test.ts +99 -0
  73. package/test/unit/sync/repositories/practices.test.ts +179 -0
  74. package/test/unit/sync/repositories/progress.test.ts +245 -0
  75. package/test/unit/sync/store/store-idb.test.ts +180 -0
  76. package/test/unit/sync/store/store.test.ts +274 -0
  77. package/test/unit/userActivity.test.js +99 -0
  78. package/tsconfig.json +15 -0
  79. package/test/awards/helpers/index.js +0 -3
  80. package/test/contentLikes.test.js +0 -95
  81. package/test/contentProgress.test.js +0 -279
  82. package/test/learningPaths.test.js +0 -70
  83. package/test/live/contentProgressLive.test.js +0 -110
  84. package/test/live/railcontentLive.test.js +0 -7
  85. package/test/sync/adapter.ts +0 -9
  86. package/test/sync/initialize-sync-manager.js +0 -88
  87. package/test/sync/models/award-database-integration.test.js +0 -519
  88. package/test/userActivity.test.js +0 -118
  89. /package/test/{awards → unit/awards}/helpers/completion-mock.js +0 -0
  90. /package/test/{lib → unit/lib}/__snapshots__/filter.test.ts.snap +0 -0
@@ -14,11 +14,16 @@ export interface User {
14
14
  }
15
15
 
16
16
  export interface InviteResponse {
17
- email: string
18
17
  id: number
19
18
  created_at: string
20
19
  expires_at: string
21
- existing_user_details: User
20
+ can_be_accepted: boolean
21
+ is_account_valid: boolean
22
+ is_invite_active: boolean
23
+ can_user_join: boolean
24
+ // These fields leak user information and are excluded entirely for the public endpoint
25
+ existing_user_details?: User
26
+ email?: string
22
27
  }
23
28
 
24
29
  export interface UsersMultiAccountResponse {
@@ -27,7 +32,7 @@ export interface UsersMultiAccountResponse {
27
32
  last_cancelled_multi_user_account: MultiUserAccountResponse
28
33
  is_active_primary: boolean
29
34
  is_active_sub: boolean
30
- active_invite: InviteResponse
35
+ active_invites: InviteResponse[]
31
36
  }
32
37
 
33
38
  export interface MultiUserAccountResponse {
@@ -35,13 +40,15 @@ export interface MultiUserAccountResponse {
35
40
  product_name: string
36
41
  is_active: boolean
37
42
  primary_user: User
38
- active_invited_emails: string[]
39
- available_seats: number
40
- available_invites: number
41
43
  total_seats: number
42
- active_subs: User[]
43
44
  end_time: string
44
45
  is_primary_account_holder: boolean
46
+ // The following fields are not included for public or subaccount users
47
+ active_invited_emails?: InviteResponse[]
48
+ available_seats?: number
49
+ available_invites?: InviteResponse[]
50
+ active_subs?: User[]
51
+ show_welcome?: boolean
45
52
  }
46
53
 
47
54
  export interface CreateAccountParams {
@@ -53,6 +60,10 @@ export interface CreateInvitesParams {
53
60
  emails: string[]
54
61
  }
55
62
 
63
+ export interface UpdateMultiUserAccountParams {
64
+ show_welcome: boolean
65
+ }
66
+
56
67
 
57
68
  /**
58
69
  * Creates a new multi-user account with optional invites and seat count.
@@ -78,6 +89,19 @@ export async function fetchUsersMultiAccountDetails(userId: number): Promise<Use
78
89
  return httpClient.get<UsersMultiAccountResponse>(`${baseUrl}/${userId}/details`)
79
90
  }
80
91
 
92
+
93
+ /**
94
+ * Fetch invite details
95
+ *
96
+ * @param {number} inviteId - The ID of the invite to check
97
+ * @returns {Promise<InviteResponse>} - A promise that resolves to the invite details.
98
+ * @throws {HttpError} - If the HTTP request fails.
99
+ */
100
+ export async function fetchInvite(inviteId: number): Promise<InviteResponse> {
101
+ const httpClient = new HttpClient(globalConfig.baseUrl)
102
+ return httpClient.get<InviteResponse>(`${baseUrl}/invites/${inviteId}`)
103
+ }
104
+
81
105
  /**
82
106
  * Creates invitations for an existing multi-user account.
83
107
  *
@@ -125,3 +149,15 @@ export async function rescindInvite(inviteId: number): Promise<void> {
125
149
  export async function removeUserFromActiveMultiUserAccount(userId: number): Promise<MultiUserAccountResponse|void> {
126
150
  return DELETE(`${globalConfig.baseUrl}${baseUrl}/${userId}/remove`, {})
127
151
  }
152
+
153
+ /**
154
+ * Updates specified fields on a multi-user account. Authorized user must be the primary account owner
155
+ *
156
+ * @param {UpdateMultiUserAccountParams} params - The parameters for updating the account.
157
+ * @returns {Promise<MultiUserAccountResponse>} - Updated MultiUserAccountResponse if account owner
158
+ * @throws {HttpError} - If the request fails.
159
+ */
160
+ export async function updateMultiUserAccount(params: UpdateMultiUserAccountParams): Promise<MultiUserAccountResponse> {
161
+ const httpClient = new HttpClient(globalConfig.baseUrl)
162
+ return httpClient.patch(`${globalConfig.baseUrl}${baseUrl}/update`, params)
163
+ }
@@ -11,9 +11,8 @@ import { HttpClient } from '../../infrastructure/http/HttpClient'
11
11
  import { globalConfig } from '../config.js'
12
12
  import { ReportResponse, ReportableType, IssueTypeMap, ReportIssueOption } from './types'
13
13
  import { Brands } from '../../lib/brands'
14
- import { generateContentUrl, generatePlaylistUrl, generateForumPostUrl, generateCommentUrl } from '../urlBuilder.ts'
15
- import {fetchByRailContentId} from "../../index";
16
- import {fetchByRailContentIds} from "../sanity";
14
+ import { generateContentUrl, generatePlaylistUrl, generateForumPostUrl, generateCommentUrl } from '../urlBuilder'
15
+ import {fetchByRailContentId, fetchByRailContentIds} from "../sanity";
17
16
  import {addContextToContent} from "../contentAggregator";
18
17
 
19
18
  /**
@@ -122,7 +121,7 @@ export async function report<T extends ReportableType>(
122
121
  id: params.id
123
122
  })
124
123
  } else if (params.type === 'forum_post') {
125
- const { fetchPost } = await import('../forums/posts.ts')
124
+ const { fetchPost } = await import('../forums/posts')
126
125
  const post = await fetchPost(params.id, params.brand)
127
126
 
128
127
  if (post?.thread) {
@@ -33,8 +33,9 @@ import {
33
33
  SONG_TYPES_WITH_CHILDREN,
34
34
  liveFields,
35
35
  postProcessBadge,
36
- parentField,
37
- grandParentField, parentRecentTypes,
36
+ parentRecentTypes,
37
+ parentReferenceField,
38
+ grandParentReferenceField,
38
39
  } from '../contentTypeConfig.js'
39
40
  import { fetchSimilarItems } from './recommendations.js'
40
41
  import { getSongType, processMetadata, ALWAYS_VISIBLE_TABS, CONTENT_STATUSES } from '../contentMetaData.js'
@@ -952,7 +953,7 @@ export async function fetchLessonContent(railContentId, { addParent = false } =
952
953
  }
953
954
 
954
955
  const parentQuery = addParent
955
- ? `"parent_content_data": *[railcontent_id in [...(^.parent_content_data[].id)]]{
956
+ ? `"parent_content_data": parent_content_reference[]->{
956
957
  "id": railcontent_id,
957
958
  title,
958
959
  slug,
@@ -960,11 +961,12 @@ export async function fetchLessonContent(railContentId, { addParent = false } =
960
961
  "logo" : logo_image_url.asset->url,
961
962
  "dark_mode_logo": dark_mode_logo_url.asset->url,
962
963
  "light_mode_logo": light_mode_logo_url.asset->url,
963
- "badge": *[references(^._id) && _type == 'content-award'][0].badge.asset->url,
964
- "badge_rear": *[references(^._id) && _type == 'content-award'][0].badge_rear.asset->url,
965
- "badge_logo": *[references(^._id) && _type == 'content-award'][0].logo.asset->url,
966
- 'parentCount': coalesce(count(parent_content_data), 0)
967
- } | order(parentCount desc),`
964
+ ...*[references(^._id) && _type == 'content-award'][0]{
965
+ "badge": badge.asset->url,
966
+ "badge_rear": badge_rear.asset->url,
967
+ "badge_logo": logo.asset->url,
968
+ }
969
+ },`
968
970
  : ''
969
971
 
970
972
  const fields = `${getFieldsForContentType()}
@@ -1106,19 +1108,6 @@ async function fetchRelatedByLicense(railcontentId, brand, onlyUseSongTypes, cou
1106
1108
  * @returns {Promise<Array<Object>|null>} - The fetched related lessons data or null if not found.
1107
1109
  */
1108
1110
  export async function fetchSiblingContent(railContentId, brand = null) {
1109
- const filterGetParent = await new FilterBuilder(`references(^._id) && _type == ^.parent_type`, {
1110
- pullFutureContent: true,
1111
- showMembershipRestrictedContent: true, // Show parent even without permissions
1112
- }).buildFilter()
1113
- const filterForParentList = await new FilterBuilder(
1114
- `references(^._id) && _type == ^.parent_type`,
1115
- {
1116
- pullFutureContent: true,
1117
- isParentFilter: true,
1118
- showMembershipRestrictedContent: true, // Show parent even without permissions
1119
- }
1120
- ).buildFilter()
1121
-
1122
1111
  const childrenFilter = await new FilterBuilder(``, {
1123
1112
  isChildrenFilter: true,
1124
1113
  showMembershipRestrictedContent: true, // Show all lessons in sidebar, need_access applied on individual page
@@ -1126,20 +1115,20 @@ export async function fetchSiblingContent(railContentId, brand = null) {
1126
1115
 
1127
1116
  const brandString = brand ? ` && brand == "${brand}"` : ''
1128
1117
  const queryFields = getFieldsForContentType()
1129
-
1118
+ const courseCollectionFields = await getFieldsForContentTypeWithFilteredChildren('course-collection')
1130
1119
  const query = `*[railcontent_id == ${railContentId}${brandString}]{
1131
1120
  _type,
1132
1121
  parent_type,
1133
1122
  railcontent_id,
1134
- 'parent_id': ${parentField}.id,
1135
- 'grandparent_id':${grandParentField}.id,
1136
- 'for-calculations': *[${filterGetParent}][0]{
1137
- 'siblings-list': child[]->railcontent_id,
1138
- 'parents-list': *[${filterForParentList}][0].child[]->railcontent_id
1123
+ 'parent_id': ${parentReferenceField}->railcontent_id,
1124
+ 'grandparent_id': ${grandParentReferenceField}->railcontent_id,
1125
+ 'collection_data': ${grandParentReferenceField}->{${courseCollectionFields}},
1126
+ 'for-calculations': ${parentReferenceField}->{
1127
+ 'siblings-list': child[]->railcontent_id,
1128
+ 'parents-list': ${parentReferenceField}->child[]->railcontent_id
1139
1129
  },
1140
- "related_lessons" : *[${filterGetParent}][0].child[${childrenFilter}]->{${queryFields}}
1130
+ "related_lessons" : ${parentReferenceField}->child[${childrenFilter}]->{${queryFields}}
1141
1131
  }`
1142
-
1143
1132
  let result = await fetchSanity(query, false, { processNeedAccess: true })
1144
1133
 
1145
1134
  //there's no way in sanity to retrieve the index of an array, so we must calculate after fetch
@@ -1152,10 +1141,6 @@ export async function fetchSiblingContent(railContentId, brand = null) {
1152
1141
 
1153
1142
  delete result['for-calculations']
1154
1143
 
1155
- if (result['grandparent_id']) {
1156
- result['collection_data'] = await fetchCourseCollectionData(result['grandparent_id'])
1157
- }
1158
-
1159
1144
  result = { ...result, parentCount, currentParentIndex, siblingCount, currentSiblingIndex }
1160
1145
  return result
1161
1146
  } else {
@@ -1199,7 +1184,7 @@ export async function fetchRelatedLessons(railContentId) {
1199
1184
  }
1200
1185
 
1201
1186
  export async function fetchLiveEvent(brand, forcedContentId = null) {
1202
- const LIVE_EXTRA_MINUTES = 30
1187
+ const LIVE_EXTRA_MINUTES = 15
1203
1188
  //calendarIDs taken from addevent.php
1204
1189
  // TODO import instructor calendars to Sanity
1205
1190
  let defaultCalendarID = ''
@@ -1320,16 +1305,12 @@ export async function fetchByReference(
1320
1305
  * @returns {Promise<int|null>}
1321
1306
  */
1322
1307
  export async function fetchTopLevelParentId(railcontentId) {
1323
- const parentFilter = 'railcontent_id in [...(^.parent_content_data[].id)] && (!defined(parent_content_data) || count(parent_content_data) == 0)'
1324
- const statusFilter = "&& status in ['scheduled', 'published', 'archived', 'unlisted']"
1325
-
1326
1308
  const query = `*[railcontent_id == ${railcontentId}]{
1327
- railcontent_id,
1328
- 'top_parent': *[${parentFilter} ${statusFilter}][0].railcontent_id
1309
+ 'top_parent': coalesce(${grandParentReferenceField}->railcontent_id, ${parentReferenceField}->railcontent_id, railcontent_id),
1329
1310
  }`
1330
1311
  let response = await fetchSanity(query, false, { processNeedAccess: false })
1331
1312
  if (!response) return null
1332
- return response['top_parent'] ?? response['railcontent_id']
1313
+ return response['top_parent'] ?? railcontentId
1333
1314
  }
1334
1315
 
1335
1316
  export async function getHierarchy(contentId, collection) {
@@ -1403,31 +1384,18 @@ async function fetchALaCarteHierarchyData(railcontentId) {
1403
1384
  const childrenFilter = await new FilterBuilder(``, { isChildrenFilter: true }).buildFilter()
1404
1385
  const query = `*[railcontent_id == ${topLevelId}]{
1405
1386
  railcontent_id,
1406
- 'metadata': { brand, 'type': _type, 'parent_id': coalesce(parent_content_data[0].id, 0) },
1387
+ 'metadata': { brand, 'type': _type, 'parent_id': coalesce(${parentReferenceField}->railcontent_id, 0) },
1407
1388
  'assignments': assignment[]{railcontent_id},
1408
1389
  'children': child[${childrenFilter}]->{
1409
1390
  railcontent_id,
1410
- 'metadata': {
1411
- brand, 'type': _type, 'parent_id': coalesce(parent_content_data[0].id, 0) },
1391
+ 'metadata': { brand, 'type': _type, 'parent_id': coalesce(${parentReferenceField}->railcontent_id, 0) },
1412
1392
  'assignments': assignment[]{railcontent_id},
1413
1393
  'children': child[${childrenFilter}]->{
1414
1394
  railcontent_id,
1415
- 'metadata': {
1416
- brand, 'type': _type, 'parent_id': coalesce(parent_content_data[0].id, 0) },
1395
+ 'metadata': { brand, 'type': _type, 'parent_id': coalesce(${parentReferenceField}->railcontent_id, 0) },
1417
1396
  'assignments': assignment[]{railcontent_id},
1418
- 'children': child[${childrenFilter}]->{
1419
- railcontent_id,
1420
- 'metadata': {
1421
- brand, 'type': _type, 'parent_id': coalesce(parent_content_data[0].id, 0) },
1422
- 'assignments': assignment[]{railcontent_id},
1423
- 'children': child[${childrenFilter}]->{
1424
- railcontent_id,
1425
- 'metadata': {
1426
- brand, 'type': _type, 'parent_id': coalesce(parent_content_data[0].id, 0) },
1427
- }
1428
- }
1429
1397
  }
1430
- },
1398
+ }
1431
1399
  }`
1432
1400
  return await fetchSanity(query, false, { processNeedAccess: false })
1433
1401
  }
@@ -2000,7 +1968,7 @@ export async function fetchTabData(
2000
1968
  ? `&& !(railcontent_id in [${excludeIds.join(',')}])`
2001
1969
  : ''
2002
1970
 
2003
- const excludeCoursesInCourseCollectionsFilter = `&& !(_type == 'course' && defined(parent_content_data))`
1971
+ const excludeCoursesInCourseCollectionsFilter = `&& !(_type == 'course' && defined(parent_content_reference) && count(parent_content_reference[]) > 0)`
2004
1972
 
2005
1973
  filter = `brand == "${brand}" && (defined(railcontent_id)) ${includedFieldsFilter} ${progressFilter} ${excludedIdsFilter} ${excludeCoursesInCourseCollectionsFilter}`
2006
1974
  const childrenFilter = await new FilterBuilder(``, {
@@ -1,14 +1,17 @@
1
1
  import { SyncTelemetry } from '../telemetry'
2
2
 
3
- import LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'
3
+ import _LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'
4
4
 
5
5
  import { deleteDatabase, lokiFatalError } from '@nozbe/watermelondb/adapters/lokijs/worker/lokiExtensions'
6
6
 
7
+ // Handle CJS/ESM interop: in Node.js ESM the default import is the exports object
8
+ const LokiJSAdapter = (_LokiJSAdapter as any).default ?? _LokiJSAdapter
9
+
7
10
  export type LokiExtensions = {
8
11
  onPersistenceError?: (err: Error) => void
9
12
  }
10
13
 
11
- export default class LokiPersistenceErrorAwareAdapter extends LokiJSAdapter {
14
+ export default class LokiPersistenceErrorAwareAdapter extends (LokiJSAdapter as typeof _LokiJSAdapter) {
12
15
  constructor(options: any, extensions: LokiExtensions = {}) {
13
16
  super(options);
14
17
  const that = this;
@@ -162,7 +165,7 @@ export function simulateIndexedDBQuotaExceeded() {
162
165
  })
163
166
  }
164
167
 
165
- export function abortWritesToDatabase(adapter: LokiJSAdapter) {
168
+ export function abortWritesToDatabase(adapter: typeof LokiJSAdapter) {
166
169
  // acts as handy helper to disable loki's save methods entirely
167
170
  lokiFatalError(adapter._driver.loki)
168
171
  return Promise.resolve()
@@ -174,7 +177,7 @@ export function abortWritesToDatabase(adapter: LokiJSAdapter) {
174
177
  * Haven't encountered live issues related to this yet, but theoretically provides
175
178
  * the cleanest slate for a user to recover from schema issues?
176
179
  */
177
- export function destroyDatabase(dbName: string, adapter: LokiJSAdapter): Promise<void> {
180
+ export function destroyDatabase(dbName: string, adapter: typeof LokiJSAdapter): Promise<void> {
178
181
  return new Promise(async (resolve, reject) => {
179
182
  if (adapter._driver) {
180
183
  try {
@@ -122,7 +122,7 @@ export interface SyncResponseBase {
122
122
 
123
123
  export type PushPayload = {
124
124
  entries: ({
125
- record: BaseModel
125
+ record: Record<string, unknown>
126
126
  meta: {
127
127
  ids: {
128
128
  id: string
@@ -135,23 +135,11 @@ export type PushPayload = {
135
135
  ids: {
136
136
  id: string
137
137
  }
138
- deleted_at: EpochMs
138
+ deleted_at: number
139
139
  }
140
140
  })[]
141
141
  }
142
142
 
143
- interface ServerPushPayload {
144
- entries: {
145
- record: BaseModel | null
146
- meta: {
147
- ids: {
148
- id: string
149
- },
150
- deleted_at: EpochMs | null
151
- }
152
- }[]
153
- }
154
-
155
143
  export function makeFetchRequest(input: RequestInfo, init?: RequestInit) {
156
144
  return (userId: number, context: SyncContext) => new Request(globalConfig.baseUrl + input, {
157
145
  ...init,
@@ -19,6 +19,10 @@ export default class SyncRepository<TModel extends BaseModel> {
19
19
  this.store = store
20
20
  }
21
21
 
22
+ async getAll() {
23
+ return this.readAll()
24
+ }
25
+
22
26
  protected async readOne(id: RecordId) {
23
27
  return this._respondToRead(() => this.store.readOne(id))
24
28
  }
@@ -23,7 +23,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
23
23
  Q.sortBy('updated_at', 'desc'),
24
24
 
25
25
  ...(limit ? [Q.take(limit)] : []),
26
- ])
26
+ ].filter(Boolean) as Q.Clause[])
27
27
 
28
28
  return opts.onlyIds
29
29
  ? results.data.map((r) => r.content_id)
@@ -44,7 +44,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
44
44
  Q.sortBy('updated_at', 'desc'),
45
45
 
46
46
  ...(limit ? [Q.take(limit)] : []),
47
- ])
47
+ ].filter(Boolean) as Q.Clause[])
48
48
 
49
49
  return opts.onlyIds
50
50
  ? results.data.map((r) => r.content_id)
@@ -78,7 +78,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
78
78
 
79
79
  Q.or(Q.where('state', STATE.STARTED), Q.where('state', STATE.COMPLETED)),
80
80
  Q.sortBy('updated_at', 'desc'),
81
- ]
81
+ ].filter(Boolean) as Q.Clause[]
82
82
 
83
83
  if (opts.updatedAfter) {
84
84
  clauses.push(Q.where('updated_at', Q.gte(opts.updatedAfter)))
@@ -892,7 +892,7 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
892
892
 
893
893
  default:
894
894
  this.telemetry.error(`[store:${this.model.table}] Unknown record status`, {
895
- status: existing._raw._status,
895
+ extra: { status: existing._raw._status },
896
896
  })
897
897
  }
898
898
  } else {
@@ -999,6 +999,11 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
999
999
  }, 'sync.cleanup')
1000
1000
  })
1001
1001
  }, SyncStore.CLEANUP_INTERVAL)
1002
+
1003
+ // in tests in node env, prevents the timer from keeping the process alive
1004
+ if (typeof (this.cleanupTimer as any).unref === 'function') {
1005
+ (this.cleanupTimer as any).unref()
1006
+ }
1002
1007
  }
1003
1008
 
1004
1009
  private stopCleanupTimer() {
@@ -4,7 +4,7 @@ import SyncStore from "../store";
4
4
 
5
5
  type SyncCallback = (reason: string) => void
6
6
 
7
- type SyncCallbacks = {
7
+ export type SyncCallbacks = {
8
8
  callback: SyncCallback
9
9
  requestSync: SyncCallback
10
10
  requestPull: SyncCallback
@@ -10,7 +10,7 @@ export type SentryLike = {
10
10
  captureMessage: typeof InjectedSentry.captureMessage
11
11
  addBreadcrumb: typeof InjectedSentry.addBreadcrumb
12
12
  startSpan: typeof InjectedSentry.startSpan
13
- logger: typeof InjectedSentry.logger
13
+ logger: { debug: (...args: any[]) => void; info: (...args: any[]) => void }
14
14
  }
15
15
 
16
16
  export type StartSpanOptions = Parameters<typeof InjectedSentry.startSpan>[0]
@@ -38,6 +38,7 @@ export interface ContentUrlParams {
38
38
  /** Navigation target (optional) */
39
39
  navigateTo?: {
40
40
  id: number
41
+ child?: { id: number }
41
42
  }
42
43
  /** Brand (drumeo, pianote, guitareo, singeo, playbass) */
43
44
  brand?: Brand
@@ -43,7 +43,7 @@ class StreakCalculator {
43
43
  }
44
44
 
45
45
  private async fetchAllPractices(): Promise<PracticeData> {
46
- const query = await db.practices.queryAll()
46
+ const query = await db.practices.getAll()
47
47
 
48
48
  return query.data.reduce((acc, practice) => {
49
49
  acc[practice.date] = acc[practice.date] || []
@@ -0,0 +1,151 @@
1
+ # Skipped Tests Reference
2
+
3
+ This document tracks all skipped tests and why they are skipped. Tests are divided into two categories:
4
+
5
+ 1. **Skipped for CI** — were passing but depend on live external services; skipped to enable clean CI runs
6
+ 2. **Previously skipped — failing or unknown** — were already skipped before CI work; many confirmed failing
7
+
8
+ The goal is to eventually move all Category 1 tests into a dedicated integration/live test suite, and to triage Category 2 tests as either fixable or retired.
9
+
10
+ ---
11
+
12
+ ## Category 1: Skipped for CI (were passing, have external dependencies)
13
+
14
+ ### `test/sanityQueryService.test.js` — Sanity CMS
15
+
16
+ All tests in this file call real Sanity GROQ queries via `initializeTestService(true)`.
17
+
18
+ | Test | Dependency |
19
+ |---|---|
20
+ | fetchSongById | Sanity |
21
+ | fetchReturning | Sanity |
22
+ | fetchLeaving | Sanity |
23
+ | fetchComingSoon | Sanity |
24
+ | fetchSanity-WithPostProcess | Sanity |
25
+ | fetchSanityPostProcess | Sanity |
26
+ | fetchByRailContentIds | Sanity |
27
+ | fetchByRailContentIds_Order | Sanity |
28
+ | fetchUpcomingNewReleases | Sanity |
29
+ | fetchLessonContent | Sanity |
30
+ | fetchAllSongsInProgress | Sanity |
31
+ | fetchNewReleases | Sanity |
32
+ | fetchAllWorkouts | Sanity |
33
+ | fetchAllInstructorField | Sanity |
34
+ | fetchAllInstructors | Sanity |
35
+ | fetchAll-CustomFields | Sanity |
36
+ | fetchRelatedLessons | Sanity |
37
+ | fetchRelatedLessons-quick-tips | Sanity |
38
+ | fetchRelatedLessons-in-rhythm | Sanity |
39
+ | getSortOrder | Sanity (describe block requires live auth) |
40
+ | fetchAll-WithProgress | Sanity |
41
+ | fetchAllFilterOptions-WithProgress | Sanity |
42
+ | fetchAll-IncludedFields | Sanity |
43
+ | fetchAll-IncludedFields-rudiment-multiple-gear | Sanity |
44
+ | fetchByReference | Sanity |
45
+ | fetchScheduledReleases | Sanity |
46
+ | fetchAll-GroupBy-Artists | Sanity |
47
+ | fetchAll-GroupBy-Instructors | Sanity |
48
+ | fetchMetadata | Sanity |
49
+ | fetchMetadata-Coach-Lessons | Sanity |
50
+ | invalidContentType | Sanity (describe block requires live auth) |
51
+ | metaDataForLessons | Sanity |
52
+ | metaDataForSongs | Sanity |
53
+ | fetchAllFilterOptionsLessons | Sanity |
54
+ | fetchAllFilterOptionsSongs | Sanity |
55
+ | fetchLiveEvent | Sanity |
56
+ | fetchRelatedLessons-pack-bundle-lessons | Sanity |
57
+ | fetchRelatedLessons-course-parts | Sanity |
58
+ | fetchRelatedLessons-song-tutorial-children | Sanity |
59
+ | fetchMetadata (second) | Sanity |
60
+
61
+ ### `test/content.test.js` — Sanity CMS + Railcontent API
62
+
63
+ | Test | Dependency |
64
+ |---|---|
65
+ | getTabResults-Singles | Sanity + Railcontent |
66
+ | getTabResults-Courses | Sanity + Railcontent |
67
+ | getTabResults-Type-Explore-All | Sanity + Railcontent |
68
+
69
+ ### `test/user/permissions.test.js` — Railcontent API
70
+
71
+ | Test | Dependency |
72
+ |---|---|
73
+ | fetchUserPermissions | Railcontent `fetchUserPermissionsData` |
74
+
75
+ ---
76
+
77
+ ## Category 2: Previously Skipped — Failing or Unknown State
78
+
79
+ These were already skipped before CI work. Status is noted where confirmed.
80
+
81
+ ### `test/sanityQueryService.test.js`
82
+
83
+ | Test | Status | Failure Reason |
84
+ |---|---|---|
85
+ | fetchSongArtistCount | Unknown | — |
86
+ | fetchUpcomingEvents | Unknown | — |
87
+ | fetchLessonContent-PlayAlong-containts-array-of-videos | Unknown | — |
88
+ | fetchAllSortField | Unknown | — |
89
+ | fetchRelatedLessons-child | Unknown | — |
90
+ | fetchPackAll | Unknown | — |
91
+ | fetchAllPacks | Unknown | — |
92
+ | fetchAll-IncludedFields-multiple | Unknown | — |
93
+ | fetchAll-IncludedFields-playalong-multiple | Unknown | — |
94
+ | fetchAll-IncludedFields-coaches-multiple-focus | Unknown | — |
95
+ | fetchAll-IncludedFields-songs-multiple-instrumentless | Unknown | — |
96
+ | fetchAll-GroupBy-Genre | Unknown | — |
97
+ | fetchShowsData | Unknown | — |
98
+ | fetchShowsData-OddTimes | Unknown | — |
99
+ | fetchTopLevelParentId | Unknown | — |
100
+ | fetchHierarchy | Unknown | — |
101
+ | fetchTopLeveldrafts | Failing | Timeout (>5s) |
102
+ | fetchCommentData | Failing | `null.forEach` — Sanity returns null for content IDs |
103
+ | baseConstructor | Unknown | — |
104
+ | withOnlyFilterAvailableStatuses | Unknown | — |
105
+ | withContentStatusAndFutureScheduledContent | Unknown | — |
106
+ | withUserPermissions | Unknown | — |
107
+ | withUserPermissionsForPlusUser | Unknown | — |
108
+ | withPermissionBypass | Unknown | — |
109
+ | withPublishOnRestrictions | Unknown | — |
110
+ | fetchAllFilterOptions | Unknown | — |
111
+ | fetchAllFilterOptions-Rudiment | Unknown | — |
112
+ | fetchAllFilterOptions-PlayAlong | Unknown | — |
113
+ | fetchAllFilterOptions-Coaches | Unknown | — |
114
+ | fetchAllFilterOptions-filter-selected | Failing | `null.meta` — API returns null for filter combination |
115
+ | customBrandTypeExists | Unknown | — |
116
+ | withCommon | Unknown | — |
117
+ | fetchOtherSongVersions | Failing | 0 results — content is drafted/admin-only |
118
+ | fetchLessonsFeaturingThisContent | Failing | 0 results — content is drafted/admin-only |
119
+ | getRecommendedForYou | Failing | `SyncError: Intended user ID does not match` |
120
+ | getRecommendedForYou-SeeAll | Failing | `SyncError: Intended user ID does not match` |
121
+
122
+ ### `test/content.test.js`
123
+
124
+ | Test | Status | Failure Reason |
125
+ |---|---|---|
126
+ | getTabResults-Filters | Failing | Timeout (>5s) |
127
+ | getTabResults-Type-Filter | Failing | `TypeError: null.entity` — Sanity returns null |
128
+ | getContentRows | Unknown |Sanity & pw-recommender |
129
+ | getNewAndUpcoming | Failing | Timeout (>5s) |
130
+ | getScheduleContentRows | Failing | Timeout (>5s) |
131
+ | getSpecificScheduleContentRow | Failing | Timeout (>5s) |
132
+
133
+ ### `test/contentProgress.test.js`
134
+
135
+ | Test | Status | Failure Reason |
136
+ |---|---|---|
137
+ | get-Songs-Tutorials | Unknown | Live Sanity call |
138
+ | get-Songs-Transcriptions | Unknown | Live Sanity call |
139
+ | get-Songs-Play-Alongs | Unknown | Live Sanity call |
140
+
141
+ ### `test/progressRows.test.js`
142
+
143
+ | Test | Status | Failure Reason |
144
+ |---|---|---|
145
+ | check progress rows logic | Failing | Stale mock data — not a live API issue; mock data no longer reflects current data shape |
146
+
147
+ ### `test/learningPaths.test.js`
148
+
149
+ | Test | Status | Failure Reason |
150
+ |---|---|---|
151
+ | learningPathCompletion | Unknown | Uses `initializeTestService(true)` — live API |
@@ -1,7 +1,7 @@
1
1
  import { globalConfig, initializeService } from '../src'
2
2
  import { LocalStorageMock } from './localStorageMock'
3
- import { initializeSyncManager } from './sync/initialize-sync-manager'
4
3
  const railContentModule = require('../src/services/railcontent.js')
4
+ const awardDefsModule = require('../src/services/awards/internal/award-definitions.js')
5
5
  let token = null
6
6
  let userId = process.env.RAILCONTENT_USER_ID ?? null
7
7
 
@@ -51,11 +51,10 @@ export async function initializeTestService(useLive = false, isAdmin = false) {
51
51
  localStorage: new LocalStorageMock(),
52
52
  isMA: true,
53
53
  }
54
+ jest.spyOn(awardDefsModule.awardDefinitions, 'initialize').mockResolvedValue()
54
55
  initializeService(config)
55
56
  // Mock user permissions
56
57
  let permissionsMock = jest.spyOn(railContentModule, 'fetchUserPermissionsData')
57
58
  let permissionsData = { permissions: [108, 91, 92], isAdmin: isAdmin }
58
59
  permissionsMock.mockImplementation(() => permissionsData)
59
-
60
- initializeSyncManager(userId)
61
60
  }