useDataApiRevokeOnCreateDefault.test.ts 7.8 KB

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