Skip to content

Commit 41227c5

Browse files
authored
feat: add admin reset data page (#448)
## Summary - Adds `/admin/reset` page with paginated table of all packages - Three actions per package: Reset Summaries, Reset Releases, Delete Package - Confirmation modal before each destructive action - Pagination controls for navigating large package lists - Nav link from main Admin page ## Details - Regenerated OpenAPI spec and Orval client code for the new `reset-summaries` and `reset-releases` endpoints (from #447) - New `useResetSummaries` and `useResetReleases` mutation hooks - MSW handlers for the new admin endpoints - 14 new tests: 4 hook tests + 10 page component tests **Depends on:** #447 ## Test plan - [x] `npx vitest run` - all 159 tests pass (13 test files) - [x] `npx tsc --noEmit` - clean TypeScript build - [ ] Manual: navigate to /admin/reset, verify table loads with pagination - [ ] Manual: test each action button opens correct confirmation modal - [ ] Manual: confirm actions call the correct API endpoints 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents ac07a7c + 9496484 commit 41227c5

File tree

9 files changed

+757
-0
lines changed

9 files changed

+757
-0
lines changed

patchnotes-web/src/api/hooks.test.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020
useRemoveFromWatchlist,
2121
useAddFromGithub,
2222
useGithubSearch,
23+
useResetSummaries,
24+
useResetReleases,
2325
} from './hooks'
2426

2527
function createWrapper() {
@@ -279,3 +281,59 @@ describe('useGithubSearch', () => {
279281
expect(result.current.data).toBeUndefined()
280282
})
281283
})
284+
285+
describe('useResetSummaries', () => {
286+
it('resets summaries for a package successfully', async () => {
287+
const { result } = renderHook(() => useResetSummaries(), {
288+
wrapper: createWrapper(),
289+
})
290+
291+
result.current.mutate('pkg-react-test-id')
292+
293+
await waitFor(() => expect(result.current.isSuccess).toBe(true))
294+
})
295+
296+
it('handles error when reset fails', async () => {
297+
server.use(
298+
http.post('/api/admin/packages/:id/reset-summaries', () => {
299+
return new HttpResponse(null, { status: 404 })
300+
})
301+
)
302+
303+
const { result } = renderHook(() => useResetSummaries(), {
304+
wrapper: createWrapper(),
305+
})
306+
307+
result.current.mutate('nonexistent')
308+
309+
await waitFor(() => expect(result.current.isError).toBe(true))
310+
})
311+
})
312+
313+
describe('useResetReleases', () => {
314+
it('resets releases for a package successfully', async () => {
315+
const { result } = renderHook(() => useResetReleases(), {
316+
wrapper: createWrapper(),
317+
})
318+
319+
result.current.mutate('pkg-react-test-id')
320+
321+
await waitFor(() => expect(result.current.isSuccess).toBe(true))
322+
})
323+
324+
it('handles error when reset fails', async () => {
325+
server.use(
326+
http.post('/api/admin/packages/:id/reset-releases', () => {
327+
return new HttpResponse(null, { status: 404 })
328+
})
329+
)
330+
331+
const { result } = renderHook(() => useResetReleases(), {
332+
wrapper: createWrapper(),
333+
})
334+
335+
result.current.mutate('nonexistent')
336+
337+
await waitFor(() => expect(result.current.isError).toBe(true))
338+
})
339+
})

patchnotes-web/src/api/hooks.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ import {
2929
searchGitHubRepositoriesUser,
3030
getSearchGitHubRepositoriesUserQueryKey,
3131
} from './generated/git-hub-search/git-hub-search'
32+
import {
33+
resetPackageSummaries,
34+
resetPackageReleases,
35+
} from './generated/admin-packages/admin-packages'
3236

3337
import {
3438
GetPackagesResponse,
@@ -214,6 +218,28 @@ export function useBulkAddPackages() {
214218
})
215219
}
216220

