Skip to content

Commit d721d14

Browse files
feat(dashboard): W9-B1 pause/resume button + modal + tier-wall (#58)
- src/components/PauseResumeButton.tsx — new component with confirmation modal + tier-wall handling. Replaces the row trailing column on ResourcesPage and lands a dedicated card on ResourceDetailPage. - e2e/pause-resume.spec.ts — Playwright coverage. - pauseResource / resumeResource client helpers + 'paused' added to ResourceStatus. - Paused pill on header + row (will be reconciled with W8 in rebase).
1 parent b079f6a commit d721d14

6 files changed

Lines changed: 671 additions & 26 deletions

File tree

e2e/pause-resume.spec.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/* pause-resume.spec.ts — Pause + Resume buttons on the resource detail page.
2+
*
3+
* Validates that:
4+
* 1. The Pause button is visible on the detail page for an active resource.
5+
* 2. Clicking Pause opens the confirmation modal (no auto-confirm).
6+
* 3. Confirming hits POST /api/v1/resources/:id/pause and flips the button
7+
* label to Resume after the response lands.
8+
* 4. The list page surfaces the Paused pill once the row's status is paused.
9+
*
10+
* Mocks the pause/resume endpoints inline so we don't depend on a real backend. */
11+
12+
import { expect, test } from '@playwright/test'
13+
import { FAKE_RESOURCES, installAPIFake, signIn } from './fixtures'
14+
15+
test.describe('Pause + Resume', () => {
16+
test('Pause → confirm → Resume label flips', async ({ page }) => {
17+
await signIn(page)
18+
await installAPIFake(page)
19+
20+
const r = FAKE_RESOURCES[0]
21+
22+
// Mock POST /api/v1/resources/:id/pause to return the resource flipped
23+
// to 'paused'. After the click the page should re-read this state and
24+
// re-render the button label.
25+
await page.route(`**/api/v1/resources/${r.token}/pause`, (route) => {
26+
if (route.request().method() !== 'POST') return route.continue()
27+
return route.fulfill({
28+
status: 200,
29+
contentType: 'application/json',
30+
body: JSON.stringify({
31+
ok: true,
32+
item: { ...r, status: 'paused' },
33+
}),
34+
})
35+
})
36+
37+
await page.route(`**/api/v1/resources/${r.token}/resume`, (route) => {
38+
if (route.request().method() !== 'POST') return route.continue()
39+
return route.fulfill({
40+
status: 200,
41+
contentType: 'application/json',
42+
body: JSON.stringify({
43+
ok: true,
44+
item: { ...r, status: 'active' },
45+
}),
46+
})
47+
})
48+
49+
await page.goto(`/app/resources/${r.token}`)
50+
51+
// The Pause button is visible (the resource starts as active).
52+
const button = page.getByTestId('pause-resume-button')
53+
await expect(button).toBeVisible()
54+
await expect(button).toHaveText('Pause')
55+
56+
// Click opens the confirmation modal — no auto-confirm.
57+
await button.click()
58+
await expect(page.getByTestId('pause-resume-modal')).toBeVisible()
59+
60+
// Confirm.
61+
await page.getByTestId('pause-resume-confirm').click()
62+
63+
// After the api responds the button flips to "Resume" and the modal
64+
// closes.
65+
await expect(page.getByTestId('pause-resume-modal')).toBeHidden()
66+
await expect(button).toHaveText('Resume')
67+
68+
// The header carries the paused pill once status flipped.
69+
await expect(page.getByTestId('resource-paused-pill').first()).toBeVisible()
70+
})
71+
})

src/api/index.ts

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -329,27 +329,6 @@ export async function inviteMember(_body: { email: string; role: string }): Prom
329329
return { ok: true }
330330
}
331331

