Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
0b131f6
feat: add idx_created_at and idx_updated_at properties for Algolia in…
mo-hossam-stack Sep 9, 2026
0bdf8b2
feat: add ProgramIndex replica configuration to Algolia update command
mo-hossam-stack Sep 9, 2026
4d4788a
feat: update ProgramIndex to include idx_created_at and idx_updated_a…
mo-hossam-stack Sep 9, 2026
b6e4ef6
feat: add tests for idx_created_at and idx_updated_at properties in P…
mo-hossam-stack Sep 9, 2026
0404b86
feat: implement sorting functionality for mentorship programs with dr…
mo-hossam-stack Sep 9, 2026
5935c06
fix: return None for unset program index timestamps
mo-hossam-stack Sep 9, 2026
0c7919f
fix: reset search page on filter changes and ignore stale responses
mo-hossam-stack Sep 9, 2026
bd5b69f
test: assert sorted URL in mentorship programs e2e
mo-hossam-stack Sep 9, 2026
1fb218d
fix: ignore stale search failures and redundant url pushes
mo-hossam-stack Sep 9, 2026
364c884
test: assert rendered results and back/forward in programs sort e2e
mo-hossam-stack Sep 9, 2026
f52b7df
Merge branch 'main' into feat/mentorship-programs-sort
mo-hossam-stack Sep 11, 2026
94760be
Merge branch 'main' into feat/mentorship-programs-sort
mo-hossam-stack Sep 13, 2026
967d431
Merge remote-tracking branch 'upstream/main' into feat/mentorship-pro…
mo-hossam-stack Sep 14, 2026
147efa8
fix: reject partially numeric page params in useSearchPage
mo-hossam-stack Sep 14, 2026
f5e234b
fix: guard page params against overflow to Infinity
mo-hossam-stack Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from django.core.management.base import BaseCommand

from apps.mentorship.index import ProgramIndex
from apps.owasp.index import ChapterIndex, ProjectIndex


Expand All @@ -12,5 +13,6 @@ def handle(self, *_args, **_options) -> None:
"""Update replicas for Algolia indices."""
self.stdout.write("\n Starting replica configuration...\n")
ChapterIndex.configure_replicas()
ProgramIndex.configure_replicas()
ProjectIndex.configure_replicas()
self.stdout.write(self.style.SUCCESS("\n Replicas have been successfully created.\n"))
18 changes: 18 additions & 0 deletions backend/src/apps/mentorship/index/registry/program.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ class ProgramIndex(IndexBase):
index_name = "programs"

fields = (
"idx_created_at",
"idx_description",
"idx_ended_at",
"idx_experience_levels",
"idx_key",
"idx_name",
"idx_started_at",
"idx_status",
"idx_updated_at",
)

