musora-content-services 2.157.2 → 2.158.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.
- package/.claude/settings.local.json +17 -5
- package/.coderabbit.yaml +0 -0
- package/.editorconfig +0 -0
- package/.github/pull_request_template.md +0 -0
- package/.github/workflows/conventional-commits.yaml +0 -0
- package/.github/workflows/docs.js.yml +1 -1
- package/.prettierignore +0 -0
- package/.prettierrc +0 -0
- package/CHANGELOG.md +30 -0
- package/CLAUDE.md +0 -0
- package/README.md +0 -0
- package/babel.config.cjs +0 -0
- package/jest.live.config.js +10 -0
- package/package.json +1 -1
- package/src/constants/award-assets.js +0 -0
- package/src/constants/membership-permissions.ts +0 -0
- package/src/contentMetaData.js +0 -0
- package/src/contentTypeConfig.js +1 -1
- package/src/filterBuilder.js +0 -0
- package/src/index.d.ts +5 -0
- package/src/index.js +5 -0
- package/src/infrastructure/http/HttpClient.ts +0 -0
- package/src/infrastructure/http/executors/FetchRequestExecutor.ts +0 -0
- package/src/infrastructure/http/index.ts +0 -0
- package/src/infrastructure/http/interfaces/HeaderProvider.ts +0 -0
- package/src/infrastructure/http/interfaces/HttpError.ts +0 -0
- package/src/infrastructure/http/interfaces/NetworkError.ts +0 -0
- package/src/infrastructure/http/interfaces/RequestExecutor.ts +0 -0
- package/src/infrastructure/http/interfaces/RequestOptions.ts +0 -0
- package/src/infrastructure/http/providers/DefaultHeaderProvider.ts +0 -0
- package/src/lib/ads/monoid.ts +0 -0
- package/src/lib/ads/semigroup.ts +0 -0
- package/src/lib/brands.ts +0 -0
- package/src/lib/lastUpdated.js +0 -0
- package/src/lib/sanity/field-access.ts +0 -0
- package/src/lib/sanity/query.ts +0 -0
- package/src/services/api/types.js +0 -0
- package/src/services/api/types.ts +0 -0
- package/src/services/awards/award-callbacks.js +0 -0
- package/src/services/awards/award-query.js +0 -0
- package/src/services/awards/internal/.indexignore +0 -0
- package/src/services/awards/internal/award-definitions.js +0 -0
- package/src/services/awards/internal/award-events.js +0 -0
- package/src/services/awards/internal/award-manager.js +0 -0
- package/src/services/awards/internal/certificate-builder.js +0 -0
- package/src/services/awards/internal/completion-data-generator.js +0 -0
- package/src/services/awards/internal/content-progress-observer.js +0 -0
- package/src/services/awards/internal/image-utils.js +0 -0
- package/src/services/awards/internal/message-generator.js +0 -0
- package/src/services/awards/internal/types.js +0 -0
- package/src/services/awards/types.d.ts +0 -0
- package/src/services/awards/types.js +0 -0
- package/src/services/config.js +0 -0
- package/src/services/content/artist.ts +0 -0
- package/src/services/content/content.ts +0 -0
- package/src/services/content/genre.ts +0 -0
- package/src/services/content/instructor.ts +0 -0
- package/src/services/content-org/content-org.js +0 -0
- package/src/services/content-org/playlists-types.js +0 -0
- package/src/services/content-org/playlists.js +0 -0
- package/src/services/contentAggregator.js +0 -0
- package/src/services/contentLikes.js +0 -0
- package/src/services/dataContext.js +0 -0
- package/src/services/dateUtils.js +0 -0
- package/src/services/endScreen/README.md +62 -0
- package/src/services/endScreen/endScreen.ts +153 -0
- package/src/services/endScreen/types.ts +63 -0
- package/src/services/eventsAPI.js +0 -0
- package/src/services/forums/categories.ts +0 -0
- package/src/services/forums/forums.ts +0 -0
- package/src/services/forums/posts.ts +0 -0
- package/src/services/forums/threads.ts +13 -2
- package/src/services/forums/types.ts +0 -0
- package/src/services/gamification/awards.ts +0 -0
- package/src/services/gamification/gamification.js +0 -0
- package/src/services/imageSRCBuilder.js +0 -0
- package/src/services/imageSRCVerify.js +0 -0
- package/src/services/liveTesting.ts +0 -0
- package/src/services/permissions/PermissionsAdapterFactory.ts +0 -0
- package/src/services/permissions/README.md +0 -0
- package/src/services/progress-events.js +0 -0
- package/src/services/progress-row/base.js +0 -0
- package/src/services/progress-row/rows/.indexignore +0 -0
- package/src/services/progress-row/rows/content-card.js +0 -0
- package/src/services/progress-row/rows/playlist-card.js +0 -0
- package/src/services/railcontent.js +0 -0
- package/src/services/recommendations.js +3 -0
- package/src/services/reporting/README.md +0 -0
- package/src/services/reporting/types.ts +0 -0
- package/src/services/sanity.js +7 -6
- package/src/services/sentry/.indexignore +0 -0
- package/src/services/sentry/index.ts +0 -0
- package/src/services/state.ts +0 -0
- package/src/services/sync/.indexignore +0 -0
- package/src/services/sync/adapters/factory.ts +0 -0
- package/src/services/sync/adapters/lokijs.ts +1 -0
- package/src/services/sync/adapters/sqlite.ts +0 -0
- package/src/services/sync/context/providers/base.ts +0 -0
- package/src/services/sync/context/providers/connectivity.ts +0 -0
- package/src/services/sync/context/providers/durability.ts +0 -0
- package/src/services/sync/context/providers/index.ts +0 -0
- package/src/services/sync/context/providers/session.ts +0 -0
- package/src/services/sync/context/providers/tabs.ts +0 -0
- package/src/services/sync/context/providers/visibility.ts +0 -0
- package/src/services/sync/database/factory.ts +0 -0
- package/src/services/sync/debug.ts +0 -0
- package/src/services/sync/effects/index.ts +0 -0
- package/src/services/sync/effects/logout-warning.ts +0 -0
- package/src/services/sync/errors/boundary.ts +0 -0
- package/src/services/sync/errors/index.ts +0 -0
- package/src/services/sync/errors/validators.ts +0 -0
- package/src/services/sync/fetch.ts +0 -0
- package/src/services/sync/index.ts +0 -0
- package/src/services/sync/manager.ts +0 -0
- package/src/services/sync/models/Base.ts +0 -0
- package/src/services/sync/models/ContentLike.ts +0 -0
- package/src/services/sync/models/ContentProgress.ts +0 -0
- package/src/services/sync/models/Practice.ts +0 -0
- package/src/services/sync/models/PracticeDayNote.ts +0 -0
- package/src/services/sync/models/UserAwardProgress.ts +0 -0
- package/src/services/sync/models/index.ts +0 -0
- package/src/services/sync/repositories/content-likes.ts +0 -0
- package/src/services/sync/repositories/content-progress.ts +0 -0
- package/src/services/sync/repositories/index.ts +0 -0
- package/src/services/sync/repositories/practice-day-notes.ts +0 -0
- package/src/services/sync/repositories/practices.ts +0 -0
- package/src/services/sync/repositories/user-award-progress.ts +0 -0
- package/src/services/sync/repository-proxy.ts +0 -0
- package/src/services/sync/resolver.ts +0 -0
- package/src/services/sync/run-scope.ts +0 -0
- package/src/services/sync/schema/index.ts +0 -0
- package/src/services/sync/serializers/index.ts +0 -0
- package/src/services/sync/serializers/model.ts +0 -0
- package/src/services/sync/serializers/raw.ts +0 -0
- package/src/services/sync/store/index.ts +2 -6
- package/src/services/sync/store-configs.ts +0 -0
- package/src/services/sync/strategies/base.ts +0 -0
- package/src/services/sync/strategies/index.ts +0 -0
- package/src/services/sync/strategies/initial.ts +0 -0
- package/src/services/sync/strategies/polling.ts +0 -0
- package/src/services/sync/telemetry/flood-prevention.ts +0 -0
- package/src/services/sync/telemetry/sampling.ts +0 -0
- package/src/services/sync/utils/event-emitter.ts +0 -0
- package/src/services/sync/utils/index.ts +0 -0
- package/src/services/sync/utils/throttle.ts +0 -0
- package/src/services/sync/utils/timers.ts +0 -0
- package/src/services/types.js +0 -0
- package/src/services/urlBuilder.ts +0 -17
- package/src/services/user/account.ts +0 -0
- package/src/services/user/chat.js +0 -0
- package/src/services/user/interests.js +0 -0
- package/src/services/user/management.js +0 -0
- package/src/services/user/memberships.ts +0 -0
- package/src/services/user/notifications.js +0 -0
- package/src/services/user/payments.ts +0 -0
- package/src/services/user/profile.js +1 -1
- package/src/services/user/sessions.js +0 -0
- package/src/services/user/streakCalculator.ts +0 -0
- package/src/services/user/types.js +0 -0
- package/src/services/user/user-management-system.js +0 -0
- package/test/SKIPPED_TESTS.md +0 -0
- package/test/initializeTests.js +0 -0
- package/test/integration/content.test.js +0 -0
- package/test/integration/contentProgress.test.js +0 -0
- package/test/integration/forum.test.js +0 -0
- package/test/integration/sanityQueryService.test.js +0 -0
- package/test/localStorageMock.js +0 -0
- package/test/log.js +0 -0
- package/test/mockData/award-definitions.js +0 -0
- package/test/mockData/mockData_fetchByRailContentIds_one_content.json +0 -0
- package/test/mockData/mockData_progress_content.json +0 -0
- package/test/mockData/mockData_sanity_progress_content.json +0 -0
- package/test/mockData/mockData_user_practices.json +0 -0
- package/test/setupConsole.js +0 -0
- package/test/setupNetworkGuard.js +0 -0
- package/test/unit/awards/award-callbacks.test.ts +144 -0
- package/test/unit/awards/internal/image-utils.test.ts +86 -0
- package/test/unit/endScreen.test.js +712 -0
- package/test/unit/infrastructure/DefaultHeaderProvider.test.ts +39 -0
- package/test/unit/infrastructure/FetchRequestExecutor.test.ts +88 -0
- package/test/unit/lib/__snapshots__/filter.test.ts.snap +0 -0
- package/test/unit/lib/query.test.ts +0 -0
- package/test/unit/progress-row/playlist-card.test.ts +104 -0
- package/test/unit/sentry.test.ts +62 -0
- package/test/unit/sync/context.test.ts +51 -0
- package/test/unit/sync/errors/sync-errors.test.ts +106 -0
- package/test/unit/sync/errors/validators.test.ts +61 -0
- package/test/unit/sync/models/user-award-progress.test.ts +82 -0
- package/test/unit/sync/repositories/user-award-progress.static.test.ts +68 -0
- package/test/unit/sync/run-scope.test.ts +23 -0
- package/test/unit/sync/store-configs.test.ts +37 -0
- package/test/unit/sync/telemetry/sync-telemetry.test.ts +118 -0
- package/test/unit/sync/utils/event-emitter.test.ts +64 -0
- package/test/unit/url-builder.test.ts +72 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { DefaultHeaderProvider } from '../../../src/infrastructure/http/providers/DefaultHeaderProvider'
|
|
2
|
+
import { globalConfig } from '../../../src/services/config.js'
|
|
3
|
+
jest.mock('../../../src/services/config.js', () => ({
|
|
4
|
+
globalConfig: {
|
|
5
|
+
localTimezoneString: null,
|
|
6
|
+
isMA: false,
|
|
7
|
+
}
|
|
8
|
+
}))
|
|
9
|
+
describe('DefaultHeaderProvider', () => {
|
|
10
|
+
let provider: DefaultHeaderProvider
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
provider = new DefaultHeaderProvider()
|
|
13
|
+
globalConfig.localTimezoneString = null
|
|
14
|
+
globalConfig.isMA = false
|
|
15
|
+
})
|
|
16
|
+
test('always includes Content-Type and Accept headers', () => {
|
|
17
|
+
const headers = provider.getHeaders()
|
|
18
|
+
expect(headers['Content-Type']).toBe('application/json')
|
|
19
|
+
expect(headers['Accept']).toBe('application/json')
|
|
20
|
+
})
|
|
21
|
+
test('adds M-Client-Timezone header when localTimezoneString is set', () => {
|
|
22
|
+
globalConfig.localTimezoneString = 'America/Vancouver'
|
|
23
|
+
const headers = provider.getHeaders()
|
|
24
|
+
expect(headers['M-Client-Timezone']).toBe('America/Vancouver')
|
|
25
|
+
})
|
|
26
|
+
test('omits M-Client-Timezone header when localTimezoneString is not set', () => {
|
|
27
|
+
const headers = provider.getHeaders()
|
|
28
|
+
expect(headers['M-Client-Timezone']).toBeUndefined()
|
|
29
|
+
})
|
|
30
|
+
test('adds X-Client-Platform header when isMA is true', () => {
|
|
31
|
+
globalConfig.isMA = true
|
|
32
|
+
const headers = provider.getHeaders()
|
|
33
|
+
expect(headers['X-Client-Platform']).toBe('mobile')
|
|
34
|
+
})
|
|
35
|
+
test('omits X-Client-Platform header when isMA is false', () => {
|
|
36
|
+
const headers = provider.getHeaders()
|
|
37
|
+
expect(headers['X-Client-Platform']).toBeUndefined()
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { FetchRequestExecutor } from '../../../src/infrastructure/http'
|
|
2
|
+
describe('FetchRequestExecutor', () => {
|
|
3
|
+
let executor: FetchRequestExecutor
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
executor = new FetchRequestExecutor()
|
|
6
|
+
})
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
jest.restoreAllMocks()
|
|
9
|
+
})
|
|
10
|
+
describe('successful responses', () => {
|
|
11
|
+
test('returns parsed JSON when content-type is application/json', async () => {
|
|
12
|
+
const mockData = { id: 1, name: 'test' }
|
|
13
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
14
|
+
ok: true,
|
|
15
|
+
headers: { get: jest.fn().mockReturnValue('application/json') },
|
|
16
|
+
json: jest.fn().mockResolvedValue(mockData),
|
|
17
|
+
text: jest.fn(),
|
|
18
|
+
})
|
|
19
|
+
const result = await executor.execute('https://api.example.com/test', { method: 'GET', headers: {} })
|
|
20
|
+
expect(result).toEqual(mockData)
|
|
21
|
+
})
|
|
22
|
+
test('returns text when content-type is not application/json', async () => {
|
|
23
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
24
|
+
ok: true,
|
|
25
|
+
headers: { get: jest.fn().mockReturnValue('text/plain') },
|
|
26
|
+
json: jest.fn(),
|
|
27
|
+
text: jest.fn().mockResolvedValue('plain text response'),
|
|
28
|
+
})
|
|
29
|
+
const result = await executor.execute('https://api.example.com/test', { method: 'GET', headers: {} })
|
|
30
|
+
expect(result).toBe('plain text response')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
describe('error responses', () => {
|
|
34
|
+
test('throws HttpError with correct shape when response is not ok', async () => {
|
|
35
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
36
|
+
ok: false,
|
|
37
|
+
status: 404,
|
|
38
|
+
statusText: 'Not Found',
|
|
39
|
+
headers: { get: jest.fn().mockReturnValue('application/json') },
|
|
40
|
+
json: jest.fn().mockResolvedValue({ message: 'not found' }),
|
|
41
|
+
text: jest.fn(),
|
|
42
|
+
})
|
|
43
|
+
await expect(
|
|
44
|
+
executor.execute('https://api.example.com/test', { method: 'GET', headers: {} })
|
|
45
|
+
).rejects.toMatchObject({
|
|
46
|
+
status: 404,
|
|
47
|
+
statusText: 'Not Found',
|
|
48
|
+
url: 'https://api.example.com/test',
|
|
49
|
+
method: 'GET',
|
|
50
|
+
body: { message: 'not found' },
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
test('error body is parsed as JSON when response body is valid JSON', async () => {
|
|
54
|
+
const errorBody = { message: 'validation failed', code: 422 }
|
|
55
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
56
|
+
ok: false,
|
|
57
|
+
status: 422,
|
|
58
|
+
statusText: 'Unprocessable Entity',
|
|
59
|
+
headers: { get: jest.fn() },
|
|
60
|
+
json: jest.fn().mockResolvedValue(errorBody),
|
|
61
|
+
text: jest.fn(),
|
|
62
|
+
})
|
|
63
|
+
await expect(
|
|
64
|
+
executor.execute('https://api.example.com/test', { method: 'POST', headers: {} })
|
|
65
|
+
).rejects.toMatchObject({
|
|
66
|
+
body: errorBody,
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
test('error body falls back to text when response body is not JSON', async () => {
|
|
70
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
71
|
+
ok: false,
|
|
72
|
+
status: 500,
|
|
73
|
+
statusText: 'Internal Server Error',
|
|
74
|
+
headers: { get: jest.fn() },
|
|
75
|
+
json: jest.fn().mockRejectedValue(new Error('invalid json')),
|
|
76
|
+
text: jest.fn().mockResolvedValue('Internal Server Error'),
|
|
77
|
+
})
|
|
78
|
+
await expect(
|
|
79
|
+
executor.execute('https://api.example.com/test', { method: 'GET', headers: {} })
|
|
80
|
+
).rejects.toMatchObject({
|
|
81
|
+
body: 'Internal Server Error',
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { processPlaylistItem, getRecentPlaylists, getPlaylistEngagedOnContent } from '../../../src/services/progress-row/rows/playlist-card.js'
|
|
2
|
+
jest.mock('../../../src/services/content-org/playlists.js', () => ({
|
|
3
|
+
fetchUserPlaylists: jest.fn(),
|
|
4
|
+
}))
|
|
5
|
+
jest.mock('../../../src/services/sanity.js', () => ({
|
|
6
|
+
...jest.requireActual('../../../src/services/sanity.js'),
|
|
7
|
+
fetchByRailContentIds: jest.fn(),
|
|
8
|
+
}))
|
|
9
|
+
jest.mock('../../../src/services/contentAggregator.js', () => ({
|
|
10
|
+
addContextToContent: jest.fn(),
|
|
11
|
+
}))
|
|
12
|
+
import { fetchUserPlaylists } from '../../../src/services/content-org/playlists.js'
|
|
13
|
+
const mockPlaylistItem = {
|
|
14
|
+
pinned: false,
|
|
15
|
+
progressTimestamp: 1234567890,
|
|
16
|
+
playlist: {
|
|
17
|
+
id: 1,
|
|
18
|
+
name: 'My Playlist',
|
|
19
|
+
brand: 'drumeo',
|
|
20
|
+
duration_formated: '1h 30m',
|
|
21
|
+
total_items: 10,
|
|
22
|
+
likes: 5,
|
|
23
|
+
first_items_thumbnail_url: [
|
|
24
|
+
{
|
|
25
|
+
thumbnail: 'https://cdn.example.com/thumb.jpg',
|
|
26
|
+
type: 'song',
|
|
27
|
+
artist_name: 'Kiss',
|
|
28
|
+
title: "Mwaa"
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
navigateTo: { id: 42, content_id: 99 },
|
|
32
|
+
user: { display_name: 'John Doe' },
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
describe('processPlaylistItem', () => {
|
|
36
|
+
test('returns correctly shaped object from playlist item', () => {
|
|
37
|
+
const result = processPlaylistItem(mockPlaylistItem)
|
|
38
|
+
expect(result).toMatchObject({
|
|
39
|
+
id: 1,
|
|
40
|
+
progressType: 'playlist',
|
|
41
|
+
header: 'playlist',
|
|
42
|
+
pinned: false,
|
|
43
|
+
progressTimestamp: 1234567890,
|
|
44
|
+
cta: {
|
|
45
|
+
text: 'Continue',
|
|
46
|
+
action: {
|
|
47
|
+
brand: 'drumeo',
|
|
48
|
+
item_id: 42,
|
|
49
|
+
content_id: 99,
|
|
50
|
+
type: 'playlists',
|
|
51
|
+
id: 1,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
test('pinned defaults to false when not set', () => {
|
|
57
|
+
const item = { ...mockPlaylistItem, pinned: undefined }
|
|
58
|
+
const result = processPlaylistItem(item)
|
|
59
|
+
expect(result.pinned).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
test('pinned is true when set', () => {
|
|
62
|
+
const item = { ...mockPlaylistItem, pinned: true }
|
|
63
|
+
const result = processPlaylistItem(item)
|
|
64
|
+
expect(result.pinned).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
test('subtitle combines duration, total_items, likes and display_name', () => {
|
|
67
|
+
const result = processPlaylistItem(mockPlaylistItem)
|
|
68
|
+
expect(result.body.subtitle).toBe('1h 30m • 10 items • 5 likes • John Doe')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
describe('getRecentPlaylists', () => {
|
|
72
|
+
test('filters out playlists with no last_progress', async () => {
|
|
73
|
+
(fetchUserPlaylists as jest.Mock).mockResolvedValue({
|
|
74
|
+
data: [
|
|
75
|
+
{ id: 1, last_progress: '2024-01-01 10:00:00', last_engaged_on: 123 },
|
|
76
|
+
{ id: 2, last_progress: null, last_engaged_on: 123 },
|
|
77
|
+
]
|
|
78
|
+
})
|
|
79
|
+
const result = await getRecentPlaylists('drumeo', 10)
|
|
80
|
+
expect(result).toHaveLength(1)
|
|
81
|
+
expect(result[0].id).toBe(1)
|
|
82
|
+
})
|
|
83
|
+
test('converts last_progress date string to UTC timestamp', async () => {
|
|
84
|
+
(fetchUserPlaylists as jest.Mock).mockResolvedValue({
|
|
85
|
+
data: [
|
|
86
|
+
{ id: 1, last_progress: '2024-01-01 10:00:00', last_engaged_on: 123 },
|
|
87
|
+
]
|
|
88
|
+
})
|
|
89
|
+
const result = await getRecentPlaylists('drumeo', 10)
|
|
90
|
+
expect(typeof result[0].progressTimestamp).toBe('number')
|
|
91
|
+
expect(result[0].progressTimestamp).toBe(new Date('2024-01-01T10:00:00Z').getTime())
|
|
92
|
+
})
|
|
93
|
+
test('returns empty array when response has no data', async () => {
|
|
94
|
+
(fetchUserPlaylists as jest.Mock).mockResolvedValue({ data: [] })
|
|
95
|
+
const result = await getRecentPlaylists('drumeo', 10)
|
|
96
|
+
expect(result).toHaveLength(0)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
describe('getPlaylistEngagedOnContent', () => {
|
|
100
|
+
test('returns empty array when recentPlaylists is empty', async () => {
|
|
101
|
+
const result = await getPlaylistEngagedOnContent([])
|
|
102
|
+
expect(result).toEqual([])
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { composeHandlers } from '../../src/services/sentry/index'
|
|
2
|
+
|
|
3
|
+
test('returns value from first handler when it returns non-defined', () => {
|
|
4
|
+
const first = jest.fn().mockReturnValue('first')
|
|
5
|
+
const second = jest.fn().mockReturnValue('second')
|
|
6
|
+
const composed = composeHandlers(first, second)
|
|
7
|
+
const result = composed('arg1' as any, 'arg2' as any)
|
|
8
|
+
|
|
9
|
+
expect(result).toBe('first')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('does not call subsequent handlers when first returns a value', () => {
|
|
13
|
+
const first = jest.fn().mockReturnValue('first')
|
|
14
|
+
const second = jest.fn().mockReturnValue('second')
|
|
15
|
+
const composed = composeHandlers(first, second)
|
|
16
|
+
composed('arg1' as any, 'arg2' as any)
|
|
17
|
+
expect(second).not.toHaveBeenCalled()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('skips first handler returning undefined and uses second', () => {
|
|
21
|
+
const first = jest.fn().mockReturnValue(undefined)
|
|
22
|
+
const second = jest.fn().mockReturnValue('second')
|
|
23
|
+
const composed = composeHandlers(first, second)
|
|
24
|
+
const result = composed('arg1' as any, 'arg2' as any)
|
|
25
|
+
|
|
26
|
+
expect(result).toBe('second')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('skips multiple undefined handlers to find first returning value', () => {
|
|
30
|
+
const first = jest.fn().mockReturnValue(undefined)
|
|
31
|
+
const second = jest.fn().mockReturnValue(undefined)
|
|
32
|
+
const third = jest.fn().mockReturnValue('third')
|
|
33
|
+
const composed = composeHandlers(first, second, third)
|
|
34
|
+
const result = composed('arg1' as any, 'arg2' as any)
|
|
35
|
+
|
|
36
|
+
expect(result).toBe('third')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('returns args[0] when all handlers return undefined', () => {
|
|
40
|
+
const first = jest.fn().mockReturnValue(undefined)
|
|
41
|
+
const second = jest.fn().mockReturnValue(undefined)
|
|
42
|
+
const composed = composeHandlers(first, second)
|
|
43
|
+
const result = composed('fallback' as any, 'arg2' as any)
|
|
44
|
+
|
|
45
|
+
expect(result).toBe('fallback')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('returns args[0] when no handlers are provided', () => {
|
|
49
|
+
const composed = composeHandlers()
|
|
50
|
+
const result = composed('fallback' as any, 'arg2' as any)
|
|
51
|
+
expect(result).toBe('fallback')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
test('passes all arguments to each handler', () => {
|
|
56
|
+
const first = jest.fn().mockReturnValue(undefined)
|
|
57
|
+
const second = jest.fn().mockReturnValue('second')
|
|
58
|
+
const composed = composeHandlers(first, second)
|
|
59
|
+
composed('arg1' as any, 'arg2' as any)
|
|
60
|
+
expect(first).toHaveBeenCalledWith('arg1', 'arg2')
|
|
61
|
+
expect(second).toHaveBeenCalledWith('arg1', 'arg2')
|
|
62
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
jest.mock('../../../src/services/sync/manager', () => ({ default: class SyncManager {} }))
|
|
2
|
+
jest.mock('../../../src/services/sync/repository-proxy', () => ({ db: {} }))
|
|
3
|
+
import { makeContext } from './helpers/index'
|
|
4
|
+
describe('SyncContext', () => {
|
|
5
|
+
test('start calls start on all providers', () => {
|
|
6
|
+
const context = makeContext() as any
|
|
7
|
+
const providers = [
|
|
8
|
+
context.session,
|
|
9
|
+
context.connectivity,
|
|
10
|
+
context.visibility,
|
|
11
|
+
context.tabs,
|
|
12
|
+
context.durability,
|
|
13
|
+
]
|
|
14
|
+
providers.forEach(p => jest.spyOn(p, 'start'))
|
|
15
|
+
context.start()
|
|
16
|
+
providers.forEach(p => expect(p.start).toHaveBeenCalled())
|
|
17
|
+
})
|
|
18
|
+
test('stop calls stop on all providers', () => {
|
|
19
|
+
const context = makeContext() as any
|
|
20
|
+
const providers = [
|
|
21
|
+
context.session,
|
|
22
|
+
context.connectivity,
|
|
23
|
+
context.visibility,
|
|
24
|
+
context.tabs,
|
|
25
|
+
context.durability,
|
|
26
|
+
]
|
|
27
|
+
providers.forEach(p => jest.spyOn(p, 'stop'))
|
|
28
|
+
context.stop()
|
|
29
|
+
providers.forEach(p => expect(p.stop).toHaveBeenCalled())
|
|
30
|
+
})
|
|
31
|
+
test('session getter returns session provider', () => {
|
|
32
|
+
const context = makeContext()
|
|
33
|
+
expect(context.session).toBeDefined()
|
|
34
|
+
})
|
|
35
|
+
test('connectivity getter returns connectivity provider', () => {
|
|
36
|
+
const context = makeContext()
|
|
37
|
+
expect(context.connectivity).toBeDefined()
|
|
38
|
+
})
|
|
39
|
+
test('visibility getter returns visibility provider', () => {
|
|
40
|
+
const context = makeContext()
|
|
41
|
+
expect(context.visibility).toBeDefined()
|
|
42
|
+
})
|
|
43
|
+
test('tabs getter returns tabs provider', () => {
|
|
44
|
+
const context = makeContext()
|
|
45
|
+
expect(context.tabs).toBeDefined()
|
|
46
|
+
})
|
|
47
|
+
test('durability getter returns durability provider', () => {
|
|
48
|
+
const context = makeContext()
|
|
49
|
+
expect(context.durability).toBeDefined()
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { SyncError, SyncValidationError, SyncAbortError, SyncInitError, SyncSetupError, SyncUnexpectedError } from '../../../../src/services/sync/errors/index'
|
|
2
|
+
import { inBoundary } from '../../../../src/services/sync/errors/boundary'
|
|
3
|
+
import { SyncTelemetry } from '../../../../src/services/sync/telemetry/index'
|
|
4
|
+
|
|
5
|
+
jest.spyOn(SyncTelemetry, 'getInstance').mockReturnValue({
|
|
6
|
+
capture: jest.fn()
|
|
7
|
+
} as any)
|
|
8
|
+
|
|
9
|
+
describe('SyncError', () => {
|
|
10
|
+
test('has correct name property', () => {
|
|
11
|
+
const error = new SyncError('test message')
|
|
12
|
+
expect(error.name).toBe('SyncError')
|
|
13
|
+
})
|
|
14
|
+
test('isReported() returns false by default', () => {
|
|
15
|
+
const error = new SyncError('test message')
|
|
16
|
+
expect(error.isReported()).toBe(false)
|
|
17
|
+
})
|
|
18
|
+
test('markReported() sets isReported() to true', () => {
|
|
19
|
+
const error = new SyncError('test message')
|
|
20
|
+
error.markReported()
|
|
21
|
+
expect(error.isReported()).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
test('getDetails() returns details passed to constructor', () => {
|
|
24
|
+
const details = { table: 'content_progress', userId: 1 }
|
|
25
|
+
const error = new SyncError('test message', details)
|
|
26
|
+
expect(error.getDetails()).toEqual(details)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
describe('SyncValidationError', () => {
|
|
30
|
+
test('has correct name property', () => {
|
|
31
|
+
const error = new SyncValidationError('invalid value', {} as any)
|
|
32
|
+
expect(error.name).toBe('SyncValidationError')
|
|
33
|
+
})
|
|
34
|
+
test('is instanceof SyncError', () => {
|
|
35
|
+
const error = new SyncValidationError('invalid value', {} as any)
|
|
36
|
+
expect(error).toBeInstanceOf(SyncError)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
describe('SyncAbortError', () => {
|
|
40
|
+
test('has default message when none provided', () => {
|
|
41
|
+
const error = new SyncAbortError()
|
|
42
|
+
expect(error.message).toBe('Sync operation was aborted')
|
|
43
|
+
})
|
|
44
|
+
test('accepts custom message', () => {
|
|
45
|
+
const error = new SyncAbortError('custom abort message')
|
|
46
|
+
expect(error.message).toBe('custom abort message')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
describe('SyncInitError', () => {
|
|
50
|
+
test('has correct name and stores cause in details', () => {
|
|
51
|
+
const cause = new Error('original error')
|
|
52
|
+
const error = new SyncInitError(cause)
|
|
53
|
+
expect(error.name).toBe('SyncInitError')
|
|
54
|
+
expect(error.getDetails()).toEqual({ cause })
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
describe('SyncSetupError', () => {
|
|
58
|
+
test('is instanceof SyncError', () => {
|
|
59
|
+
const error = new SyncSetupError(new Error('setup failed'))
|
|
60
|
+
expect(error).toBeInstanceOf(SyncError)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
describe('SyncUnexpectedError', () => {
|
|
64
|
+
test('is instanceof SyncError', () => {
|
|
65
|
+
const error = new SyncUnexpectedError('unexpected')
|
|
66
|
+
expect(error).toBeInstanceOf(SyncError)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('inBoundary', () => {
|
|
71
|
+
let mockCapture: jest.Mock
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
mockCapture = jest.fn()
|
|
75
|
+
jest.spyOn(SyncTelemetry, 'getInstance').mockReturnValue({
|
|
76
|
+
capture: mockCapture
|
|
77
|
+
} as any)
|
|
78
|
+
})
|
|
79
|
+
test('returns result of sync function when no error', () => {
|
|
80
|
+
const result = inBoundary(() => 'hello')
|
|
81
|
+
expect(result).toBe('hello')
|
|
82
|
+
expect(mockCapture).not.toHaveBeenCalled()
|
|
83
|
+
})
|
|
84
|
+
test('throws and calls capture when sync function throws', () => {
|
|
85
|
+
const error = new Error('sync error')
|
|
86
|
+
expect(() => inBoundary(() => { throw error })).toThrow('sync error')
|
|
87
|
+
expect(mockCapture).toHaveBeenCalledWith(error, undefined)
|
|
88
|
+
})
|
|
89
|
+
test('returns resolved value of async function when no error', async () => {
|
|
90
|
+
const result = await inBoundary(async () => 'async hello')
|
|
91
|
+
expect(result).toBe('async hello')
|
|
92
|
+
expect(mockCapture).not.toHaveBeenCalled()
|
|
93
|
+
})
|
|
94
|
+
test('re-throws and calls capture when async function rejects', async () => {
|
|
95
|
+
const error = new Error('async error')
|
|
96
|
+
await expect(inBoundary(async () => { throw error })).rejects.toThrow('async error')
|
|
97
|
+
expect(mockCapture).toHaveBeenCalledWith(error, undefined)
|
|
98
|
+
})
|
|
99
|
+
test('passes context to fn and to capture on error', () => {
|
|
100
|
+
const context = { table: 'content_progress' }
|
|
101
|
+
const error = new Error('context error')
|
|
102
|
+
expect(() => inBoundary((ctx) => { throw error }, context)).toThrow('context error')
|
|
103
|
+
expect(mockCapture).toHaveBeenCalledWith(error, context)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { percent, nullableString, string, number, varchar, numberInRange, enumValue } from '../../../../src/services/sync/errors/validators'
|
|
2
|
+
import { SyncValidationError } from '../../../../src/services/sync/errors/index'
|
|
3
|
+
describe('validators', () => {
|
|
4
|
+
describe('percent', () => {
|
|
5
|
+
test('returns value when within 0-100', () => {
|
|
6
|
+
expect(percent(50)).toBe(50)
|
|
7
|
+
})
|
|
8
|
+
})
|
|
9
|
+
test('returns 0 at minimum boundary', () => {
|
|
10
|
+
expect(percent(0)).toBe(0)
|
|
11
|
+
})
|
|
12
|
+
test('returns 100 at maximum boundary', () => {
|
|
13
|
+
expect(percent(100)).toBe(100)
|
|
14
|
+
})
|
|
15
|
+
test('throws SyncValidationError when value exceeds 100', () => {
|
|
16
|
+
expect(() => percent(101)).toThrow(SyncValidationError)
|
|
17
|
+
})
|
|
18
|
+
test('throws SyncValidationError when value is negative', () => {
|
|
19
|
+
expect(() => percent(-1)).toThrow(SyncValidationError)
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
describe('nullableString', () => {
|
|
23
|
+
test('returns null when value is null', () => {
|
|
24
|
+
expect(nullableString(null)).toBeNull()
|
|
25
|
+
})
|
|
26
|
+
test('returns string when value is a string', () => {
|
|
27
|
+
expect(nullableString('hello')).toBe('hello')
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
describe('string', () => {
|
|
31
|
+
test('throws SyncValidationError when value is not a string', () => {
|
|
32
|
+
expect(() => string(123)).toThrow(SyncValidationError)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
describe('number', () => {
|
|
36
|
+
test('throws SyncValidationError when value is not a number', () => {
|
|
37
|
+
expect(() => number('abc')).toThrow(SyncValidationError)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
describe('varchar', () => {
|
|
41
|
+
test('returns string when within max length', () => {
|
|
42
|
+
expect(varchar(10)('hello')).toBe('hello')
|
|
43
|
+
})
|
|
44
|
+
test('throws SyncValidationError when string exceeds max length', () => {
|
|
45
|
+
expect(() => varchar(5)('toolong')).toThrow(SyncValidationError)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
describe('numberInRange', () => {
|
|
49
|
+
test('returns value when within range', () => {
|
|
50
|
+
expect(numberInRange(0, 10)(5)).toBe(5)
|
|
51
|
+
})
|
|
52
|
+
test('throws SyncValidationError when value exceeds max', () => {
|
|
53
|
+
expect(() => numberInRange(0, 10)(11)).toThrow(SyncValidationError)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
describe('enumValue', () => {
|
|
57
|
+
test('throws SyncValidationError when value is not in enum', () => {
|
|
58
|
+
enum TestEnum { A = 'a', B = 'b' }
|
|
59
|
+
expect(() => enumValue(TestEnum)('c')).toThrow(SyncValidationError)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
jest.mock('../../../../src/services/sync/manager', () => ({ default: class SyncManager {} }))
|
|
2
|
+
jest.mock('../../../../src/services/sync/repository-proxy', () => ({ db: {} }))
|
|
3
|
+
|
|
4
|
+
import { Database } from '@nozbe/watermelondb'
|
|
5
|
+
import { makeDatabase, makeStore, resetDatabase } from '../helpers/index'
|
|
6
|
+
import UserAwardProgress from '../../../../src/services/sync/models/UserAwardProgress'
|
|
7
|
+
import UserAwardProgressRepository from '../../../../src/services/sync/repositories/user-award-progress'
|
|
8
|
+
let db: Database
|
|
9
|
+
let repo: UserAwardProgressRepository
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
db = makeDatabase()
|
|
12
|
+
const { store } = makeStore(UserAwardProgress, db)
|
|
13
|
+
repo = new UserAwardProgressRepository(store)
|
|
14
|
+
})
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await resetDatabase(db)
|
|
17
|
+
})
|
|
18
|
+
describe('UserAwardProgress model', () => {
|
|
19
|
+
describe('getters', () => {
|
|
20
|
+
test('award_id returns raw string value', async () => {
|
|
21
|
+
await repo.recordAwardProgress('test-award-id', 50)
|
|
22
|
+
const result = await repo.getByAwardId('test-award-id')
|
|
23
|
+
expect(result.data?.award_id).toBe('test-award-id')
|
|
24
|
+
})
|
|
25
|
+
test('progress_percentage returns raw number value', async () => {
|
|
26
|
+
await repo.recordAwardProgress('test-award-id', 75)
|
|
27
|
+
const result = await repo.getByAwardId('test-award-id')
|
|
28
|
+
expect(result.data?.progress_percentage).toBe(75)
|
|
29
|
+
})
|
|
30
|
+
test('completed_at returns null when not set', async () => {
|
|
31
|
+
await repo.recordAwardProgress('test-award-id', 50)
|
|
32
|
+
const result = await repo.getByAwardId('test-award-id')
|
|
33
|
+
expect(result.data?.completed_at).toBeNull()
|
|
34
|
+
})
|
|
35
|
+
test('progress_data parses JSON when set', async () => {
|
|
36
|
+
const progressData = { step: 1, total: 5 }
|
|
37
|
+
await repo.recordAwardProgress('test-award-id', 50, { progressData })
|
|
38
|
+
const result = await repo.getByAwardId('test-award-id')
|
|
39
|
+
expect(result.data?.progress_data).toEqual(progressData)
|
|
40
|
+
})
|
|
41
|
+
test('progress_data returns null when not set', async () => {
|
|
42
|
+
await repo.recordAwardProgress('test-award-id', 50)
|
|
43
|
+
const result = await repo.getByAwardId('test-award-id')
|
|
44
|
+
expect(result.data?.progress_data).toBeNull()
|
|
45
|
+
})
|
|
46
|
+
test('completion_data parses JSON when set', async () => {
|
|
47
|
+
const completionData = {
|
|
48
|
+
content_title: 'Blues Foundations',
|
|
49
|
+
completed_at: '2024-01-01T00:00:00Z',
|
|
50
|
+
days_user_practiced: 14,
|
|
51
|
+
practice_minutes: 180,
|
|
52
|
+
}
|
|
53
|
+
await repo.recordAwardProgress('test-award-id', 100, { completionData })
|
|
54
|
+
const result = await repo.getByAwardId('test-award-id')
|
|
55
|
+
expect(result.data?.completion_data).toEqual(completionData)
|
|
56
|
+
})
|
|
57
|
+
test('completion_data returns null when not set', async () => {
|
|
58
|
+
await repo.recordAwardProgress('test-award-id', 50)
|
|
59
|
+
const result = await repo.getByAwardId('test-award-id')
|
|
60
|
+
expect(result.data?.completion_data).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
describe('setters via validators', () => {
|
|
64
|
+
test.todo('progress_percentage throws SyncValidationError when value exceeds 100')
|
|
65
|
+
test('completed_at accepts null', async () => {
|
|
66
|
+
await repo.recordAwardProgress('test-award-id', 50, { completedAt: null })
|
|
67
|
+
const result = await repo.getByAwardId('test-award-id')
|
|
68
|
+
expect(result.data?.completed_at).toBeNull()
|
|
69
|
+
})
|
|
70
|
+
test('completion_data serialises to JSON when set', async () => {
|
|
71
|
+
const completionData = {
|
|
72
|
+
content_title: 'Test',
|
|
73
|
+
completed_at: '2024-01-01T00:00:00Z',
|
|
74
|
+
days_user_practiced: 5,
|
|
75
|
+
practice_minutes: 60,
|
|
76
|
+
}
|
|
77
|
+
await repo.recordAwardProgress('test-award-id', 100, { completionData })
|
|
78
|
+
const result = await repo.getByAwardId('test-award-id')
|
|
79
|
+
expect(result.data?.completion_data).toEqual(completionData)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
jest.mock('../../../../src/services/sync/manager', () => ({ default: class SyncManager {} }))
|
|
2
|
+
jest.mock('../../../../src/services/sync/repository-proxy', () => ({ db: {} }))
|
|
3
|
+
|
|
4
|
+
import UserAwardProgressRepository from '../../../../src/services/sync/repositories/user-award-progress'
|
|
5
|
+
|
|
6
|
+
describe('UserAwardProgressRepository static methods', () => {
|
|
7
|
+
describe('isCompleted', () => {
|
|
8
|
+
test('returns true when completed_at is set and progress_percentage is 100', () => {
|
|
9
|
+
const result = UserAwardProgressRepository.isCompleted({
|
|
10
|
+
completed_at: '2024-01-01T00:00:00Z',
|
|
11
|
+
progress_percentage: 100,
|
|
12
|
+
})
|
|
13
|
+
expect(result).toBe(true)
|
|
14
|
+
})
|
|
15
|
+
test('returns false when completed_at is null', () => {
|
|
16
|
+
const result = UserAwardProgressRepository.isCompleted({
|
|
17
|
+
completed_at: null,
|
|
18
|
+
progress_percentage: 100,
|
|
19
|
+
})
|
|
20
|
+
expect(result).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
test('returns false when progress_percentage is less than 100', () => {
|
|
23
|
+
const result = UserAwardProgressRepository.isCompleted({
|
|
24
|
+
completed_at: '2024-01-01T00:00:00Z',
|
|
25
|
+
progress_percentage: 50,
|
|
26
|
+
})
|
|
27
|
+
expect(result).toBe(false)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
describe('isInProgress', () => {
|
|
31
|
+
test('returns true when progress is greater than 0 and not completed', () => {
|
|
32
|
+
const result = UserAwardProgressRepository.isInProgress({
|
|
33
|
+
completed_at: null,
|
|
34
|
+
progress_percentage: 50,
|
|
35
|
+
})
|
|
36
|
+
expect(result).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
test('returns false when award is completed', () => {
|
|
39
|
+
const result = UserAwardProgressRepository.isInProgress({
|
|
40
|
+
completed_at: '2024-01-01T00:00:00Z',
|
|
41
|
+
progress_percentage: 100,
|
|
42
|
+
})
|
|
43
|
+
expect(result).toBe(false)
|
|
44
|
+
})
|
|
45
|
+
test('returns true when progress is 0 and not completed', () => {
|
|
46
|
+
const result = UserAwardProgressRepository.isInProgress({
|
|
47
|
+
completed_at: null,
|
|
48
|
+
progress_percentage: 0,
|
|
49
|
+
})
|
|
50
|
+
expect(result).toBe(true)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
describe('completedAtDate', () => {
|
|
54
|
+
test('returns a Date object when completed_at is set', () => {
|
|
55
|
+
const result = UserAwardProgressRepository.completedAtDate({
|
|
56
|
+
completed_at: '2024-01-01T00:00:00Z',
|
|
57
|
+
})
|
|
58
|
+
expect(result).toBeInstanceOf(Date)
|
|
59
|
+
expect(result?.toISOString()).toBe('2024-01-01T00:00:00.000Z')
|
|
60
|
+
})
|
|
61
|
+
test('returns null when completed_at is null', () => {
|
|
62
|
+
const result = UserAwardProgressRepository.completedAtDate({
|
|
63
|
+
completed_at: null,
|
|
64
|
+
})
|
|
65
|
+
expect(result).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
})
|