index.test.tsx407 lines · main
1import { render, screen, waitFor } from '@testing-library/react'
2import { LOCAL_STORAGE_KEYS } from 'common'
3import type { ReactNode } from 'react'
4import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
6import { MobileSheetProvider } from '../Navigation/NavigationBar/MobileSheetContext'
7import { ProjectLayout } from './index'
8import { STUDIO_PAGE_TITLE_SEPARATOR } from '@/lib/page-title'
9
10const { mockRouter, mockSetSelectedDatabaseId, mockSetMobileMenuOpen } = vi.hoisted(() => ({
11 mockRouter: {
12 pathname: '/project/[ref]/observability/query-performance',
13 asPath: '/project/default/observability/query-performance',
14 push: vi.fn(),
15 replace: vi.fn(),
16 },
17 mockSetSelectedDatabaseId: vi.fn(),
18 mockSetMobileMenuOpen: vi.fn(),
19}))
20
21const {
22 mockAddBanner,
23 mockDismissBanner,
24 mockProjectState,
25 mockResourceWarningsState,
26 mockBannerDismissedState,
27 mockUseLocalStorageQuery,
28} = vi.hoisted(() => ({
29 mockAddBanner: vi.fn(),
30 mockDismissBanner: vi.fn(),
31 mockProjectState: {
32 current: {
33 ref: 'default',
34 name: 'Project 1',
35 status: 'ACTIVE_HEALTHY',
36 postgrestStatus: 'ONLINE',
37 infra_compute_size: undefined as string | undefined,
38 integration_source: null as string | null,
39 },
40 },
41 mockResourceWarningsState: { current: undefined as any[] | undefined },
42 mockBannerDismissedState: { current: false },
43 mockUseLocalStorageQuery: vi.fn(),
44}))
45
46vi.mock('next/router', () => ({
47 useRouter: () => mockRouter,
48}))
49
50vi.mock('next/head', async () => {
51 const React = await import('react')
52
53 const Head = ({ children }: { children?: ReactNode }) => {
54 React.useEffect(() => {
55 const titleElement = React.Children.toArray(children).find(
56 (child) => React.isValidElement(child) && child.type === 'title'
57 )
58
59 if (!React.isValidElement<{ children: ReactNode }>(titleElement)) return
60
61 const titleText = React.Children.toArray(titleElement.props.children).join('')
62 document.title = titleText
63 }, [children])
64
65 return null
66 }
67
68 return { default: Head }
69})
70
71vi.mock('common', () => ({
72 useParams: () => ({ ref: 'default' }),
73 mergeRefs:
74 (..._refs: any[]) =>
75 (_value: unknown) => {},
76 IS_PLATFORM: false,
77 LOCAL_STORAGE_KEYS: {
78 FREE_MICRO_UPGRADE_BANNER_DISMISSED: (ref: string) =>
79 `free-micro-upgrade-banner-dismissed-${ref}`,
80 PROJECT_INTEGRATION_BANNER_DISMISSED: (ref: string, integrationSource: string) =>
81 `project-integration-banner-dismissed-${ref}-${integrationSource}`,
82 },
83 isFeatureEnabled: () => false,
84}))
85
86vi.mock('framer-motion', () => ({
87 AnimatePresence: ({ children }: { children: ReactNode }) => <>{children}</>,
88 motion: {
89 div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
90 create: (Component: any) => Component,
91 },
92}))
93
94vi.mock('ui', () => ({
95 cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
96 Alert: ({ children, ...props }: any) => <div {...props}>{children}</div>,
97 AlertDescription: ({ children, ...props }: any) => <div {...props}>{children}</div>,
98 AlertTitle: ({ children, ...props }: any) => <div {...props}>{children}</div>,
99 CommandInput: { displayName: 'CommandInput' },
100 Command: { displayName: 'Command' },
101 CommandGroup: { displayName: 'CommandGroup' },
102 CommandItem: { displayName: 'CommandItem' },
103 CommandList: { displayName: 'CommandList' },
104 LogoLoader: () => <div data-testid="logo-loader" />,
105 ResizableHandle: (props: any) => <div {...props} />,
106 ResizablePanel: ({ children, ...props }: any) => <div {...props}>{children}</div>,
107 ResizablePanelGroup: ({ children, ...props }: any) => <div {...props}>{children}</div>,
108 Sidebar: ({ children, ...props }: any) => <div {...props}>{children}</div>,
109 SidebarContent: ({ children, ...props }: any) => <div {...props}>{children}</div>,
110 SidebarFooter: ({ children, ...props }: any) => <div {...props}>{children}</div>,
111 SidebarGroup: ({ children, ...props }: any) => <div {...props}>{children}</div>,
112 SidebarMenu: ({ children, ...props }: any) => <div {...props}>{children}</div>,
113 SidebarMenuButton: (props: any) => <div {...props} />,
114 SidebarMenuItem: (props: any) => <div {...props} />,
115 useIsMobile: () => false,
116 usePanelRef: () => undefined,
117 useSidebar: () => ({ setOpen: vi.fn() }),
118}))
119
120vi.mock('ui-patterns/MobileSheetNav/MobileSheetNav', () => ({
121 default: ({ children }: { children: ReactNode }) => <>{children}</>,
122}))
123
124vi.mock('../editors/EditorsLayout.hooks', () => ({
125 useEditorType: () => undefined,
126}))
127
128vi.mock('../MainScrollContainerContext', () => ({
129 useSetMainScrollContainer: () => () => {},
130}))
131
132vi.mock('./BuildingState', () => ({ default: () => null }))
133vi.mock('./ConnectingState', () => ({ default: () => null }))
134vi.mock('./LoadingState', () => ({ LoadingState: () => null }))
135vi.mock('./PausedState/ProjectPausedState', () => ({ ProjectPausedState: () => null }))
136vi.mock('./PauseFailedState', () => ({ PauseFailedState: () => null }))
137vi.mock('./PausingState', () => ({ PausingState: () => null }))
138vi.mock('./ProductMenuBar', () => ({
139 default: ({ children }: { children: ReactNode }) => <>{children}</>,
140}))
141vi.mock('./ResizingState', () => ({ ResizingState: () => null }))
142vi.mock('./RestartingState', () => ({ default: () => null }))
143vi.mock('./RestoreFailedState', () => ({ RestoreFailedState: () => null }))
144vi.mock('./RestoringState', () => ({ RestoringState: () => null }))
145vi.mock('./UpgradingState', () => ({ UpgradingState: () => null }))
146
147vi.mock('@/components/interfaces/BranchManagement/CreateBranchModal', () => ({
148 CreateBranchModal: () => null,
149}))
150vi.mock('@/components/interfaces/ProjectAPIDocs/ProjectAPIDocs', () => ({
151 ProjectAPIDocs: () => null,
152}))
153vi.mock('@/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner', () => ({
154 ResourceExhaustionWarningBanner: () => null,
155}))
156vi.mock('@/components/ui/ButtonTooltip', () => ({
157 ButtonTooltip: ({ children, ...props }: any) => <button {...props}>{children}</button>,
158}))
159vi.mock('@/components/ui/PartnerIcon', () => ({
160 default: () => <div data-testid="partner-icon" />,
161}))
162
163vi.mock('@/hooks/custom-content/useCustomContent', () => ({
164 useCustomContent: () => ({ appTitle: 'Briven' }),
165}))
166
167vi.mock('@/hooks/misc/useLocalStorage', () => ({
168 useLocalStorageQuery: (...args: unknown[]) => mockUseLocalStorageQuery(...args),
169}))
170
171vi.mock('@/components/ui/BannerStack/BannerStackProvider', () => ({
172 BANNER_ID: { FREE_MICRO_UPGRADE: 'free-micro-upgrade-banner' },
173 useBannerStack: () => ({
174 addBanner: mockAddBanner,
175 dismissBanner: mockDismissBanner,
176 banners: [],
177 }),
178}))
179
180vi.mock('@/components/ui/BannerStack/Banners/BannerFreeMicroUpgrade', () => ({
181 BannerFreeMicroUpgrade: () => null,
182}))
183
184vi.mock('@/data/usage/resource-warnings-query', () => ({
185 useResourceWarningsQuery: () => ({ data: mockResourceWarningsState.current }),
186}))
187
188vi.mock('@/hooks/misc/useSelectedOrganization', () => ({
189 useSelectedOrganizationQuery: () => ({
190 data: { name: 'Organization 1', slug: 'org-1' },
191 }),
192}))
193
194vi.mock('@/hooks/misc/useSelectedProject', () => ({
195 useSelectedProjectQuery: () => ({ data: mockProjectState.current }),
196}))
197
198vi.mock('@/hooks/misc/withAuth', () => ({
199 withAuth: (Component: any) => Component,
200}))
201
202vi.mock('@/hooks/ui/useFlag', () => ({
203 usePHFlag: () => undefined,
204}))
205
206vi.mock('@/state/app-state', () => ({
207 useAppStateSnapshot: () => ({
208 mobileMenuOpen: false,
209 showSidebar: false,
210 setMobileMenuOpen: mockSetMobileMenuOpen,
211 }),
212}))
213
214vi.mock('@/state/database-selector', () => ({
215 useDatabaseSelectorStateSnapshot: () => ({
216 setSelectedDatabaseId: mockSetSelectedDatabaseId,
217 }),
218}))
219
220const renderLayout = () =>
221 render(
222 <MobileSheetProvider>
223 <ProjectLayout product="Database" isBlocking={false}>
224 <div />
225 </ProjectLayout>
226 </MobileSheetProvider>
227 )
228
229describe('ProjectLayout title', () => {
230 beforeEach(() => {
231 mockRouter.pathname = '/project/[ref]/observability/query-performance'
232 mockRouter.asPath = '/project/default/observability/query-performance'
233 document.title = ''
234 mockProjectState.current = {
235 ref: 'default',
236 name: 'Project 1',
237 status: 'ACTIVE_HEALTHY',
238 postgrestStatus: 'ONLINE',
239 infra_compute_size: undefined,
240 integration_source: null,
241 }
242 mockBannerDismissedState.current = false
243 mockUseLocalStorageQuery.mockImplementation(() => [mockBannerDismissedState.current, vi.fn()])
244 })
245
246 afterEach(() => {
247 vi.clearAllMocks()
248 document.title = ''
249 })
250
251 it('sets a composed document title and deduplicates identical section/surface labels', async () => {
252 render(
253 <MobileSheetProvider>
254 <ProjectLayout browserTitle={{ section: 'Settings' }} product="Settings" isBlocking={false}>
255 <div>Page Content</div>
256 </ProjectLayout>
257 </MobileSheetProvider>
258 )
259
260 await waitFor(() => {
261 expect(document.title).toBe(
262 ['Settings', 'Project 1', 'Organization 1', 'Briven'].join(STUDIO_PAGE_TITLE_SEPARATOR)
263 )
264 })
265 })
266
267 it('prefers entity-first browserTitle metadata when provided', async () => {
268 render(
269 <MobileSheetProvider>
270 <ProjectLayout
271 product="Database"
272 browserTitle={{ entity: 'users', section: 'Tables' }}
273 isBlocking={false}
274 >
275 <div>Page Content</div>
276 </ProjectLayout>
277 </MobileSheetProvider>
278 )
279
280 await waitFor(() => {
281 expect(document.title).toBe(
282 ['users', 'Tables', 'Database', 'Project 1', 'Organization 1', 'Briven'].join(
283 STUDIO_PAGE_TITLE_SEPARATOR
284 )
285 )
286 })
287 })
288
289 it('renders the Stripe project banner across project surfaces when the selected project is Stripe-connected', () => {
290 mockProjectState.current = {
291 ...mockProjectState.current,
292 integration_source: 'stripe_projects',
293 }
294
295 renderLayout()
296
297 expect(screen.getByText('This project is connected to Stripe')).toBeTruthy()
298 expect(
299 screen.getByText('Changes made here may affect your connected Stripe project.')
300 ).toBeTruthy()
301 expect(screen.getByTestId('partner-icon')).toBeTruthy()
302 })
303
304 it('uses a project-specific dismiss key for the Stripe project banner', () => {
305 mockProjectState.current = {
306 ...mockProjectState.current,
307 integration_source: 'stripe_projects',
308 }
309
310 renderLayout()
311
312 expect(mockUseLocalStorageQuery).toHaveBeenCalledWith(
313 LOCAL_STORAGE_KEYS.PROJECT_INTEGRATION_BANNER_DISMISSED('default', 'stripe_projects'),
314 false
315 )
316 })
317})
318
319describe('FREE_MICRO_UPGRADE banner', () => {
320 beforeEach(() => {
321 mockRouter.pathname = '/project/[ref]'
322 mockRouter.asPath = '/project/default'
323 mockProjectState.current = {
324 ref: 'default',
325 name: 'Project 1',
326 status: 'ACTIVE_HEALTHY',
327 postgrestStatus: 'ONLINE',
328 infra_compute_size: 'nano',
329 integration_source: null,
330 }
331 mockResourceWarningsState.current = [
332 {
333 project: 'default',
334 cpu_exhaustion: true,
335 memory_and_swap_exhaustion: false,
336 disk_space_exhaustion: false,
337 },
338 ]
339 mockBannerDismissedState.current = false
340 })
341
342 afterEach(() => {
343 vi.clearAllMocks()
344 mockRouter.pathname = '/project/[ref]/observability/query-performance'
345 mockRouter.asPath = '/project/default/observability/query-performance'
346 mockProjectState.current = {
347 ref: 'default',
348 name: 'Project 1',
349 status: 'ACTIVE_HEALTHY',
350 postgrestStatus: 'ONLINE',
351 infra_compute_size: undefined,
352 integration_source: null,
353 }
354 mockResourceWarningsState.current = undefined
355 mockBannerDismissedState.current = false
356 })
357
358 it('calls addBanner when project is nano and compute is near exhaustion', async () => {
359 renderLayout()
360
361 await waitFor(() => {
362 expect(mockAddBanner).toHaveBeenCalledWith(
363 expect.objectContaining({ id: 'free-micro-upgrade-banner' })
364 )
365 })
366 })
367
368 it('calls dismissBanner when banner was previously dismissed', async () => {
369 mockBannerDismissedState.current = true
370
371 renderLayout()
372
373 await waitFor(() => {
374 expect(mockDismissBanner).toHaveBeenCalledWith('free-micro-upgrade-banner')
375 })
376 expect(mockAddBanner).not.toHaveBeenCalled()
377 })
378
379 it('calls dismissBanner when compute warnings are cleared', async () => {
380 mockResourceWarningsState.current = [
381 {
382 project: 'default',
383 cpu_exhaustion: false,
384 memory_and_swap_exhaustion: false,
385 disk_space_exhaustion: false,
386 },
387 ]
388
389 renderLayout()
390
391 await waitFor(() => {
392 expect(mockDismissBanner).toHaveBeenCalledWith('free-micro-upgrade-banner')
393 })
394 expect(mockAddBanner).not.toHaveBeenCalled()
395 })
396
397 it('calls dismissBanner when project is not nano compute', async () => {
398 mockProjectState.current = { ...mockProjectState.current, infra_compute_size: 'micro' }
399
400 renderLayout()
401
402 await waitFor(() => {
403 expect(mockDismissBanner).toHaveBeenCalledWith('free-micro-upgrade-banner')
404 })
405 expect(mockAddBanner).not.toHaveBeenCalled()
406 })
407})