332-
// ─── Resource pause / resume (LIVE — Pro+ tier) ────────────────────────
333-
// Pause keeps the data + connection_url but stops counting the resource
334-
// against the per-team count quota; storage still counts. Resume flips
335-
// status back to active. 402 means the team is on a tier that doesn't
336-
// include pause/resume; surface the agent_action upgrade copy.
337-
export async function pauseResource(id: string): Promise<{ ok: true; resource: Resource }> {
338-
const r = await call<{ ok: boolean; resource: any }>(
339-
`/api/v1/resources/${encodeURIComponent(id)}/pause`,
340-
{ method: 'POST' },
341-
)
342-
return { ok: true, resource: adaptResource(r.resource) }
343-
}
344-
345-
export async function resumeResource(id: string): Promise<{ ok: true; resource: Resource }> {
346-
const r = await call<{ ok: boolean; resource: any }>(
347-
`/api/v1/resources/${encodeURIComponent(id)}/resume`,
348-
{ method: 'POST' },
349-
)
350-
return { ok: true, resource: adaptResource(r.resource) }
351-
}
352-
353332
// ─── Resources (LIVE) ───────────────────────────────────────────────────
354333
type ResourceListResp = { ok: boolean; items: any[]; total: number }
355334
type ResourceGetResp = { ok: boolean; item: any }
@@ -401,6 +380,35 @@ export async function deleteResource(id: string): Promise<void> {
401380
await call(`/api/v1/resources/${id}`, { method: 'DELETE' })
402381
}
403382

