useDataApiRevokeOnCreateDefault.test.ts255 lines · main
1import { renderHook } from '@testing-library/react'
2import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
4import {
5 useDataApiRevokeOnCreateDefaultEnabled,
6 useTrackDefaultPrivilegesExposure,
7} from '../useDataApiRevokeOnCreateDefault'
8import { usePHFlag } from '@/hooks/ui/useFlag'
9import * as constants from '@/lib/constants'
10import { useTrack } from '@/lib/telemetry/track'
11
12vi.mock('@/hooks/ui/useFlag', () => ({
13 usePHFlag: vi.fn(),
14}))
15
16vi.mock('@/lib/constants', async () => {
17 const actual = await vi.importActual<typeof import('@/lib/constants')>('@/lib/constants')
18 return {
19 ...actual,
20 IS_TEST_ENV: false,
21 }
22})
23
24vi.mock('@/lib/telemetry/track', () => ({
25 useTrack: vi.fn(),
26}))
27
28describe('useDataApiRevokeOnCreateDefaultEnabled', () => {
29 afterEach(() => {
30 vi.restoreAllMocks()
31 vi.mocked(constants, { partial: true }).IS_TEST_ENV = false
32 })
33
34 it('returns false when the PostHog flag is undefined (not yet resolved)', () => {
35 vi.mocked(usePHFlag).mockReturnValue(undefined)
36 const { result } = renderHook(() => useDataApiRevokeOnCreateDefaultEnabled())
37 expect(result.current).toBe(false)
38 })
39
40 it('returns false when the PostHog flag is false', () => {
41 vi.mocked(usePHFlag).mockReturnValue(false)
42 const { result } = renderHook(() => useDataApiRevokeOnCreateDefaultEnabled())
43 expect(result.current).toBe(false)
44 })
45
46 it('returns true when the PostHog flag is true', () => {
47 vi.mocked(usePHFlag).mockReturnValue(true)
48 const { result } = renderHook(() => useDataApiRevokeOnCreateDefaultEnabled())
49 expect(result.current).toBe(true)
50 })
51
52 it('returns false in test env regardless of flag value', () => {
53 vi.mocked(constants, { partial: true }).IS_TEST_ENV = true
54 vi.mocked(usePHFlag).mockReturnValue(true)
55 const { result } = renderHook(() => useDataApiRevokeOnCreateDefaultEnabled())
56 expect(result.current).toBe(false)
57 })
58})
59
60describe('useTrackDefaultPrivilegesExposure', () => {
61 const track = vi.fn()
62
63 beforeEach(() => {
64 vi.mocked(useTrack).mockReturnValue(track)
65 })
66
67 afterEach(() => {
68 vi.clearAllMocks()
69 })
70
71 it('does not fire while the flag is undefined', () => {
72 vi.mocked(usePHFlag).mockReturnValue(undefined)
73 renderHook(() =>
74 useTrackDefaultPrivilegesExposure({
75 surface: 'main',
76 dataApiDefaultPrivileges: true,
77 hasUserModified: false,
78 })
79 )
80 expect(track).not.toHaveBeenCalled()
81 })
82
83 it('fires once when the flag resolves to true on the main surface', () => {
84 vi.mocked(usePHFlag).mockReturnValue(true)
85 renderHook(() =>
86 useTrackDefaultPrivilegesExposure({
87 surface: 'main',
88 dataApiDefaultPrivileges: false,
89 hasUserModified: false,
90 })
91 )
92 expect(track).toHaveBeenCalledTimes(1)
93 expect(track).toHaveBeenCalledWith(
94 'project_creation_default_privileges_exposed',
95 {
96 surface: 'main',
97 dataApiDefaultPrivileges: false,
98 dataApiRevokeOnCreateDefaultEnabled: true,
99 },
100 undefined
101 )
102 })
103
104 it('fires once when the flag resolves to false on the main surface', () => {
105 vi.mocked(usePHFlag).mockReturnValue(false)
106 renderHook(() =>
107 useTrackDefaultPrivilegesExposure({
108 surface: 'main',
109 dataApiDefaultPrivileges: true,
110 hasUserModified: false,
111 })
112 )
113 expect(track).toHaveBeenCalledTimes(1)
114 expect(track).toHaveBeenCalledWith(
115 'project_creation_default_privileges_exposed',
116 {
117 surface: 'main',
118 dataApiDefaultPrivileges: true,
119 dataApiRevokeOnCreateDefaultEnabled: false,
120 },
121 undefined
122 )
123 })
124
125 it('does not fire while the form value is stale relative to the flag (waits for sync)', () => {
126 // Race: flag just resolved to true (treatment), but the caller-side sync
127 // useEffect hasn't run yet, so the form value is still the legacy `true`.
128 // Without the convergence gate, exposure would fire with the wrong value.
129 vi.mocked(usePHFlag).mockReturnValue(true)
130 renderHook(() =>
131 useTrackDefaultPrivilegesExposure({
132 surface: 'main',
133 dataApiDefaultPrivileges: true,
134 hasUserModified: false,
135 })
136 )
137 expect(track).not.toHaveBeenCalled()
138 })
139
140 it('fires on the next render after the form syncs to match the flag', () => {
141 vi.mocked(usePHFlag).mockReturnValue(true)
142 const { rerender } = renderHook(
143 ({ dataApiDefaultPrivileges }: { dataApiDefaultPrivileges: boolean }) =>
144 useTrackDefaultPrivilegesExposure({
145 surface: 'main',
146 dataApiDefaultPrivileges,
147 hasUserModified: false,
148 }),
149 { initialProps: { dataApiDefaultPrivileges: true } }
150 )
151 expect(track).not.toHaveBeenCalled()
152
153 // Caller-side sync runs and updates the form value to !flag.
154 rerender({ dataApiDefaultPrivileges: false })
155 expect(track).toHaveBeenCalledTimes(1)
156 expect(track).toHaveBeenCalledWith(
157 'project_creation_default_privileges_exposed',
158 expect.objectContaining({
159 dataApiDefaultPrivileges: false,
160 dataApiRevokeOnCreateDefaultEnabled: true,
161 }),
162 undefined
163 )
164 })
165
166 it('fires immediately with the dirty value when the user has modified the field', () => {
167 // User toggled the checkbox before the flag resolved, dirtying the field.
168 // The sync gate is bypassed; exposure fires with the user's explicit value.
169 vi.mocked(usePHFlag).mockReturnValue(true)
170 renderHook(() =>
171 useTrackDefaultPrivilegesExposure({
172 surface: 'main',
173 dataApiDefaultPrivileges: true, // form value disagrees with !flag=false
174 hasUserModified: true,
175 })
176 )
177 expect(track).toHaveBeenCalledTimes(1)
178 expect(track).toHaveBeenCalledWith(
179 'project_creation_default_privileges_exposed',
180 {
181 surface: 'main',
182 dataApiDefaultPrivileges: true,
183 dataApiRevokeOnCreateDefaultEnabled: true,
184 },
185 undefined
186 )
187 })
188
189 it('fires on the vercel surface with the form-flag convergence gate', () => {
190 vi.mocked(usePHFlag).mockReturnValue(true)
191 renderHook(() =>
192 useTrackDefaultPrivilegesExposure({
193 surface: 'vercel',
194 orgSlug: 'acme-org',
195 dataApiDefaultPrivileges: false,
196 hasUserModified: false,
197 })
198 )
199 expect(track).toHaveBeenCalledWith(
200 'project_creation_default_privileges_exposed',
201 {
202 surface: 'vercel',
203 dataApiDefaultPrivileges: false,
204 dataApiRevokeOnCreateDefaultEnabled: true,
205 },
206 { organization: 'acme-org' }
207 )
208 })
209
210 it('skips emission on vercel surface when orgSlug is missing', () => {
211 vi.mocked(usePHFlag).mockReturnValue(true)
212 renderHook(() =>
213 useTrackDefaultPrivilegesExposure({
214 surface: 'vercel',
215 orgSlug: undefined,
216 dataApiDefaultPrivileges: false,
217 hasUserModified: false,
218 })
219 )
220 expect(track).not.toHaveBeenCalled()
221 })
222
223 it('deduplicates across re-renders', () => {
224 vi.mocked(usePHFlag).mockReturnValue(true)
225 const { rerender } = renderHook(() =>
226 useTrackDefaultPrivilegesExposure({
227 surface: 'main',
228 dataApiDefaultPrivileges: false,
229 hasUserModified: false,
230 })
231 )
232 rerender()
233 rerender()
234 expect(track).toHaveBeenCalledTimes(1)
235 })
236
237 it('does not re-fire if the flag flips after initial exposure', () => {
238 vi.mocked(usePHFlag).mockReturnValue(false)
239 const { rerender } = renderHook(() =>
240 useTrackDefaultPrivilegesExposure({
241 surface: 'main',
242 dataApiDefaultPrivileges: true,
243 hasUserModified: false,
244 })
245 )
246 vi.mocked(usePHFlag).mockReturnValue(true)
247 rerender()
248 expect(track).toHaveBeenCalledTimes(1)
249 expect(track).toHaveBeenCalledWith(
250 'project_creation_default_privileges_exposed',
251 expect.objectContaining({ dataApiRevokeOnCreateDefaultEnabled: false }),
252 undefined
253 )
254 })
255})