Skip to content

Commit cd9f16c

Browse files
add unit + integration coverage for Course Builder slug gating (#1550)
* chore: add unit tests for Course Builder slug gating [task:beginners-guide-to-react-broken-lesson-titles] Add tests to ensure Course Builder DB queries only use LIKE matching when a slug contains a "~<id>" suffix, preventing legacy slugs from accidentally matching and overwriting titles. * chore: add integration tests for legacy title stability [task:beginners-guide-to-react-broken-lesson-titles] Covers the full loadLesson and loadResourcesForCourse flows with mocked GraphQL, Sanity, and Course Builder DB. Ensures legacy slugs never trigger LIKE-based Course Builder matching and titles cannot be overwritten by unrelated records. * feat(playlists): increase per_page limit to 200 and set revalidation for courses to 3600 seconds Updated the loadAllPlaylistsByPage function to fetch 200 playlists per request instead of 25. Additionally, modified the getStaticProps function in the courses index to revalidate every hour, improving data freshness. * refactor(courses): remove revalidation setting from getStaticProps Eliminated the revalidation parameter from the getStaticProps function in the courses index, simplifying the data fetching logic. * fix(posts): add error handling for legacy lesson data fetch Enhanced the getStaticProps function to include error handling when fetching legacy lesson data, ensuring better resilience and logging of fetch failures. * Update index.tsx --------- Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
1 parent 3b2099e commit cd9f16c

File tree

5 files changed

+413
-5
lines changed

5 files changed

+413
-5
lines changed
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
describe('integration: loadResourcesForCourse (ordering + title correctness)', () => {
2+
const originalEnv = process.env
3+
4+
beforeEach(() => {
5+
jest.resetModules()
6+
process.env = {...originalEnv}
7+
process.env.COURSE_BUILDER_DATABASE_URL = 'mysql://example'
8+
})
9+
10+
afterEach(() => {
11+
process.env = originalEnv
12+
jest.restoreAllMocks()
13+
})
14+
15+
test('returns ordered lessons with correct titles for legacy course slugs', async () => {
16+
const execute = jest.fn(async (sql: string) => {
17+
// As above: only return bogus data if LIKE is used (should not happen)
18+
if (String(sql).includes('LIKE')) {
19+
return [
20+
[
21+
{
22+
id: 'post_zzzzz',
23+
type: 'post',
24+
fields: JSON.stringify({
25+
slug: 'unrelated-course-builder-post~zzzzz',
26+
title: 'WRONG TITLE FROM COURSE BUILDER',
27+
body: 'wrong',
28+
}),
29+
video_fields: null,
30+
},
31+
],
32+
] as any
33+
}
34+
return [[]] as any
35+
})
36+
37+
const release = jest.fn()
38+
const getConnection = jest.fn(async () => ({execute, release}))
39+
40+
jest.doMock('mysql2/promise', () => ({
41+
__esModule: true,
42+
createPool: jest.fn(() => ({getConnection})),
43+
}))
44+
45+
const request = jest.fn(async (query: string, variables: any) => {
46+
const q = String(query)
47+
if (q.includes('query getPlaylistLessonSlugs')) {
48+
return {
49+
playlist: {
50+
id: 999,
51+
slug: variables.slug,
52+
items: [
53+
{
54+
__typename: 'Lesson',
55+
slug: 'legacy-lesson-1',
56+
path: '/lessons/legacy-lesson-1',
57+
},
58+
{
59+
__typename: 'Lesson',
60+
slug: 'legacy-lesson-2',
61+
path: '/lessons/legacy-lesson-2',
62+
},
63+
],
64+
},
65+
}
66+
}
67+
68+
if (q.includes('query getLesson')) {
69+
const slug = variables.slug
70+
return {
71+
lesson: {
72+
id: 123,
73+
completed: false,
74+
slug,
75+
title:
76+
slug === 'legacy-lesson-1'
77+
? 'Legacy Lesson One Title'
78+
: 'Legacy Lesson Two Title',
79+
description: '',
80+
duration: 60,
81+
free_forever: true,
82+
path: `/lessons/${slug}`,
83+
transcript: null,
84+
transcript_url: null,
85+
subtitles_url: null,
86+
hls_url: null,
87+
dash_url: null,
88+
http_url: null,
89+
lesson_view_url: null,
90+
thumb_url: null,
91+
icon_url: null,
92+
download_url: null,
93+
staff_notes_url: null,
94+
state: 'published',
95+
repo_url: null,
96+
code_url: null,
97+
primary_tag: {
98+
name: 'react',
99+
label: 'React',
100+
http_url: '',
101+
image_url: '',
102+
},
103+
created_at: null,
104+
updated_at: null,
105+
published_at: null,
106+
collection: null,
107+
tags: [],
108+
instructor: {
109+
full_name: 'Someone',
110+
avatar_64_url: '',
111+
slug: 'someone',
112+
twitter: '',
113+
},
114+
},
115+
}
116+
}
117+
118+
throw new Error(`Unexpected GraphQL query in test: ${q}`)
119+
})
120+
121+
jest.doMock('@/utils/configured-graphql-client', () => ({
122+
__esModule: true,
123+
getGraphQLClient: () => ({request}),
124+
}))
125+
126+
jest.doMock('@/utils/sanity-client', () => ({
127+
__esModule: true,
128+
sanityClient: {fetch: jest.fn(async () => ({}))},
129+
}))
130+
131+
jest.doMock('@/lib/lesson-comments', () => ({
132+
__esModule: true,
133+
loadLessonComments: jest.fn(async () => []),
134+
}))
135+
136+
jest.spyOn(console, 'log').mockImplementation(() => {})
137+
jest.spyOn(console, 'warn').mockImplementation(() => {})
138+
jest.spyOn(console, 'debug').mockImplementation(() => {})
139+
jest.spyOn(console, 'error').mockImplementation(() => {})
140+
141+
const {loadResourcesForCourse} = await import('../course-resources')
142+
143+
const lessons = await loadResourcesForCourse({
144+
slug: 'the-beginner-s-guide-to-react',
145+
})
146+
147+
expect(lessons.map((l) => l.slug)).toEqual([
148+
'legacy-lesson-1',
149+
'legacy-lesson-2',
150+
])
151+
expect(lessons.map((l) => l.title)).toEqual([
152+
'Legacy Lesson One Title',
153+
'Legacy Lesson Two Title',
154+
])
155+
156+
// Two lessons loaded => two Course Builder lookups, neither should use LIKE
157+
expect(execute).toHaveBeenCalledTimes(2)
158+
for (const call of execute.mock.calls) {
159+
expect(String(call[0])).not.toContain('LIKE')
160+
}
161+
})
162+
})
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
describe('get-course-builder-metadata query gating', () => {
2+
const originalEnv = process.env
3+
4+
beforeEach(() => {
5+
jest.resetModules()
6+
process.env = {...originalEnv}
7+
process.env.COURSE_BUILDER_DATABASE_URL = 'mysql://example'
8+
})
9+
10+
afterEach(() => {
11+
process.env = originalEnv
12+
jest.restoreAllMocks()
13+
})
14+
15+
test('legacy lesson slug uses exact match only (no LIKE)', async () => {
16+
const execute = jest.fn(async (sql: string, params: any[]) => {
17+
return [[]] as any
18+
})
19+
const release = jest.fn()
20+
const getConnection = jest.fn(async () => ({execute, release}))
21+
22+
jest.doMock('mysql2/promise', () => ({
23+
__esModule: true,
24+
createPool: jest.fn(() => ({getConnection})),
25+
}))
26+
27+
jest.spyOn(console, 'warn').mockImplementation(() => {})
28+
jest.spyOn(console, 'log').mockImplementation(() => {})
29+
30+
const {getCourseBuilderLesson} = await import(
31+
'../get-course-builder-metadata'
32+
)
33+
34+
const legacySlug = 'the-beginner-s-guide-to-react'
35+
const result = await getCourseBuilderLesson(legacySlug)
36+
37+
expect(result).toBeNull()
38+
expect(execute).toHaveBeenCalledTimes(1)
39+
const [sql, params] = execute.mock.calls[0]
40+
expect(String(sql)).not.toContain('LIKE')
41+
expect(params).toEqual([legacySlug, legacySlug])
42+
expect(release).toHaveBeenCalledTimes(1)
43+
})
44+
45+
test('course builder lesson slug (~id) can use LIKE matching', async () => {
46+
const execute = jest.fn(async (sql: string, params: any[]) => {
47+
return [[]] as any
48+
})
49+
const release = jest.fn()
50+
const getConnection = jest.fn(async () => ({execute, release}))
51+
52+
jest.doMock('mysql2/promise', () => ({
53+
__esModule: true,
54+
createPool: jest.fn(() => ({getConnection})),
55+
}))
56+
57+
jest.spyOn(console, 'warn').mockImplementation(() => {})
58+
jest.spyOn(console, 'log').mockImplementation(() => {})
59+
60+
const {getCourseBuilderLesson} = await import(
61+
'../get-course-builder-metadata'
62+
)
63+
64+
const cbSlug = 'some-course-title~duu9m'
65+
const result = await getCourseBuilderLesson(cbSlug)
66+
67+
expect(result).toBeNull()
68+
expect(execute).toHaveBeenCalledTimes(1)
69+
const [sql, params] = execute.mock.calls[0]
70+
expect(String(sql)).toContain('LIKE')
71+
expect(params).toEqual([cbSlug, cbSlug, '%duu9m', '%duu9m'])
72+
expect(release).toHaveBeenCalledTimes(1)
73+
})
74+
75+
test('legacy course slug uses exact match only (no LIKE) for course metadata', async () => {
76+
const execute = jest.fn(async (sql: string, params: any[]) => {
77+
return [[]] as any
78+
})
79+
const release = jest.fn()
80+
const getConnection = jest.fn(async () => ({execute, release}))
81+
82+
jest.doMock('mysql2/promise', () => ({
83+
__esModule: true,
84+
createPool: jest.fn(() => ({getConnection})),
85+
}))
86+
87+
jest.spyOn(console, 'warn').mockImplementation(() => {})
88+
jest.spyOn(console, 'log').mockImplementation(() => {})
89+
jest.spyOn(console, 'error').mockImplementation(() => {})
90+
91+
const {loadCourseBuilderCourseMetadata} = await import(
92+
'../get-course-builder-metadata'
93+
)
94+
95+
const legacyCourseSlug =
96+
'fundamentals-of-redux-course-from-dan-abramov-bd5cc867'
97+
const result = await loadCourseBuilderCourseMetadata(legacyCourseSlug)
98+
99+
expect(result).toBeNull()
100+
expect(execute).toHaveBeenCalledTimes(1)
101+
const [sql, params] = execute.mock.calls[0]
102+
expect(String(sql)).not.toContain('LIKE')
103+
expect(params).toEqual([legacyCourseSlug, legacyCourseSlug])
104+
expect(release).toHaveBeenCalledTimes(1)
105+
})
106+
})
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
describe('integration: loadLesson (title correctness)', () => {
2+
const originalEnv = process.env
3+
4+
beforeEach(() => {
5+
jest.resetModules()
6+
process.env = {...originalEnv}
7+
process.env.COURSE_BUILDER_DATABASE_URL = 'mysql://example'
8+
})
9+
10+
afterEach(() => {
11+
process.env = originalEnv
12+
jest.restoreAllMocks()
13+
})
14+
15+
test('legacy slug title is not overwritten by unrelated Course Builder records', async () => {
16+
const execute = jest.fn(async (sql: string) => {
17+
// If legacy slug ever triggers LIKE matching again, return a bogus record
18+
// that would overwrite the title. With the gating fix, LIKE should never
19+
// be used for legacy slugs and this should return no rows.
20+
if (String(sql).includes('LIKE')) {
21+
return [
22+
[
23+
{
24+
id: 'post_zzzzz',
25+
type: 'post',
26+
fields: JSON.stringify({
27+
slug: 'unrelated-course-builder-post~zzzzz',
28+
title: 'WRONG TITLE FROM COURSE BUILDER',
29+
body: 'wrong',
30+
}),
31+
video_fields: null,
32+
},
33+
],
34+
] as any
35+
}
36+
return [[]] as any
37+
})
38+
39+
const release = jest.fn()
40+
const getConnection = jest.fn(async () => ({execute, release}))
41+
42+
jest.doMock('mysql2/promise', () => ({
43+
__esModule: true,
44+
createPool: jest.fn(() => ({getConnection})),
45+
}))
46+
47+
const request = jest.fn(async (query: string, variables: any) => {
48+
if (String(query).includes('query getLesson')) {
49+
return {
50+
lesson: {
51+
id: 123,
52+
completed: false,
53+
slug: variables.slug,
54+
title: 'Correct Legacy Lesson Title',
55+
description: 'Correct legacy description',
56+
duration: 60,
57+
free_forever: true,
58+
path: `/lessons/${variables.slug}`,
59+
transcript: null,
60+
transcript_url: null,
61+
subtitles_url: null,
62+
hls_url: null,
63+
dash_url: null,
64+
http_url: null,
65+
lesson_view_url: null,
66+
thumb_url: null,
67+
icon_url: null,
68+
download_url: null,
69+
staff_notes_url: null,
70+
state: 'published',
71+
repo_url: null,
72+
code_url: null,
73+
primary_tag: {
74+
name: 'react',
75+
label: 'React',
76+
http_url: '',
77+
image_url: '',
78+
},
79+
created_at: null,
80+
updated_at: null,
81+
published_at: null,
82+
collection: null,
83+
tags: [],
84+
instructor: {
85+
full_name: 'Someone',
86+
avatar_64_url: '',
87+
slug: 'someone',
88+
twitter: '',
89+
},
90+
},
91+
}
92+
}
93+
throw new Error(`Unexpected GraphQL query in test: ${String(query)}`)
94+
})
95+
96+
jest.doMock('@/utils/configured-graphql-client', () => ({
97+
__esModule: true,
98+
getGraphQLClient: () => ({request}),
99+
}))
100+
101+
jest.doMock('@/utils/sanity-client', () => ({
102+
__esModule: true,
103+
sanityClient: {fetch: jest.fn(async () => ({}))},
104+
}))
105+
106+
jest.doMock('@/lib/lesson-comments', () => ({
107+
__esModule: true,
108+
loadLessonComments: jest.fn(async () => []),
109+
}))
110+
111+
jest.spyOn(console, 'log').mockImplementation(() => {})
112+
jest.spyOn(console, 'warn').mockImplementation(() => {})
113+
jest.spyOn(console, 'error').mockImplementation(() => {})
114+
115+
const {loadLesson} = await import('../lessons')
116+
117+
const legacySlug = 'the-beginner-s-guide-to-react'
118+
const lesson = await loadLesson(legacySlug)
119+
120+
expect(lesson.title).toBe('Correct Legacy Lesson Title')
121+
expect(lesson.slug).toBe(legacySlug)
122+
123+
// Ensure mysql executed but did not run LIKE-based query for legacy slugs
124+
expect(execute).toHaveBeenCalledTimes(1)
125+
const [sql] = execute.mock.calls[0]
126+
expect(String(sql)).not.toContain('LIKE')
127+
})
128+
})

0 commit comments

Comments
 (0)