settings = {
Expand Down Expand Up @@ -45,6 +47,22 @@ class ProgramIndex(IndexBase):

should_index = "is_indexable"

@staticmethod
def configure_replicas() -> None: # type: ignore[override]
"""Configure the settings for program replicas."""
replicas = {
"name_asc": ["asc(idx_name)"],
"name_desc": ["desc(idx_name)"],
"created_at_asc": ["asc(idx_created_at)"],
"created_at_desc": ["desc(idx_created_at)"],
"updated_at_asc": ["asc(idx_updated_at)"],
"updated_at_desc": ["desc(idx_updated_at)"],
"ended_at_asc": ["asc(idx_ended_at)"],
"ended_at_desc": ["desc(idx_ended_at)"],
}

IndexBase.configure_replicas("programs", replicas)

def get_entities(self):
"""Return only published programs for indexing."""
return Program.objects.filter(status=Program.ProgramStatus.PUBLISHED)
10 changes: 10 additions & 0 deletions backend/src/apps/mentorship/models/mixins/program.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,21 @@ def idx_key(self) -> str:
"""Unique key for Algolia indexing."""
return self.key

@property
def idx_created_at(self) -> str | None:
"""Formatted created datetime for sorting."""
return self.nest_created_at.isoformat() if self.nest_created_at else None

@property
def idx_status(self) -> str:
"""Status for Algolia indexing."""
return self.status

@property
def idx_updated_at(self) -> str | None:
"""Formatted updated datetime for sorting."""
return self.nest_updated_at.isoformat() if self.nest_updated_at else None

@property
def idx_description(self) -> str:
"""Description for Algolia indexing."""
Expand Down
22 changes: 22 additions & 0 deletions backend/tests/unit/apps/mentorship/model/program_mixin_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,28 @@ def test_idx_started_at_none(self):
mock = self._make_program_mock(started_at=None)
assert ProgramIndexMixin.idx_started_at.fget(mock) is None

def test_idx_created_at(self):
"""Test idx_created_at returns ISO formatted created datetime."""
created = datetime(2025, 1, 1, 9, 0, 0, tzinfo=UTC)
mock = self._make_program_mock(nest_created_at=created)
assert ProgramIndexMixin.idx_created_at.fget(mock) == created.isoformat()

def test_idx_created_at_none(self):
"""Test idx_created_at returns None when nest_created_at is None."""
mock = self._make_program_mock(nest_created_at=None)
assert ProgramIndexMixin.idx_created_at.fget(mock) is None

def test_idx_updated_at(self):
"""Test idx_updated_at returns ISO formatted updated datetime."""
updated = datetime(2025, 6, 15, 12, 30, 0, tzinfo=UTC)
mock = self._make_program_mock(nest_updated_at=updated)
assert ProgramIndexMixin.idx_updated_at.fget(mock) == updated.isoformat()

def test_idx_updated_at_none(self):
"""Test idx_updated_at returns None when nest_updated_at is None."""
mock = self._make_program_mock(nest_updated_at=None)
assert ProgramIndexMixin.idx_updated_at.fget(mock) is None

def test_idx_ended_at(self):
"""Test idx_ended_at returns ISO formatted end datetime."""
ended = datetime(2025, 12, 31, 18, 0, 0, tzinfo=UTC)
Expand Down
32 changes: 32 additions & 0 deletions e2e/pages/MentorshipPrograms.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,38 @@ test.describe('Mentorship Programs Page', () => {
await expect(page.getByPlaceholder('Search for programs...')).toBeVisible()
})

test('sort dropdown exposes all sort options', async ({ page }) => {
await page.getByRole('button', { name: 'Sort by' }).click()
for (const option of ['Relevancy', 'Name', 'Date Created', 'Last Updated', 'End Date']) {
await expect(page.getByRole('option', { name: option })).toBeVisible()
}
})

test('sorting by name requests the name replica index', async ({ page }) => {
const requestedIndexes: string[] = []
page.on('request', (request) => {
if (request.url().includes('/idx/')) {
requestedIndexes.push(request.postDataJSON()?.indexName)
}
})

await page.getByRole('button', { name: 'Sort by' }).click()
await page.getByRole('option', { name: 'Name' }).click()
await expect.poll(() => requestedIndexes).toContain('programs_name_desc')

await page.getByRole('button', { name: /Sort in descending order/i }).click()
await expect.poll(() => requestedIndexes).toContain('programs_name_asc')

await expect(page).toHaveURL(/sortBy=name/)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
await expect(page.getByRole('heading', { name: 'Program 1' })).toBeVisible()

await page.goBack()
await expect(page).toHaveURL(/sortBy=name&order=desc/)

await page.goForward()
await expect(page).toHaveURL(/sortBy=name&order=asc/)
})

test('displays "No programs found" when there are no programs', async ({ page }) => {
await page.route('**/idx/', async (route) => {
await route.fulfill({
Expand Down
178 changes: 178 additions & 0 deletions frontend/__tests__/unit/hooks/useSearchPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ jest.mock('app/global-error', () => ({
}))

const mockFetchAlgoliaData = fetchAlgoliaData as jest.Mock
const mockHandleAppError = jest.requireMock('app/global-error').handleAppError as jest.Mock
const mockUseSearchParams = useSearchParams as jest.Mock
const mockUseRouter = useRouter as jest.Mock

Expand All @@ -23,6 +24,14 @@ const defaultOptions = {
defaultOrder: 'desc',
} as const

const programsOptions = {
indexName: 'programs',
pageTitle: 'OWASP Programs',
defaultSortBy: 'default',
defaultOrder: 'desc',
hitsPerPage: 24,
} as const

describe('useSearchPage', () => {
const push = jest.fn()

Expand Down Expand Up @@ -63,6 +72,7 @@ describe('useSearchPage', () => {
hits: [{ objectID: '1' }],
totalPages: 5,
})
window.scrollTo = jest.fn()
})

it('preserves the page query param on initial load', async () => {
Expand Down Expand Up @@ -221,6 +231,27 @@ describe('useSearchPage', () => {
await waitFor(() => {
expect(result.current.currentPage).toBe(1)
})

mockUseSearchParams.mockReturnValue(new URLSearchParams('page=2foo'))
rerender()

await waitFor(() => {
expect(result.current.currentPage).toBe(1)
})

mockUseSearchParams.mockReturnValue(new URLSearchParams(`page=${'9'.repeat(400)}`))
rerender()

await waitFor(() => {
expect(result.current.currentPage).toBe(1)
})

mockUseSearchParams.mockReturnValue(new URLSearchParams('page=12'))
rerender()

await waitFor(() => {
expect(result.current.currentPage).toBe(12)
})
})

it('still pushes URL updates for user-driven page changes after back/forward sync', async () => {
Expand Down Expand Up @@ -320,4 +351,151 @@ describe('useSearchPage', () => {
expect(push).toHaveBeenCalledWith('?q=nest')
})
})

it('resets to page 1 and fetches the replica index when sort changes on a later page', async () => {
mockUseSearchParams.mockReturnValue(new URLSearchParams())

const { result } = renderHook(() => useSearchPage<{ key: string }>(programsOptions))

await waitFor(() => {
expect(mockFetchAlgoliaData).toHaveBeenCalledTimes(1)
})
expect(push).not.toHaveBeenCalled()

act(() => {
result.current.handlePageChange(2)
})

await waitFor(() => {
expect(mockFetchAlgoliaData).toHaveBeenLastCalledWith('programs', '', 2, 24, [])
})
expect(push).toHaveBeenLastCalledWith('?page=2')

act(() => {
result.current.handleSortChange('name')
})

await waitFor(() => {
expect(mockFetchAlgoliaData).toHaveBeenLastCalledWith('programs_name_desc', '', 1, 24, [])
})
expect(mockFetchAlgoliaData).not.toHaveBeenCalledWith('programs_name_desc', '', 2, 24, [])
expect(result.current.sortBy).toBe('name')
expect(result.current.currentPage).toBe(1)
})

it('adopts sort and order params from the URL when it changes', async () => {
mockUseSearchParams.mockReturnValue(new URLSearchParams('sortBy=name&order=desc'))

const { result, rerender } = renderHook(() => useSearchPage<{ key: string }>(programsOptions))

await waitFor(() => {
expect(result.current.sortBy).toBe('name')
})
expect(result.current.order).toBe('desc')
await waitFor(() => {
expect(mockFetchAlgoliaData).toHaveBeenLastCalledWith('programs_name_desc', '', 1, 24, [])
})
expect(push).not.toHaveBeenCalled()

mockUseSearchParams.mockReturnValue(new URLSearchParams())
rerender()

await waitFor(() => {
expect(result.current.sortBy).toBe('default')
})
expect(result.current.order).toBe('desc')
await waitFor(() => {
expect(mockFetchAlgoliaData).toHaveBeenLastCalledWith('programs', '', 1, 24, [])
})
expect(push).not.toHaveBeenCalled()

await act(async () => {
result.current.handleSortChange('date_created')
})
expect(push).toHaveBeenCalledWith('?sortBy=date_created&order=desc')
expect(result.current.sortBy).toBe('date_created')
})

it('ignores stale responses superseded by a newer request', async () => {
let resolveInitial!: (value: { hits: { key: string }[]; totalPages: number }) => void
let resolveNewer!: (value: { hits: { key: string }[]; totalPages: number }) => void
const initial = new Promise<{ hits: { key: string }[]; totalPages: number }>((resolve) => {
resolveInitial = resolve
})
const newer = new Promise<{ hits: { key: string }[]; totalPages: number }>((resolve) => {
resolveNewer = resolve
})

mockFetchAlgoliaData.mockReturnValueOnce(initial).mockReturnValueOnce(newer)
mockUseSearchParams.mockReturnValue(new URLSearchParams())

const { result } = renderHook(() => useSearchPage<{ key: string }>(programsOptions))
await waitFor(() => {
expect(mockFetchAlgoliaData).toHaveBeenCalledTimes(1)
})

act(() => {
result.current.handleSearch('owasp')
})
await waitFor(() => {
expect(mockFetchAlgoliaData).toHaveBeenCalledTimes(2)
})

await act(async () => {
resolveNewer({ hits: [{ key: 'new' }], totalPages: 1 })
})
await waitFor(() => {
expect(result.current.items).toEqual([{ key: 'new' }])
})

await act(async () => {
resolveInitial({ hits: [{ key: 'stale' }], totalPages: 1 })
})
await waitFor(() => {
expect(result.current.items).toEqual([{ key: 'new' }])
})
})

it('ignores stale failures superseded by a newer request', async () => {
let rejectInitial!: (reason?: object) => void
let resolveNewer!: (value: { hits: { key: string }[]; totalPages: number }) => void
const initial = new Promise<{ hits: { key: string }[]; totalPages: number }>(
(_resolve, reject) => {
rejectInitial = reject
}
)
const newer = new Promise<{ hits: { key: string }[]; totalPages: number }>((resolve) => {
resolveNewer = resolve
})

mockFetchAlgoliaData.mockReturnValueOnce(initial).mockReturnValueOnce(newer)
mockUseSearchParams.mockReturnValue(new URLSearchParams())

const { result } = renderHook(() => useSearchPage<{ key: string }>(programsOptions))
await waitFor(() => {
expect(mockFetchAlgoliaData).toHaveBeenCalledTimes(1)
})

act(() => {
result.current.handleSearch('owasp')
})
await waitFor(() => {
expect(mockFetchAlgoliaData).toHaveBeenCalledTimes(2)
})

await act(async () => {
resolveNewer({ hits: [{ key: 'new' }], totalPages: 1 })
})
await waitFor(() => {
expect(result.current.items).toEqual([{ key: 'new' }])
})
expect(result.current.isLoaded).toBe(true)

await act(async () => {
rejectInitial(new Error('stale failure'))
})
expect(mockHandleAppError).not.toHaveBeenCalled()
expect(result.current.items).toEqual([{ key: 'new' }])
expect(result.current.isLoaded).toBe(true)
})
})
Loading