221+
export function useResetSummaries() {
222+
const queryClient = useQueryClient()
223+
224+
return useMutation({
225+
mutationFn: (id: string) => resetPackageSummaries(id),
226+
onSuccess: () => {
227+
queryClient.invalidateQueries({ queryKey: getGetPackagesQueryKey() })
228+
},
229+
})
230+
}
231+
232+
export function useResetReleases() {
233+
const queryClient = useQueryClient()
234+
235+
return useMutation({
236+
mutationFn: (id: string) => resetPackageReleases(id),
237+
onSuccess: () => {
238+
queryClient.invalidateQueries({ queryKey: getGetPackagesQueryKey() })
239+
},
240+
})
241+
}
242+
217243
// ── Watchlist Hooks ──────────────────────────────────────────
218244

219245
export function useWatchlist() {

patchnotes-web/src/pages/Admin.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,11 @@ export function Admin() {
645645
Email Templates
646646
</Button>
647647
</Link>
648+
<Link to="/admin/reset">
649+
<Button variant="secondary" size="sm">
650+
Reset Data
651+
</Button>
652+
</Link>
648653
<Button
649654
variant="secondary"
650655
size="sm"
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import { render, screen, waitFor } from '../test/utils'
2+
import userEvent from '@testing-library/user-event'
3+
import { http, HttpResponse } from 'msw'
4+
import { server } from '../test/mocks/server'
5+
import { mockPackages } from '../test/mocks/handlers'
6+
import { AdminReset } from './AdminReset'
7+
8+
// Mock @tanstack/react-router
9+
const mockNavigate = vi.fn()
10+
vi.mock('@tanstack/react-router', () => ({
11+
Link: ({ children, to, ...props }: Record<string, unknown>) => (
12+
<a href={to as string} {...props}>
13+
{children as React.ReactNode}
14+
</a>
15+
),
16+
useNavigate: () => mockNavigate,
17+
}))
18+
19+
// Override the global @stytch/react mock to control auth state per-test
20+
const mockStytchUser = vi.hoisted(() => vi.fn())
21+
22+
vi.mock('@stytch/react', () => ({
23+
StytchProvider: ({ children }: { children: React.ReactNode }) => children,
24+
useStytch: () => ({
25+
magicLinks: { authenticate: vi.fn() },
26+
oauth: { authenticate: vi.fn() },
27+
session: { revoke: vi.fn() },
28+
}),
29+
useStytchUser: mockStytchUser,
30+
}))
31+
32+
const adminUser = {
33+
user: {
34+
user_id: 'test-user-id',
35+
emails: [{ email: 'admin@example.com' }],
36+
roles: ['patch_notes_admin'],
37+
},
38+
isInitialized: true,
39+
}
40+
41+
const nonAdminUser = {
42+
user: {
43+
user_id: 'test-user-id',
44+
emails: [{ email: 'user@example.com' }],
45+
roles: [],
46+
},
47+
isInitialized: true,
48+
}
49+
50+
beforeEach(() => {
51+
mockNavigate.mockClear()
52+
})
53+
54+
describe('AdminReset', () => {
55+
describe('when user is admin', () => {
56+
beforeEach(() => {
57+
mockStytchUser.mockReturnValue(adminUser)
58+
})
59+
60+
it('renders the packages table', async () => {
61+
render(<AdminReset />)
62+
63+
await waitFor(() => {
64+
expect(screen.getByText('react')).toBeInTheDocument()
65+
})
66+
67+
expect(screen.getByText('lodash')).toBeInTheDocument()
68+
})
69+
70+
it('shows Reset Summaries buttons for each package', async () => {
71+
render(<AdminReset />)
72+
73+
await waitFor(() => {
74+
expect(screen.getByText('react')).toBeInTheDocument()
75+
})
76+
77+
const resetButtons = screen.getAllByText('Reset Summaries')
78+
expect(resetButtons).toHaveLength(mockPackages.length)
79+
})
80+
81+
it('shows Reset Releases buttons for each package', async () => {
82+
render(<AdminReset />)
83+
84+
await waitFor(() => {
85+
expect(screen.getByText('react')).toBeInTheDocument()
86+
})
87+
88+
const resetButtons = screen.getAllByText('Reset Releases')
89+
expect(resetButtons).toHaveLength(mockPackages.length)
90+
})
91+
92+
it('shows Delete buttons for each package', async () => {
93+
render(<AdminReset />)
94+
95+
await waitFor(() => {
96+
expect(screen.getByText('react')).toBeInTheDocument()
97+
})
98+
99+
const deleteButtons = screen.getAllByText('Delete')
100+
expect(deleteButtons).toHaveLength(mockPackages.length)
101+
})
102+
103+
it('shows confirmation modal when Reset Summaries is clicked', async () => {
104+
const user = userEvent.setup()
105+
render(<AdminReset />)
106+
107+
await waitFor(() => {
108+
expect(screen.getByText('react')).toBeInTheDocument()
109+
})
110+
111+
const resetButtons = screen.getAllByText('Reset Summaries')
112+
await user.click(resetButtons[0])
113+
114+
expect(
115+
screen.getByText(/mark all releases as needing new summaries/i)
116+
).toBeInTheDocument()
117+
})
118+
119+
it('shows confirmation modal when Reset Releases is clicked', async () => {
120+
const user = userEvent.setup()
121+
render(<AdminReset />)
122+
123+
await waitFor(() => {
124+
expect(screen.getByText('react')).toBeInTheDocument()
125+
})
126+
127+
const resetButtons = screen.getAllByText('Reset Releases')
128+
await user.click(resetButtons[0])
129+
130+
expect(
131+
screen.getByText(/delete all releases and summaries/i)
132+
).toBeInTheDocument()
133+
})
134+
135+
it('shows confirmation modal when Delete is clicked', async () => {
136+
const user = userEvent.setup()
137+
render(<AdminReset />)
138+
139+
await waitFor(() => {
140+
expect(screen.getByText('react')).toBeInTheDocument()
141+
})
142+
143+
const deleteButtons = screen.getAllByText('Delete')
144+
await user.click(deleteButtons[0])
145+
146+
expect(
147+
screen.getByText(/permanently delete this package/i)
148+
).toBeInTheDocument()
149+
})
150+
151+
it('closes modal when Cancel is clicked', async () => {
152+
const user = userEvent.setup()
153+
render(<AdminReset />)
154+
155+
await waitFor(() => {
156+
expect(screen.getByText('react')).toBeInTheDocument()
157+
})
158+
159+
const resetButtons = screen.getAllByText('Reset Summaries')
160+
await user.click(resetButtons[0])
161+
162+
expect(
163+
screen.getByText(/mark all releases as needing new summaries/i)
164+
).toBeInTheDocument()
165+
166+
await user.click(screen.getByText('Cancel'))
167+
168+
await waitFor(() => {
169+
expect(
170+
screen.queryByText(/mark all releases as needing new summaries/i)
171+
).not.toBeInTheDocument()
172+
})
173+
})
174+
175+
it('calls reset summaries API on confirm', async () => {
176+
let resetCalled = false
177+
server.use(
178+
http.post('/api/admin/packages/:id/reset-summaries', () => {
179+
resetCalled = true
180+
return new HttpResponse(null, { status: 204 })
181+
})
182+
)
183+
184+
const user = userEvent.setup()
185+
render(<AdminReset />)
186+
187+
await waitFor(() => {
188+
expect(screen.getByText('react')).toBeInTheDocument()
189+
})
190+
191+
const resetButtons = screen.getAllByText('Reset Summaries')
192+
await user.click(resetButtons[0])
193+
194+
// The modal has its own "Reset Summaries" confirm button alongside the row buttons.
195+
// Find all buttons with that text and click the last one (the modal confirm).
196+
const confirmButtons = screen.getAllByRole('button', {
197+
name: 'Reset Summaries',
198+
})
199+
await user.click(confirmButtons[confirmButtons.length - 1])
200+
201+
await waitFor(() => {
202+
expect(resetCalled).toBe(true)
203+
})
204+
})
205+
206+
it('shows loading state', () => {
207+
server.use(
208+
http.get('/api/packages', () => {
209+
return new Promise(() => {}) // never resolves
210+
})
211+
)
212+
213+
render(<AdminReset />)
214+
215+
expect(screen.getByText('Loading packages...')).toBeInTheDocument()
216+
})
217+
})
218+
219+
describe('when user is not admin', () => {
220+
beforeEach(() => {
221+
mockStytchUser.mockReturnValue(nonAdminUser)
222+
})
223+
224+
it('redirects non-admin users', async () => {
225+
render(<AdminReset />)
226+
227+
await waitFor(() => {
228+
expect(mockNavigate).toHaveBeenCalledWith({ to: '/' })
229+
})
230+
})
231+
})
232+
})

0 commit comments

Comments
 (0)