From 73da203d0794e20af7aff31a0b68e1cc9bdfee91 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:38:24 +0000 Subject: [PATCH 1/9] fix(projects): load every project page in the year list The projects page only requested the first API page, so years with more than 50 projects hid the rest. Walk nextCursor until the full filtered list is loaded. Co-Authored-By: Trevor Elkins --- src/app/queries/projects.ts | 30 ++++++++++++++++---- test/app/routes.test.tsx | 55 +++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/app/queries/projects.ts b/src/app/queries/projects.ts index 1f30106..d71b078 100644 --- a/src/app/queries/projects.ts +++ b/src/app/queries/projects.ts @@ -38,17 +38,37 @@ export function useProjects( group?: string, search?: string, ) { - const query = new URLSearchParams({year: yearId, limit: '50'}); - if (kind) query.set('kind', kind); - if (group) query.set('group', group); - if (search) query.set('q', search); return useQuery({ queryKey: ['projects', yearId, kind, group, search], - queryFn: () => apiRequest(`/projects?${query}`), + queryFn: () => fetchAllProjects(yearId, kind, group, search), placeholderData: keepPreviousData, }); } +async function fetchAllProjects( + yearId: string, + kind?: 'project' | 'idea', + group?: string, + search?: string, +): Promise { + const projects: ProjectsResponse['projects'] = []; + let cursor: string | undefined; + + do { + const query = new URLSearchParams({year: yearId, limit: '50'}); + if (kind) query.set('kind', kind); + if (group) query.set('group', group); + if (search) query.set('q', search); + if (cursor) query.set('cursor', cursor); + + const page = await apiRequest(`/projects?${query}`); + projects.push(...page.projects); + cursor = page.nextCursor ?? undefined; + } while (cursor); + + return {projects, nextCursor: null}; +} + export function useProject(projectId: string) { return useQuery({ queryKey: ['project', projectId], diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx index 24598e5..8f3571a 100644 --- a/test/app/routes.test.tsx +++ b/test/app/routes.test.tsx @@ -345,6 +345,61 @@ describe('clickable project routes', () => { ).toBe('true'); }); + it('loads every project page until nextCursor is exhausted', async () => { + fetchMock.mockImplementation(async (input) => { + const url = input instanceof Request ? input.url : input.toString(); + if (url.includes('/api/years/2026')) { + return json({ + year: { + id: '2026', + votingEnabled: false, + submissionsClosed: false, + projectCount: 51, + ideaCount: 0, + groupCount: 0, + participantCount: 51, + }, + groups: [], + awards: [], + }); + } + + const requestUrl = new URL(url, 'https://hackweek.test'); + const cursor = requestUrl.searchParams.get('cursor'); + if (cursor === '50') { + return json({ + projects: [{...projectFixture, id: 'project-51', name: 'Project 51'}], + nextCursor: null, + }); + } + + return json({ + projects: Array.from({length: 50}, (_, index) => ({ + ...projectFixture, + id: `project-${index + 1}`, + name: `Project ${index + 1}`, + })), + nextCursor: '50', + }); + }); + + renderRoute(, '/years/2026/projects', '/years/:yearId/projects'); + + expect(await screen.findByRole('heading', {name: 'Project 1'})).toBeTruthy(); + expect(await screen.findByRole('heading', {name: 'Project 51'})).toBeTruthy(); + expect(screen.getByRole('region', {name: 'project list'}).children).toHaveLength(51); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringMatching(/\/api\/projects\?(?=.*year=2026)(?=.*limit=50)(?!.*cursor=)/), + undefined, + ); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringMatching( + /\/api\/projects\?(?=.*year=2026)(?=.*limit=50)(?=.*cursor=50)/, + ), + undefined, + ); + }); + it('live-updates server search without replacing the current list', async () => { let resolveSearch!: (response: Response) => void; const pendingSearch = new Promise((resolve) => { From 06b11604e27146e56a34bdd2fe96d10a0f9cc9d2 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:40:34 +0000 Subject: [PATCH 2/9] test(projects): format pagination regression coverage --- test/app/routes.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx index 8f3571a..16bd8e2 100644 --- a/test/app/routes.test.tsx +++ b/test/app/routes.test.tsx @@ -389,7 +389,9 @@ describe('clickable project routes', () => { expect(await screen.findByRole('heading', {name: 'Project 51'})).toBeTruthy(); expect(screen.getByRole('region', {name: 'project list'}).children).toHaveLength(51); expect(fetchMock).toHaveBeenCalledWith( - expect.stringMatching(/\/api\/projects\?(?=.*year=2026)(?=.*limit=50)(?!.*cursor=)/), + expect.stringMatching( + /\/api\/projects\?(?=.*year=2026)(?=.*limit=50)(?!.*cursor=)/, + ), undefined, ); expect(fetchMock).toHaveBeenCalledWith( From 561a443449786467017d4b6a23ecfeb2d99cab98 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:48:55 +0000 Subject: [PATCH 3/9] fix(projects): raise page size to 250 and add page controls Allow the projects API to return up to 250 items per request so current Hackweek years fit in one fetch. Keep next/previous controls for any result set that still returns a next cursor. Co-Authored-By: Trevor Elkins --- src/app/queries/projects.ts | 39 +++++---------- src/app/routes/ProjectsPage.tsx | 87 ++++++++++++++++++++++++++++----- src/app/styles.css | 32 ++++++++++++ src/worker/routes/projects.ts | 2 +- test/app/routes.test.tsx | 41 +++++++++++----- 5 files changed, 151 insertions(+), 50 deletions(-) diff --git a/src/app/queries/projects.ts b/src/app/queries/projects.ts index d71b078..10f4261 100644 --- a/src/app/queries/projects.ts +++ b/src/app/queries/projects.ts @@ -32,43 +32,30 @@ export function useYear(yearId: string) { }); } +export const PROJECTS_PAGE_SIZE = 250; + export function useProjects( yearId: string, kind?: 'project' | 'idea', group?: string, search?: string, + cursor?: string, ) { + const query = new URLSearchParams({ + year: yearId, + limit: String(PROJECTS_PAGE_SIZE), + }); + if (kind) query.set('kind', kind); + if (group) query.set('group', group); + if (search) query.set('q', search); + if (cursor) query.set('cursor', cursor); return useQuery({ - queryKey: ['projects', yearId, kind, group, search], - queryFn: () => fetchAllProjects(yearId, kind, group, search), + queryKey: ['projects', yearId, kind, group, search, cursor ?? null], + queryFn: () => apiRequest(`/projects?${query}`), placeholderData: keepPreviousData, }); } -async function fetchAllProjects( - yearId: string, - kind?: 'project' | 'idea', - group?: string, - search?: string, -): Promise { - const projects: ProjectsResponse['projects'] = []; - let cursor: string | undefined; - - do { - const query = new URLSearchParams({year: yearId, limit: '50'}); - if (kind) query.set('kind', kind); - if (group) query.set('group', group); - if (search) query.set('q', search); - if (cursor) query.set('cursor', cursor); - - const page = await apiRequest(`/projects?${query}`); - projects.push(...page.projects); - cursor = page.nextCursor ?? undefined; - } while (cursor); - - return {projects, nextCursor: null}; -} - export function useProject(projectId: string) { return useQuery({ queryKey: ['project', projectId], diff --git a/src/app/routes/ProjectsPage.tsx b/src/app/routes/ProjectsPage.tsx index efceb57..642a568 100644 --- a/src/app/routes/ProjectsPage.tsx +++ b/src/app/routes/ProjectsPage.tsx @@ -35,6 +35,8 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) { const [group, setGroup] = useState(''); const [searchInput, setSearchInput] = useState(''); const [search, setSearch] = useState(''); + const [cursor, setCursor] = useState(); + const [cursorHistory, setCursorHistory] = useState>([]); const [view, setView] = useState(getProjectsView); const year = useYear(yearId); const projects = useProjects( @@ -42,8 +44,20 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) { kind, kind === 'project' ? group || undefined : undefined, search || undefined, + cursor, ); const error = year.error ?? projects.error; + const pageProjects = projects.data?.projects ?? []; + const nextCursor = projects.data?.nextCursor ?? null; + const pageOffset = cursor ? Number(cursor) : 0; + const pageStart = pageOffset + 1; + const pageEnd = pageOffset + pageProjects.length; + const showPagination = Boolean(cursor || nextCursor); + + const resetPagination = () => { + setCursor(undefined); + setCursorHistory([]); + }; useEffect(() => { const timeout = window.setTimeout(() => { @@ -52,6 +66,10 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) { return () => window.clearTimeout(timeout); }, [searchInput]); + useEffect(() => { + resetPagination(); + }, [yearId, search]); + return ( {!year.data ? ( @@ -136,13 +154,19 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
@@ -153,7 +177,10 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) { Group setGroup(event.target.value)} + onChange={(event) => { + setGroup(event.target.value); + resetPagination(); + }} > {year.data.groups.map((item) => ( @@ -206,8 +260,13 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {
)} - {!projects.data?.projects.length ? ( -
+ {!pageProjects.length ? ( +

No {kind === 'idea' ? 'ideas' : 'projects'} found

@@ -220,8 +279,10 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) {

- {projects.data.projects.map((project) => ( + {pageProjects.map((project) => ( )} + {showPagination && ( + + )} )} diff --git a/src/app/styles.css b/src/app/styles.css index 865871d..1239ce9 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -829,6 +829,30 @@ main { align-items: center; justify-content: flex-end; } +.projectPagination { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; + justify-content: space-between; + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--line); +} +.projectPagination p { + margin: 0; + color: var(--muted); + font-size: 0.85rem; +} +.projectPagination > div { + display: flex; + gap: 0.75rem; + align-items: center; +} +.projectPagination button:disabled { + opacity: 0.45; + cursor: not-allowed; +} .projectViewToggle { display: inline-flex; gap: 2px; @@ -3130,6 +3154,14 @@ kbd { width: 100%; justify-content: space-between; } + .projectPagination { + align-items: stretch; + flex-direction: column; + } + .projectPagination > div { + width: 100%; + justify-content: space-between; + } .projectRow { grid-template-areas: 'name name' diff --git a/src/worker/repositories/projects.ts b/src/worker/repositories/projects.ts index da3c738..d91ed97 100644 --- a/src/worker/repositories/projects.ts +++ b/src/worker/repositories/projects.ts @@ -535,24 +535,33 @@ async function assertUsersExist(db: D1Database, ids: string[]) { } } +// D1 allows at most 100 bound parameters per query. +// https://developers.cloudflare.com/d1/platform/limits/ +const D1_MAX_BOUND_PARAMETERS = 100; + async function membersByProjectIds(db: D1Database, ids: string[]) { const result = new Map(); if (!ids.length) return result; - const placeholders = ids.map(() => '?').join(','); - const {results} = await db - .prepare( - `SELECT pm.project_id, u.id, u.email, u.display_name, u.avatar_url, u.is_admin - FROM project_members pm JOIN users u ON u.id = pm.user_id - WHERE pm.project_id IN (${placeholders}) - ORDER BY u.display_name COLLATE NOCASE, u.id`, - ) - .bind(...ids) - .all(); - for (const row of results) { - const members = result.get(row.project_id) ?? []; - members.push(mapMember(row)); - result.set(row.project_id, members); + + for (let offset = 0; offset < ids.length; offset += D1_MAX_BOUND_PARAMETERS) { + const chunk = ids.slice(offset, offset + D1_MAX_BOUND_PARAMETERS); + const placeholders = chunk.map(() => '?').join(','); + const {results} = await db + .prepare( + `SELECT pm.project_id, u.id, u.email, u.display_name, u.avatar_url, u.is_admin + FROM project_members pm JOIN users u ON u.id = pm.user_id + WHERE pm.project_id IN (${placeholders}) + ORDER BY u.display_name COLLATE NOCASE, u.id`, + ) + .bind(...chunk) + .all(); + for (const row of results) { + const members = result.get(row.project_id) ?? []; + members.push(mapMember(row)); + result.set(row.project_id, members); + } } + return result; } diff --git a/src/worker/routes/projects.ts b/src/worker/routes/projects.ts index afd8d62..802f1b0 100644 --- a/src/worker/routes/projects.ts +++ b/src/worker/routes/projects.ts @@ -30,7 +30,7 @@ projectsRoutes.get('/', async (c) => { throw new ServiceError('VALIDATION_FAILED', 'Kind query is invalid', 400); } const kind = kindQuery === 'project' || kindQuery === 'idea' ? kindQuery : undefined; - const limit = boundedInteger(c.req.query('limit'), 24, 1, 50, 'Limit'); + const limit = boundedInteger(c.req.query('limit'), 24, 1, 250, 'Limit'); const offset = boundedInteger(c.req.query('cursor'), 0, 0, 100_000, 'Cursor'); const search = boundedSearch(c.req.query('q')); const response: ProjectsResponse = await listProjects(c.env.DB, { diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx index a9c7b7a..dcb865c 100644 --- a/test/app/routes.test.tsx +++ b/test/app/routes.test.tsx @@ -562,6 +562,289 @@ describe('clickable project routes', () => { ).toBe('true'); }); + it('requests a 250-item page and focuses and announces loaded pages', async () => { + let resolveSecondPage!: (response: Response) => void; + const pendingSecondPage = new Promise((resolve) => { + resolveSecondPage = resolve; + }); + fetchMock.mockImplementation(async (input) => { + const url = input instanceof Request ? input.url : input.toString(); + if (url.includes('/api/years/2026')) { + return json({ + year: { + id: '2026', + votingEnabled: false, + submissionsClosed: false, + projectCount: 251, + ideaCount: 0, + groupCount: 0, + participantCount: 251, + }, + groups: [], + awards: [], + }); + } + + const requestUrl = new URL(url, 'https://hackweek.test'); + expect(requestUrl.searchParams.get('limit')).toBe('250'); + const cursor = requestUrl.searchParams.get('cursor'); + if (cursor === '250') return pendingSecondPage; + + return json({ + projects: Array.from({length: 250}, (_, index) => ({ + ...projectFixture, + id: `project-${index + 1}`, + name: `Project ${index + 1}`, + })), + nextCursor: '250', + }); + }); + + renderRoute(, '/years/2026/projects', '/years/:yearId/projects'); + + expect(await screen.findByRole('heading', {name: 'Project 1'})).toBeTruthy(); + expect(screen.getByRole('region', {name: 'project list'}).children).toHaveLength(250); + expect(screen.getByText('showing 1–250+')).toBeTruthy(); + expect(screen.getByRole('button', {name: 'previous'}).hasAttribute('disabled')).toBe( + true, + ); + + const next = screen.getByRole('button', {name: 'next'}); + await userEvent.click(next); + + const pagination = screen.getByRole('navigation', {name: 'Project pages'}); + expect(within(pagination).getByRole('status').textContent).toBe('loading page…'); + expect(screen.getByRole('heading', {name: 'Project 1'})).toBeTruthy(); + expect(document.activeElement).toBe(next); + + resolveSecondPage( + json({ + projects: [{...projectFixture, id: 'project-251', name: 'Project 251'}], + nextCursor: null, + }), + ); + + expect(await screen.findByRole('heading', {name: 'Project 251'})).toBeTruthy(); + expect(within(pagination).getByRole('status').textContent).toBe('showing 251–251'); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole('region', {name: 'project list'}), + ), + ); + expect(screen.queryByRole('heading', {name: 'Project 1'})).toBeNull(); + expect(screen.getByRole('button', {name: 'next'}).hasAttribute('disabled')).toBe( + true, + ); + + await userEvent.click(screen.getByRole('button', {name: 'previous'})); + + expect(await screen.findByRole('heading', {name: 'Project 1'})).toBeTruthy(); + expect(within(pagination).getByRole('status').textContent).toBe('showing 1–250+'); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole('region', {name: 'project list'}), + ), + ); + expect(screen.getByRole('button', {name: 'previous'}).hasAttribute('disabled')).toBe( + true, + ); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringMatching( + /\/api\/projects\?(?=.*year=2026)(?=.*limit=250)(?!.*cursor=)/, + ), + undefined, + ); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringMatching( + /\/api\/projects\?(?=.*year=2026)(?=.*limit=250)(?=.*cursor=250)/, + ), + undefined, + ); + }); + + it('preserves filter focus when it supersedes a pending page fetch', async () => { + let resolveSecondPage!: (response: Response) => void; + let resolveIdeas!: (response: Response) => void; + const pendingSecondPage = new Promise((resolve) => { + resolveSecondPage = resolve; + }); + const pendingIdeas = new Promise((resolve) => { + resolveIdeas = resolve; + }); + fetchMock.mockImplementation(async (input) => { + const url = input instanceof Request ? input.url : input.toString(); + if (url.includes('/api/years/2026')) { + return json({ + year: { + id: '2026', + votingEnabled: false, + submissionsClosed: false, + projectCount: 251, + ideaCount: 1, + groupCount: 0, + participantCount: 252, + }, + groups: [], + awards: [], + }); + } + + const requestUrl = new URL(url, 'https://hackweek.test'); + if (requestUrl.searchParams.get('kind') === 'idea') return pendingIdeas; + if (requestUrl.searchParams.get('cursor') === '250') return pendingSecondPage; + return json({projects: [projectFixture], nextCursor: '250'}); + }); + + renderRoute(, '/years/2026/projects', '/years/:yearId/projects'); + + expect(await screen.findByRole('heading', {name: 'A small machine'})).toBeTruthy(); + await userEvent.click(screen.getByRole('button', {name: 'next'})); + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('cursor=250'), + undefined, + ), + ); + + const ideas = screen.getByRole('button', {name: /Ideas/}); + await userEvent.click(ideas); + expect(document.activeElement).toBe(ideas); + + resolveIdeas( + json({ + projects: [ + { + ...projectFixture, + id: 'idea', + name: 'Open signal', + kind: 'idea', + group: null, + members: [], + }, + ], + nextCursor: null, + }), + ); + + expect(await screen.findByRole('heading', {name: 'Open signal'})).toBeTruthy(); + await waitFor(() => expect(document.activeElement).toBe(ideas)); + + resolveSecondPage( + json({ + projects: [{...projectFixture, id: 'project-251', name: 'Project 251'}], + nextCursor: null, + }), + ); + }); + + it('keeps search focus when typing supersedes a pending page fetch', async () => { + let resolveSecondPage!: (response: Response) => void; + const pendingSecondPage = new Promise((resolve) => { + resolveSecondPage = resolve; + }); + fetchMock.mockImplementation(async (input) => { + const url = input instanceof Request ? input.url : input.toString(); + if (url.includes('/api/years/2026')) { + return json({ + year: { + id: '2026', + votingEnabled: false, + submissionsClosed: false, + projectCount: 251, + ideaCount: 0, + groupCount: 0, + participantCount: 251, + }, + groups: [], + awards: [], + }); + } + + const requestUrl = new URL(url, 'https://hackweek.test'); + if (requestUrl.searchParams.get('cursor') === '250') return pendingSecondPage; + return json({projects: [projectFixture], nextCursor: '250'}); + }); + + renderRoute(, '/years/2026/projects', '/years/:yearId/projects'); + + expect(await screen.findByRole('heading', {name: 'A small machine'})).toBeTruthy(); + await userEvent.click(screen.getByRole('button', {name: 'next'})); + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('cursor=250'), + undefined, + ), + ); + + const searchInput = screen.getByRole('searchbox', { + name: 'Search projects and ideas', + }); + await userEvent.type(searchInput, 's'); + expect(document.activeElement).toBe(searchInput); + + resolveSecondPage( + json({ + projects: [{...projectFixture, id: 'project-251', name: 'Project 251'}], + nextCursor: null, + }), + ); + + expect(await screen.findByRole('heading', {name: 'Project 251'})).toBeTruthy(); + await waitFor(() => expect(document.activeElement).toBe(searchInput)); + }); + + it('keeps Previous available and announces an empty later page', async () => { + fetchMock.mockImplementation(async (input) => { + const url = input instanceof Request ? input.url : input.toString(); + if (url.includes('/api/years/2026')) { + return json({ + year: { + id: '2026', + votingEnabled: false, + submissionsClosed: false, + projectCount: 1, + ideaCount: 0, + groupCount: 0, + participantCount: 1, + }, + groups: [], + awards: [], + }); + } + + const cursor = new URL(url, 'https://hackweek.test').searchParams.get('cursor'); + if (cursor === '250') return json({projects: [], nextCursor: null}); + return json({projects: [projectFixture], nextCursor: '250'}); + }); + + renderRoute(, '/years/2026/projects', '/years/:yearId/projects'); + + expect(await screen.findByRole('heading', {name: 'A small machine'})).toBeTruthy(); + await userEvent.click(screen.getByRole('button', {name: 'next'})); + + expect(await screen.findByRole('heading', {name: 'No projects found'})).toBeTruthy(); + const emptyResults = screen.getByRole('region', {name: 'project results'}); + const pagination = screen.getByRole('navigation', {name: 'Project pages'}); + expect(within(pagination).getByRole('status').textContent).toBe( + 'no projects found on this page', + ); + const previous = within(pagination).getByRole('button', {name: 'previous'}); + expect(previous.hasAttribute('disabled')).toBe(false); + await waitFor(() => expect(document.activeElement).toBe(emptyResults)); + + await userEvent.click(previous); + + expect(await screen.findByRole('heading', {name: 'A small machine'})).toBeTruthy(); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole('region', {name: 'project list'}), + ), + ); + expect(screen.getByRole('button', {name: 'previous'}).hasAttribute('disabled')).toBe( + true, + ); + }); + it('live-updates server search without replacing the current list', async () => { let resolveSearch!: (response: Response) => void; const pendingSearch = new Promise((resolve) => { diff --git a/test/projects/projects.test.ts b/test/projects/projects.test.ts index 479f819..e0e79f8 100644 --- a/test/projects/projects.test.ts +++ b/test/projects/projects.test.ts @@ -13,7 +13,7 @@ let outsiderToken: string; beforeEach(async () => { suffix += 1; - yearId = `project-year-${suffix}`; + yearId = `project-year-${String(suffix).padStart(3, '0')}`; groupId = `group-${suffix}`; memberToken = await createSessionCookie({ sub: `project-member-${suffix}`, @@ -99,6 +99,37 @@ describe('project and history APIs', () => { expect(ideaView.body.project.permissions.canVote).toBe(false); }); + it('lists more than 100 projects without exceeding D1 bound-parameter limits', async () => { + const user = await env.DB.prepare('SELECT id FROM users WHERE google_subject = ?') + .bind(`project-member-${suffix}`) + .first<{id: string}>(); + const statements = Array.from({length: 101}, (_, index) => { + const id = `bulk-project-${suffix}-${index}`; + return [ + env.DB.prepare( + `INSERT INTO projects (id, source_id, year_id, creator_id, name, kind, group_id) + VALUES (?, ?, ?, ?, ?, 'project', ?)`, + ).bind(id, id, yearId, user!.id, `Bulk project ${index}`, groupId), + env.DB.prepare( + 'INSERT INTO project_members (project_id, user_id) VALUES (?, ?)', + ).bind(id, user!.id), + ]; + }).flat(); + await env.DB.batch(statements); + + const page = await api( + `/projects?year=${yearId}&kind=project&limit=101`, + memberToken, + ); + + expect(page.status).toBe(200); + expect(page.body.projects).toHaveLength(101); + expect(page.body.nextCursor).toBeNull(); + expect(page.body.projects[0].members).toEqual([ + expect.objectContaining({email: `project-member-${suffix}@sentry.io`}), + ]); + }); + it('searches titles and descriptions before pagination with relevant results first', async () => { const exact = await createProject(memberToken, { name: 'Signal', From d8bd47104009b58f8f8acacd4dbe164c99dc3728 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:25:04 +0000 Subject: [PATCH 9/9] chore: refresh PR base against current master Co-Authored-By: Trevor Elkins