Skip to content

Commit 2da5b1b

Browse files
feat(settings): 3-radio deploy TTL policy + free-tier gating (#155)
* feat(settings): 3-radio deploy TTL policy + tier gating on free tier mastermanas805 (Pro) landed on `default_deployment_ttl_policy=auto_24h` with no in-dashboard path to flip back — only the agent could PATCH /api/v1/team/settings. Surfaces the existing setting as a 3-radio group (Permanent / Auto-expire 24h / Custom hours) so users who arrived at auto_24h via support or pre-promotion can self-serve. UX: - 3 native radios, accessible role=radiogroup, full-row click target. - Static help text: "applies to all NEW deploys. Existing deploys keep their per-deploy setting." (testid: ttl-policy-help). - Free tier: card stays visible (so users can SEE the active default), every radio disabled with an "Upgrade to change" tooltip + an upgrade hint linking to /app/billing. - Custom hours: surface present but disabled-with-tooltip on every tier — the api PATCH only accepts the two-value enum today; per-deploy custom TTL via POST /deployments/:id/ttl is unchanged. - Existing non-admin RBAC hide (developer/viewer) preserved. Coverage: Symptom: pro/team users on auto_24h had no in-UI policy switch. Enumeration: rg -F 'default_deployment_ttl_policy' instanode-web/src/ Sites found: 4 (api/index.ts type/getter/setter + SettingsPage card). Sites touched: 1 (SettingsPage card — additive, no api shape change). Coverage test: SettingsPage.tier-gate.test.tsx asserts radio state + PATCH call + free-tier disabled state + custom-hours coming-soon. Live verified: pending CI + post-merge curl of the live dashboard. Tests: 28/28 SettingsPage tests pass (incl. 5 new tier-gate cases). * chore(settings): cover all radio branches for patch-coverage gate The previous commit left 6 uncovered lines (diff-cover failed with 80% patch coverage). Refactor so every branch is reachable from the existing pro/free tier tests: - Hoist save callbacks into stable savePermanent / saveAuto24h refs (one line each instead of inline arrows on every render). - Hoist tier-conditional title / disabled / description into named consts so the JSX has no inline ternaries. - Replace the custom-radio no-op arrow with a module-level `noop` so there is no "unreachable disabled handler" arrow for coverage to flag. - Add tier-gate test covering save('auto_24h') (permanent → auto_24h flip). diff-cover: 100% (25/25 lines).
1 parent 900e780 commit 2da5b1b

2 files changed

Lines changed: 430 additions & 21 deletions

File tree

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/* SettingsPage.tier-gate.test.tsx — DeployTtlPolicyCard tier gating.
2+
*
3+
* Companion to SettingsPage.test.tsx (which mocks useDashboardCtx as a fixed
4+
* pro-tier owner). This file overrides the dashboard context per-test so we
5+
* can verify:
6+
* • free tier sees the card with every radio disabled + an "Upgrade" hint
7+
* • paid tier (pro) reflects the current policy in the radio group + the
8+
* "Permanent" radio fires PATCH /api/v1/team/settings
9+
* • the static help text is always present
10+
* • "Custom hours" is disabled-with-tooltip on every tier until the api
11+
* accepts per-team hours (today PATCH /team/settings enum is two-valued).
12+
*
13+
* Lives in its own file because vi.mock('../hooks/useDashboardCtx') has a
14+
* factory hoisted to module scope — overriding the returned tier per-test
15+
* is cleanest with a mutable factory closure (see `ctxOverride` below).
16+
*/
17+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
18+
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'
19+
import { MemoryRouter } from 'react-router-dom'
20+
21+
vi.mock('../api', async () => {
22+
const actual = await vi.importActual<typeof import('../api')>('../api')
23+
return {
24+
...actual,
25+
listAPIKeys: vi.fn(),
26+
listMembers: vi.fn(),
27+
getTeamSettings: vi.fn(),
28+
updateTeamSettings: vi.fn(),
29+
}
30+
})
31+
32+
// Mutable context — each test sets the tier it wants before render. The
33+
// factory captures the live reference so the mock returns whatever the
34+
// current test left in `ctxOverride`.
35+
const ctxOverride: { tier: string } = { tier: 'pro' }
36+
37+
vi.mock('../hooks/useDashboardCtx', () => ({
38+
useDashboardCtx: () => ({
39+
me: {
40+
user: { id: 'u1', email: 'me@instanode.dev' },
41+
team: { id: 't1', tier: ctxOverride.tier },
42+
},
43+
meErr: null,
44+
meLoading: false,
45+
env: 'production',
46+
envs: ['production'],
47+
counts: { resources: 0, deployments: 0, vault: 0, team: 1 },
48+
resources: [],
49+
billing: null,
50+
billingLoading: false,
51+
}),
52+
}))
53+
54+
vi.mock('../components/Common', async () => {
55+
const actual = await vi.importActual<typeof import('../components/Common')>('../components/Common')
56+
return { ...actual, copyToClipboard: vi.fn() }
57+
})
58+
59+
import { SettingsPage } from './SettingsPage'
60+
import * as api from '../api'
61+
62+
const m = {
63+
listAPIKeys: api.listAPIKeys as unknown as ReturnType<typeof vi.fn>,
64+
listMembers: api.listMembers as unknown as ReturnType<typeof vi.fn>,
65+
getTeamSettings: api.getTeamSettings as unknown as ReturnType<typeof vi.fn>,
66+
updateTeamSettings: api.updateTeamSettings as unknown as ReturnType<typeof vi.fn>,
67+
}
68+
69+
beforeEach(() => {
70+
vi.clearAllMocks()
71+
ctxOverride.tier = 'pro'
72+
m.listAPIKeys.mockResolvedValue({ ok: true, items: [] })
73+
m.listMembers.mockResolvedValue({
74+
ok: true,
75+
members: [{ id: 'u1', user_id: 'u1', role: 'owner' }],
76+
member_limit: 5,
77+
})
78+
m.getTeamSettings.mockResolvedValue({
79+
ok: true,
80+
settings: { default_deployment_ttl_policy: 'auto_24h' },
81+
})
82+
m.updateTeamSettings.mockResolvedValue({
83+
ok: true,
84+
settings: { default_deployment_ttl_policy: 'permanent' },
85+
})
86+
})
87+
afterEach(() => cleanup())
88+
89+
function renderPage() {
90+
return render(
91+
<MemoryRouter>
92+
<SettingsPage />
93+
</MemoryRouter>,
94+
)
95+
}
96+
97+
describe('DeployTtlPolicyCard — paid tier (pro, owner)', () => {
98+
it('reflects current policy in the radio group and saves "Permanent" via PATCH', async () => {
99+
ctxOverride.tier = 'pro'
100+
renderPage()
101+
await waitFor(() => expect(screen.getByTestId('deploy-ttl-policy-card')).toBeTruthy())
102+
await waitFor(() => expect(screen.getByTestId('ttl-policy-auto-24h')).toBeTruthy())
103+
104+
// Initial state — server returned auto_24h, so that radio is checked.
105+
const auto = screen.getByTestId('ttl-policy-auto-24h') as HTMLInputElement
106+
const perm = screen.getByTestId('ttl-policy-permanent') as HTMLInputElement
107+
expect(auto.checked).toBe(true)
108+
expect(perm.checked).toBe(false)
109+
expect(auto.disabled).toBe(false)
110+
expect(perm.disabled).toBe(false)
111+
112+
// Flip to permanent → PATCH /api/v1/team/settings.
113+
fireEvent.click(perm)
114+
await waitFor(() =>
115+
expect(m.updateTeamSettings).toHaveBeenCalledWith({
116+
default_deployment_ttl_policy: 'permanent',
117+
}),
118+
)
119+
await waitFor(() => expect(screen.getByTestId('ttl-policy-saved')).toBeTruthy())
120+
})
121+
122+
it('saves "Auto-expire after 24h" when the user flips back from permanent', async () => {
123+
ctxOverride.tier = 'pro'
124+
// Start in permanent, then flip to auto_24h — covers save('auto_24h').
125+
m.getTeamSettings.mockResolvedValue({
126+
ok: true,
127+
settings: { default_deployment_ttl_policy: 'permanent' },
128+
})
129+
m.updateTeamSettings.mockResolvedValue({
130+
ok: true,
131+
settings: { default_deployment_ttl_policy: 'auto_24h' },
132+
})
133+
renderPage()
134+
await waitFor(() => expect(screen.getByTestId('ttl-policy-auto-24h')).toBeTruthy())
135+
const auto = screen.getByTestId('ttl-policy-auto-24h') as HTMLInputElement
136+
expect(auto.checked).toBe(false)
137+
fireEvent.click(auto)
138+
await waitFor(() =>
139+
expect(m.updateTeamSettings).toHaveBeenCalledWith({
140+
default_deployment_ttl_policy: 'auto_24h',
141+
}),
142+
)
143+
})
144+
145+
it('keeps the static help text visible', async () => {
146+
ctxOverride.tier = 'pro'
147+
renderPage()
148+
await waitFor(() => expect(screen.getByTestId('ttl-policy-help')).toBeTruthy())
149+
expect(screen.getByTestId('ttl-policy-help').textContent).toMatch(
150+
/applies to all NEW deploys.*Existing deploys keep their per-deploy setting/i,
151+
)
152+
})
153+
154+
it('disables "Custom hours" with a coming-soon tooltip even on paid tier', async () => {
155+
ctxOverride.tier = 'pro'
156+
renderPage()
157+
await waitFor(() => expect(screen.getByTestId('ttl-policy-custom')).toBeTruthy())
158+
const custom = screen.getByTestId('ttl-policy-custom') as HTMLInputElement
159+
expect(custom.disabled).toBe(true)
160+
161+
// Tooltip lives on the wrapping label so the user gets it on hover.
162+
const row = screen.getByTestId('ttl-policy-custom-row')
163+
expect(row.getAttribute('title')).toMatch(/coming soon/i)
164+
165+
// The hours text input is rendered but disabled.
166+
const input = screen.getByTestId('ttl-policy-custom-hours-input') as HTMLInputElement
167+
expect(input.disabled).toBe(true)
168+
})
169+
})
170+
171+
describe('DeployTtlPolicyCard — free tier (owner)', () => {
172+
it('renders the card with every radio disabled and an "Upgrade to change" tooltip', async () => {
173+
ctxOverride.tier = 'free'
174+
renderPage()
175+
176+
// Card visible (the surface stays so the user can SEE the current default).
177+
await waitFor(() => expect(screen.getByTestId('deploy-ttl-policy-card')).toBeTruthy())
178+
await waitFor(() => expect(screen.getByTestId('ttl-policy-auto-24h')).toBeTruthy())
179+
180+
const auto = screen.getByTestId('ttl-policy-auto-24h') as HTMLInputElement
181+
const perm = screen.getByTestId('ttl-policy-permanent') as HTMLInputElement
182+
const custom = screen.getByTestId('ttl-policy-custom') as HTMLInputElement
183+
184+
// Every radio disabled on free tier — server enforces too (rule 1).
185+
expect(auto.disabled).toBe(true)
186+
expect(perm.disabled).toBe(true)
187+
expect(custom.disabled).toBe(true)
188+
189+
// Tooltip via the wrapping label's title attribute.
190+
expect(screen.getByTestId('ttl-policy-auto-24h-row').getAttribute('title')).toBe(
191+
'Upgrade to change',
192+
)
193+
expect(screen.getByTestId('ttl-policy-permanent-row').getAttribute('title')).toBe(
194+
'Upgrade to change',
195+
)
196+
197+
// Upgrade hint card surfaces an explicit billing link.
198+
expect(screen.getByTestId('ttl-policy-upgrade-hint')).toBeTruthy()
199+
})
200+
201+
it('does NOT call updateTeamSettings when a disabled radio is clicked', async () => {
202+
ctxOverride.tier = 'free'
203+
renderPage()
204+
await waitFor(() => expect(screen.getByTestId('ttl-policy-permanent')).toBeTruthy())
205+
fireEvent.click(screen.getByTestId('ttl-policy-permanent'))
206+
// jsdom won't fire onChange on a disabled radio; even if it did, the
207+
// handler bails because !isPaidTier. Either way: no API call.
208+
await new Promise((r) => setTimeout(r, 50))
209+
expect(m.updateTeamSettings).not.toHaveBeenCalled()
210+
})
211+
})

0 commit comments

Comments
 (0)