383+
// ─── Pause / resume (Pro+ feature) ──────────────────────────────────────
384+
//
385+
// Pause stops the resource counting against quota while preserving every
386+
// byte of data. Resume flips it back to 'active' so it counts again and is
387+
// reachable. Both are idempotent on the server: pausing an already-paused
388+
// resource returns the current row unchanged; same for resume.
389+
//
390+
// Tier gate: the agent API returns 402 with `agent_action` on
391+
// anonymous/free/hobby. The callers (PauseResumeButton) trap that status
392+
// and render the upgrade CTA inline instead of throwing. Other errors
393+
// (5xx, network) propagate so the UI can surface a real banner.
394+
//
395+
// Status semantics:
396+
// POST /api/v1/resources/:id/pause → { ok, item: <resource with status='paused'> }
397+
// POST /api/v1/resources/:id/resume → { ok, item: <resource with status='active'> }
398+
//
399+
// We re-use the existing GetResp adapter so the returned Resource mirrors
400+
// what GET /:id would have produced — callers can replace state directly
401+
// without a second fetch.
402+
export async function pauseResource(id: string): Promise<{ ok: true; resource: Resource }> {
403+
const r = await call<ResourceGetResp>(`/api/v1/resources/${id}/pause`, { method: 'POST' })
404+
return { ok: true, resource: adaptResource(r.item) }
405+
}
406+
407+
export async function resumeResource(id: string): Promise<{ ok: true; resource: Resource }> {
408+
const r = await call<ResourceGetResp>(`/api/v1/resources/${id}/resume`, { method: 'POST' })
409+
return { ok: true, resource: adaptResource(r.item) }
410+
}
411+
404412
export async function rotateResource(id: string): Promise<{ ok: true; connection_url: string; resource: Resource }> {
405413
const r = await call<{ ok: boolean; connection_url: string }>(
406414
`/api/v1/resources/${id}/rotate-credentials`,
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
/* PauseResumeButton.test.tsx — unit coverage for the pause/resume toggle.
2+
*
3+
* Covers:
4+
* - Label flips based on resource.status
5+
* - Click opens the confirmation modal (no accidental auto-confirm)
6+
* - Confirm in modal calls api.pauseResource / api.resumeResource by status
7+
* - 402 swaps the modal body for the inline UpgradeButton CTA
8+
* - 500 surfaces an inline error and leaves the modal open
9+
* - Terminal statuses (expired/tombstoned/deleted) render nothing
10+
*
11+
* We mock the api module so no real fetch goes out and so we can control
12+
* exactly which promise the button awaits. */
13+
14+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
15+
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'
16+
import type { Resource } from '../api'
17+
18+
vi.mock('../api', async () => {
19+
const actual = await vi.importActual<typeof import('../api')>('../api')
20+
return {
21+
...actual,
22+
pauseResource: vi.fn(),
23+
resumeResource: vi.fn(),
24+
reportExperimentConverted: vi.fn(),
25+
}
26+
})
27+
28+
import * as api from '../api'
29+
import { PauseResumeButton } from './PauseResumeButton'
30+
31+
const baseResource: Resource = {
32+
id: 'res_abc123',
33+
token: 'tok_abc123',
34+
resource_type: 'postgres',
35+
tier: 'hobby',
36+
status: 'active',
37+
name: 'orders-db',
38+
env: 'production',
39+
storage_bytes: 1_000_000,
40+
storage_limit_bytes: 500_000_000,
41+
storage_exceeded: false,
42+
connections_in_use: 1,
43+
connections_limit: 5,
44+
cloud_vendor: 'aws',
45+
country_code: 'IN',
46+
expires_at: null,
47+
created_at: '2026-05-01T00:00:00Z',
48+
}
49+
50+
beforeEach(() => {
51+
;(api.pauseResource as any).mockReset()
52+
;(api.resumeResource as any).mockReset()
53+
;(api.reportExperimentConverted as any).mockReset()
54+
;(api.reportExperimentConverted as any).mockResolvedValue(undefined)
55+
})
56+
57+
afterEach(() => {
58+
cleanup()
59+
})
60+
61+
describe('PauseResumeButton — label per status', () => {
62+
it('renders "Pause" when status is active', () => {
63+
render(<PauseResumeButton resource={baseResource} onUpdated={() => {}} />)
64+
const btn = screen.getByTestId('pause-resume-button')
65+
expect(btn.textContent).toBe('Pause')
66+
expect(btn.getAttribute('data-action')).toBe('pause')
67+
})
68+
69+
it('renders "Resume" when status is paused', () => {
70+
const paused: Resource = { ...baseResource, status: 'paused' }
71+
render(<PauseResumeButton resource={paused} onUpdated={() => {}} />)
72+
const btn = screen.getByTestId('pause-resume-button')
73+
expect(btn.textContent).toBe('Resume')
74+
expect(btn.getAttribute('data-action')).toBe('resume')
75+
})
76+
77+
it('renders nothing for expired resources', () => {
78+
const expired: Resource = { ...baseResource, status: 'expired' }
79+
const { container } = render(
80+
<PauseResumeButton resource={expired} onUpdated={() => {}} />,
81+
)
82+
expect(container.firstChild).toBeNull()
83+
})
84+
85+
it('renders nothing for tombstoned resources', () => {
86+
const t: Resource = { ...baseResource, status: 'tombstoned' }
87+
const { container } = render(<PauseResumeButton resource={t} onUpdated={() => {}} />)
88+
expect(container.firstChild).toBeNull()
89+
})
90+
91+
it('renders nothing for deleted resources', () => {
92+
const d: Resource = { ...baseResource, status: 'deleted' }
93+
const { container } = render(<PauseResumeButton resource={d} onUpdated={() => {}} />)
94+
expect(container.firstChild).toBeNull()
95+
})
96+
})
97+
98+
describe('PauseResumeButton — modal flow', () => {
99+
it('does NOT call the api on first click — opens the modal instead', async () => {
100+
render(<PauseResumeButton resource={baseResource} onUpdated={() => {}} />)
101+
fireEvent.click(screen.getByTestId('pause-resume-button'))
102+
await waitFor(() => {
103+
expect(screen.getByTestId('pause-resume-modal')).toBeTruthy()
104+
})
105+
expect(api.pauseResource).not.toHaveBeenCalled()
106+
expect(api.resumeResource).not.toHaveBeenCalled()
107+
})
108+
109+
it('confirming on an active resource calls api.pauseResource(id)', async () => {
110+
const onUpdated = vi.fn()
111+
;(api.pauseResource as any).mockResolvedValue({
112+
ok: true,
113+
resource: { ...baseResource, status: 'paused' },
114+
})
115+
render(<PauseResumeButton resource={baseResource} onUpdated={onUpdated} />)
116+
fireEvent.click(screen.getByTestId('pause-resume-button'))
117+
fireEvent.click(screen.getByTestId('pause-resume-confirm'))
118+
await waitFor(() => {
119+
expect(api.pauseResource).toHaveBeenCalledWith('res_abc123')
120+
})
121+
expect(api.resumeResource).not.toHaveBeenCalled()
122+
await waitFor(() => {
123+
expect(onUpdated).toHaveBeenCalledWith(
124+
expect.objectContaining({ status: 'paused' }),
125+
)
126+
})
127+
})
128+
129+
it('confirming on a paused resource calls api.resumeResource(id)', async () => {
130+
const onUpdated = vi.fn()
131+
const paused: Resource = { ...baseResource, status: 'paused' }
132+
;(api.resumeResource as any).mockResolvedValue({
133+
ok: true,
134+
resource: { ...baseResource, status: 'active' },
135+
})
136+
render(<PauseResumeButton resource={paused} onUpdated={onUpdated} />)
137+
fireEvent.click(screen.getByTestId('pause-resume-button'))
138+
fireEvent.click(screen.getByTestId('pause-resume-confirm'))
139+
await waitFor(() => {
140+
expect(api.resumeResource).toHaveBeenCalledWith('res_abc123')
141+
})
142+
expect(api.pauseResource).not.toHaveBeenCalled()
143+
await waitFor(() => {
144+
expect(onUpdated).toHaveBeenCalledWith(
145+
expect.objectContaining({ status: 'active' }),
146+
)
147+
})
148+
})
149+
150+
it('Cancel button closes the modal without calling the api', async () => {
151+
render(<PauseResumeButton resource={baseResource} onUpdated={() => {}} />)
152+
fireEvent.click(screen.getByTestId('pause-resume-button'))
153+
expect(screen.getByTestId('pause-resume-modal')).toBeTruthy()
154+
fireEvent.click(screen.getByTestId('pause-resume-cancel'))
155+
await waitFor(() => {
156+
expect(screen.queryByTestId('pause-resume-modal')).toBeNull()
157+
})
158+
expect(api.pauseResource).not.toHaveBeenCalled()
159+
})
160+
})
161+
162+
describe('PauseResumeButton — tier-wall (402) handling', () => {
163+
it('on 402 swaps the modal body for the upgrade CTA and does NOT call onUpdated', async () => {
164+
const onUpdated = vi.fn()
165+
const tierErr = Object.assign(new Error('pro tier required'), {
166+
status: 402,
167+
code: 'agent_action',
168+
})
169+
;(api.pauseResource as any).mockRejectedValue(tierErr)
170+
render(<PauseResumeButton resource={baseResource} onUpdated={onUpdated} />)
171+
fireEvent.click(screen.getByTestId('pause-resume-button'))
172+
fireEvent.click(screen.getByTestId('pause-resume-confirm'))
173+
await waitFor(() => {
174+
expect(screen.getByTestId('pause-resume-upgrade')).toBeTruthy()
175+
})
176+
// The upgrade CTA itself is the UpgradeButton — surfaced under its
177+
// own data-testid (pause-resume-upgrade-cta).
178+
expect(screen.getByTestId('pause-resume-upgrade-cta')).toBeTruthy()
179+
expect(onUpdated).not.toHaveBeenCalled()
180+
// No inline error in the 402 path — the tier-wall replaces the entire
181+
// action row.
182+
expect(screen.queryByTestId('pause-resume-error')).toBeNull()
183+
})
184+
})
185+
186+
describe('PauseResumeButton — generic error (5xx / network)', () => {
187+
it('on 500 surfaces an inline error and leaves the modal open', async () => {
188+
const onUpdated = vi.fn()
189+
const serverErr = Object.assign(new Error('upstream timeout'), {
190+
status: 500,
191+
code: 'http_500',
192+
})
193+
;(api.pauseResource as any).mockRejectedValue(serverErr)
194+
render(<PauseResumeButton resource={baseResource} onUpdated={onUpdated} />)
195+
fireEvent.click(screen.getByTestId('pause-resume-button'))
196+
fireEvent.click(screen.getByTestId('pause-resume-confirm'))
197+
await waitFor(() => {
198+
const err = screen.getByTestId('pause-resume-error')
199+
expect(err).toBeTruthy()
200+
expect(err.textContent).toMatch(/upstream timeout/)
201+
})
202+
// Modal stays open so the user can retry.
203+
expect(screen.getByTestId('pause-resume-modal')).toBeTruthy()
204+
expect(onUpdated).not.toHaveBeenCalled()
205+
// Confirm button is re-enabled after the error so a retry is possible.
206+
const confirm = screen.getByTestId('pause-resume-confirm') as HTMLButtonElement
207+
expect(confirm.disabled).toBe(false)
208+
})
209+
210+
it('on a thrown network error (no status) still surfaces the message', async () => {
211+
const netErr = new Error('Failed to fetch')
212+
;(api.pauseResource as any).mockRejectedValue(netErr)
213+
render(<PauseResumeButton resource={baseResource} onUpdated={() => {}} />)
214+
fireEvent.click(screen.getByTestId('pause-resume-button'))
215+
fireEvent.click(screen.getByTestId('pause-resume-confirm'))
216+
await waitFor(() => {
217+
expect(screen.getByTestId('pause-resume-error').textContent).toMatch(
218+
/Failed to fetch/,
219+
)
220+
})
221+
})
222+
})

0 commit comments

Comments
 (0)