dify/web/app/components/apps/hooks/use-apps-query-state.spec.ts

364 lines
11 KiB
TypeScript

/**
* Test suite for useAppsQueryState hook
*
* This hook manages app filtering state through URL search parameters, enabling:
* - Bookmarkable filter states (users can share URLs with specific filters active)
* - Browser history integration (back/forward buttons work with filters)
* - Multiple filter types: tagIDs, keywords, isCreatedByMe
*
* The hook syncs local filter state with URL search parameters, making filter
* navigation persistent and shareable across sessions.
*/
import { act, renderHook } from '@testing-library/react'
// Import the hook after mocks are set up
import useAppsQueryState from './use-apps-query-state'
// Mock Next.js navigation hooks
const mockPush = vi.fn()
const mockPathname = '/apps'
let mockSearchParams = new URLSearchParams()
vi.mock('next/navigation', () => ({
usePathname: vi.fn(() => mockPathname),
useRouter: vi.fn(() => ({
push: mockPush,
})),
useSearchParams: vi.fn(() => mockSearchParams),
}))
describe('useAppsQueryState', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSearchParams = new URLSearchParams()
})
describe('Basic functionality', () => {
it('should return query object and setQuery function', () => {
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query).toBeDefined()
expect(typeof result.current.setQuery).toBe('function')
})
it('should initialize with empty query when no search params exist', () => {
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.tagIDs).toBeUndefined()
expect(result.current.query.keywords).toBeUndefined()
expect(result.current.query.isCreatedByMe).toBe(false)
})
})
describe('Parsing search params', () => {
it('should parse tagIDs from URL', () => {
mockSearchParams.set('tagIDs', 'tag1;tag2;tag3')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2', 'tag3'])
})
it('should parse single tagID from URL', () => {
mockSearchParams.set('tagIDs', 'single-tag')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.tagIDs).toEqual(['single-tag'])
})
it('should parse keywords from URL', () => {
mockSearchParams.set('keywords', 'search term')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.keywords).toBe('search term')
})
it('should parse isCreatedByMe as true from URL', () => {
mockSearchParams.set('isCreatedByMe', 'true')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.isCreatedByMe).toBe(true)
})
it('should parse isCreatedByMe as false for other values', () => {
mockSearchParams.set('isCreatedByMe', 'false')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.isCreatedByMe).toBe(false)
})
it('should parse all params together', () => {
mockSearchParams.set('tagIDs', 'tag1;tag2')
mockSearchParams.set('keywords', 'test')
mockSearchParams.set('isCreatedByMe', 'true')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
expect(result.current.query.keywords).toBe('test')
expect(result.current.query.isCreatedByMe).toBe(true)
})
})
describe('Updating query state', () => {
it('should update keywords via setQuery', () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ keywords: 'new search' })
})
expect(result.current.query.keywords).toBe('new search')
})
it('should update tagIDs via setQuery', () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ tagIDs: ['tag1', 'tag2'] })
})
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
})
it('should update isCreatedByMe via setQuery', () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ isCreatedByMe: true })
})
expect(result.current.query.isCreatedByMe).toBe(true)
})
it('should support partial updates via callback', () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ keywords: 'initial' })
})
act(() => {
result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true }))
})
expect(result.current.query.keywords).toBe('initial')
expect(result.current.query.isCreatedByMe).toBe(true)
})
})
describe('URL synchronization', () => {
it('should sync keywords to URL', async () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ keywords: 'search' })
})
// Wait for useEffect to run
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(mockPush).toHaveBeenCalledWith(
expect.stringContaining('keywords=search'),
{ scroll: false },
)
})
it('should sync tagIDs to URL with semicolon separator', async () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ tagIDs: ['tag1', 'tag2'] })
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(mockPush).toHaveBeenCalledWith(
expect.stringContaining('tagIDs=tag1%3Btag2'),
{ scroll: false },
)
})
it('should sync isCreatedByMe to URL', async () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ isCreatedByMe: true })
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(mockPush).toHaveBeenCalledWith(
expect.stringContaining('isCreatedByMe=true'),
{ scroll: false },
)
})
it('should remove keywords from URL when empty', async () => {
mockSearchParams.set('keywords', 'existing')
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ keywords: '' })
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
// Should be called without keywords param
expect(mockPush).toHaveBeenCalled()
})
it('should remove tagIDs from URL when empty array', async () => {
mockSearchParams.set('tagIDs', 'tag1;tag2')
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ tagIDs: [] })
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(mockPush).toHaveBeenCalled()
})
it('should remove isCreatedByMe from URL when false', async () => {
mockSearchParams.set('isCreatedByMe', 'true')
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ isCreatedByMe: false })
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(mockPush).toHaveBeenCalled()
})
})
describe('Edge cases', () => {
it('should handle empty tagIDs string in URL', () => {
// NOTE: This test documents current behavior where ''.split(';') returns ['']
// This could potentially cause filtering issues as it's treated as a tag with empty name
// rather than absence of tags. Consider updating parseParams if this is problematic.
mockSearchParams.set('tagIDs', '')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.tagIDs).toEqual([''])
})
it('should handle empty keywords', () => {
mockSearchParams.set('keywords', '')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.keywords).toBeUndefined()
})
it('should handle undefined tagIDs', () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ tagIDs: undefined })
})
expect(result.current.query.tagIDs).toBeUndefined()
})
it('should handle special characters in keywords', () => {
// Use URLSearchParams constructor to properly simulate URL decoding behavior
// URLSearchParams.get() decodes URL-encoded characters
mockSearchParams = new URLSearchParams('keywords=test%20with%20spaces')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.keywords).toBe('test with spaces')
})
})
describe('Memoization', () => {
it('should return memoized object reference when query unchanged', () => {
const { result, rerender } = renderHook(() => useAppsQueryState())
const firstResult = result.current
rerender()
const secondResult = result.current
expect(firstResult.query).toBe(secondResult.query)
})
it('should return new object reference when query changes', () => {
const { result } = renderHook(() => useAppsQueryState())
const firstQuery = result.current.query
act(() => {
result.current.setQuery({ keywords: 'changed' })
})
expect(result.current.query).not.toBe(firstQuery)
})
})
describe('Integration scenarios', () => {
it('should handle sequential updates', async () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ keywords: 'first' })
})
act(() => {
result.current.setQuery(prev => ({ ...prev, tagIDs: ['tag1'] }))
})
act(() => {
result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true }))
})
expect(result.current.query.keywords).toBe('first')
expect(result.current.query.tagIDs).toEqual(['tag1'])
expect(result.current.query.isCreatedByMe).toBe(true)
})
it('should clear all filters', () => {
mockSearchParams.set('tagIDs', 'tag1;tag2')
mockSearchParams.set('keywords', 'search')
mockSearchParams.set('isCreatedByMe', 'true')
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({
tagIDs: undefined,
keywords: undefined,
isCreatedByMe: false,
})
})
expect(result.current.query.tagIDs).toBeUndefined()
expect(result.current.query.keywords).toBeUndefined()
expect(result.current.query.isCreatedByMe).toBe(false)
})
})
})