mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2026-01-15 17:34:44 +00:00
548 lines
14 KiB
TypeScript
548 lines
14 KiB
TypeScript
/* eslint-disable camelcase */ // Directus database fields use snake_case
|
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
|
|
import { render, screen, fireEvent, act } from '@testing-library/react'
|
|
import { MapContainer } from 'react-leaflet'
|
|
import { MemoryRouter } from 'react-router-dom'
|
|
import { toast } from 'react-toastify'
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
|
|
import { LocateControl } from './LocateControl'
|
|
|
|
import type { Item } from '#types/Item'
|
|
import type { ItemsApi } from '#types/ItemsApi'
|
|
import type { ItemType } from '#types/ItemType'
|
|
import type { LayerProps as Layer } from '#types/LayerProps'
|
|
import type { MarkerIcon } from '#types/MarkerIcon'
|
|
import type { Mock } from 'vitest'
|
|
|
|
interface User {
|
|
id: string
|
|
first_name: string
|
|
last_name: string
|
|
email: string
|
|
}
|
|
|
|
// Mock external dependencies
|
|
vi.mock('react-toastify', () => ({
|
|
toast: {
|
|
loading: vi.fn(() => 'toast-id'),
|
|
update: vi.fn(),
|
|
success: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
vi.mock('react-leaflet', async () => {
|
|
const actual = await vi.importActual('react-leaflet')
|
|
return {
|
|
...actual,
|
|
useMap: vi.fn(() => ({
|
|
closePopup: vi.fn(),
|
|
})),
|
|
useMapEvents: vi.fn((eventHandlers) => {
|
|
;(global as any).mockMapEventHandlers = eventHandlers
|
|
return null
|
|
}),
|
|
}
|
|
})
|
|
|
|
vi.mock('leaflet', () => ({
|
|
control: {
|
|
locate: vi.fn(() => ({
|
|
addTo: vi.fn(),
|
|
start: vi.fn(),
|
|
stop: vi.fn(),
|
|
})),
|
|
},
|
|
}))
|
|
|
|
vi.mock('#components/Auth/useAuth')
|
|
vi.mock('#components/Map/hooks/useMyProfile')
|
|
vi.mock('#components/Map/hooks/useItems')
|
|
vi.mock('#components/Map/hooks/useLayers')
|
|
|
|
const mockNavigate = vi.fn()
|
|
vi.mock('react-router-dom', async () => {
|
|
const actual = await vi.importActual('react-router-dom')
|
|
return {
|
|
...actual,
|
|
useNavigate: () => mockNavigate,
|
|
}
|
|
})
|
|
|
|
const mockUser: User = {
|
|
id: 'user-1',
|
|
first_name: 'John',
|
|
last_name: 'Doe',
|
|
email: 'john@example.com',
|
|
}
|
|
|
|
const mockApi: ItemsApi<Item> = {
|
|
getItems: vi.fn().mockResolvedValue([]),
|
|
getItem: vi.fn(),
|
|
createItem: vi.fn(),
|
|
updateItem: vi.fn(),
|
|
deleteItem: vi.fn(),
|
|
collectionName: 'test-collection',
|
|
}
|
|
|
|
const mockMarkerIcon: MarkerIcon = {
|
|
image: 'test-icon.svg',
|
|
size: 32,
|
|
}
|
|
|
|
const mockItemType: ItemType = {
|
|
name: 'user',
|
|
show_name_input: true,
|
|
show_profile_button: true,
|
|
show_start_end: false,
|
|
show_start_end_input: false,
|
|
show_text: true,
|
|
show_text_input: true,
|
|
custom_text: '',
|
|
profileTemplate: [],
|
|
offers_and_needs: false,
|
|
icon_as_labels: false,
|
|
relations: false,
|
|
template: 'simple',
|
|
questlog: false,
|
|
}
|
|
|
|
const mockLayer: Layer = {
|
|
id: 'layer-1',
|
|
name: 'Users',
|
|
menuColor: '#ff0000',
|
|
menuText: 'Users',
|
|
markerIcon: mockMarkerIcon,
|
|
markerShape: 'circle',
|
|
markerDefaultColor: '#ff0000',
|
|
itemType: mockItemType,
|
|
userProfileLayer: true,
|
|
api: mockApi,
|
|
}
|
|
|
|
const mockProfile: Item = {
|
|
id: 'profile-1',
|
|
name: 'John Doe',
|
|
position: {
|
|
type: 'Point',
|
|
coordinates: [10.0, 50.0],
|
|
},
|
|
user_created: mockUser,
|
|
layer: mockLayer,
|
|
}
|
|
|
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
|
<MemoryRouter>
|
|
<MapContainer center={[51.505, -0.09]} zoom={13}>
|
|
{children}
|
|
</MapContainer>
|
|
</MemoryRouter>
|
|
)
|
|
|
|
describe('<LocateControl />', () => {
|
|
let mockUseAuth: Mock
|
|
let mockUseMyProfile: Mock
|
|
let mockUseUpdateItem: Mock
|
|
let mockUseAddItem: Mock
|
|
let mockUseLayers: Mock
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks()
|
|
vi.useFakeTimers()
|
|
mockNavigate.mockClear()
|
|
;(global as any).mockMapEventHandlers = {}
|
|
|
|
const { useAuth } = await import('#components/Auth/useAuth')
|
|
const { useMyProfile } = await import('#components/Map/hooks/useMyProfile')
|
|
const { useUpdateItem, useAddItem } = await import('#components/Map/hooks/useItems')
|
|
const { useLayers } = await import('#components/Map/hooks/useLayers')
|
|
|
|
mockUseAuth = vi.mocked(useAuth)
|
|
mockUseMyProfile = vi.mocked(useMyProfile)
|
|
mockUseUpdateItem = vi.mocked(useUpdateItem)
|
|
mockUseAddItem = vi.mocked(useAddItem)
|
|
mockUseLayers = vi.mocked(useLayers)
|
|
|
|
mockUseAuth.mockReturnValue({ user: mockUser })
|
|
mockUseMyProfile.mockReturnValue({ myProfile: null, isMyProfileLoaded: true })
|
|
mockUseUpdateItem.mockReturnValue(vi.fn())
|
|
mockUseAddItem.mockReturnValue(vi.fn())
|
|
mockUseLayers.mockReturnValue([mockLayer])
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
describe('Component Rendering', () => {
|
|
it('renders the locate control button', () => {
|
|
render(
|
|
<TestWrapper>
|
|
<LocateControl />
|
|
</TestWrapper>,
|
|
)
|
|
|
|
const button = screen.getByRole('button', { name: /start location tracking/i })
|
|
expect(button).toBeInTheDocument()
|
|
})
|
|
|
|
it('displays target icon when not active', () => {
|
|
render(
|
|
<TestWrapper>
|
|
<LocateControl />
|
|
</TestWrapper>,
|
|
)
|
|
|
|
const button = screen.getByRole('button', { name: /start location tracking/i })
|
|
expect(button).toBeInTheDocument()
|
|
expect(button.querySelector('svg')).toBeInTheDocument()
|
|
})
|
|
|
|
it('matches snapshot', () => {
|
|
const { container } = render(
|
|
<TestWrapper>
|
|
<LocateControl />
|
|
</TestWrapper>,
|
|
)
|
|
expect(container.firstChild).toMatchSnapshot()
|
|
})
|
|
})
|
|
|
|
describe('Modal Display Logic', () => {
|
|
it('shows modal for new user without profile when location is found', () => {
|
|
mockUseMyProfile.mockReturnValue({ myProfile: null, isMyProfileLoaded: true })
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<LocateControl />
|
|
</TestWrapper>,
|
|
)
|
|
|
|
const locationEvent = {
|
|
latlng: { lat: 52.5, lng: 13.4, distanceTo: vi.fn(() => 200) },
|
|
}
|
|
|
|
act(() => {
|
|
;(global as any).mockMapEventHandlers?.locationfound?.(locationEvent)
|
|
})
|
|
|
|
act(() => {
|
|
vi.runAllTimers()
|
|
})
|
|
|
|
// Check if modal appears after timeout
|
|
expect(screen.getByText(/create your profile at your current location/i)).toBeInTheDocument()
|
|
})
|
|
|
|
it('shows modal for existing user when location is far from current position', () => {
|
|
const profileWithPosition = {
|
|
...mockProfile,
|
|
position: {
|
|
type: 'Point' as const,
|
|
coordinates: [10.0, 50.0],
|
|
},
|
|
}
|
|
mockUseMyProfile.mockReturnValue({ myProfile: profileWithPosition, isMyProfileLoaded: true })
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<LocateControl />
|
|
</TestWrapper>,
|
|
)
|
|
|
|
const locationEvent = {
|
|
latlng: {
|
|
lat: 52.5,
|
|
lng: 13.4,
|
|
distanceTo: vi.fn(() => 200),
|
|
},
|
|
}
|
|
|
|
act(() => {
|
|
;(global as any).mockMapEventHandlers?.locationfound?.(locationEvent)
|
|
})
|
|
|
|
act(() => {
|
|
vi.runAllTimers()
|
|
})
|
|
|
|
// Check if modal appears after timeout
|
|
expect(screen.getByText(/place your profile at your current location/i)).toBeInTheDocument()
|
|
})
|
|
|
|
it('does not show modal when distance is less than 100m', () => {
|
|
const profileWithPosition = {
|
|
...mockProfile,
|
|
position: {
|
|
type: 'Point' as const,
|
|
coordinates: [10.0, 50.0],
|
|
},
|
|
}
|
|
mockUseMyProfile.mockReturnValue({ myProfile: profileWithPosition, isMyProfileLoaded: true })
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<LocateControl />
|
|
</TestWrapper>,
|
|
)
|
|
|
|
// Mock distanceTo to return a distance < 100m
|
|
const locationEvent = {
|
|
latlng: {
|
|
lat: 50.001, // Very close to current position
|
|
lng: 10.001,
|
|
distanceTo: vi.fn(() => 50), // Distance less than 100m
|
|
},
|
|
}
|
|
|
|
act(() => {
|
|
;(global as any).mockMapEventHandlers?.locationfound?.(locationEvent)
|
|
})
|
|
|
|
act(() => {
|
|
vi.runAllTimers()
|
|
})
|
|
|
|
// Modal should not appear
|
|
expect(
|
|
screen.queryByText(/place your profile at your current location/i),
|
|
).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('does not show modal when location error occurs', () => {
|
|
mockUseMyProfile.mockReturnValue({ myProfile: null, isMyProfileLoaded: true })
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<LocateControl />
|
|
</TestWrapper>,
|
|
)
|
|
|
|
// Simulate location error
|
|
act(() => {
|
|
;(global as any).mockMapEventHandlers?.locationerror?.()
|
|
})
|
|
|
|
act(() => {
|
|
vi.runAllTimers()
|
|
})
|
|
|
|
// Modal should not appear
|
|
expect(
|
|
screen.queryByText(/create your profile at your current location/i),
|
|
).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Profile Creation', () => {
|
|
it('creates new profile when user has no existing profile', async () => {
|
|
const mockCreateItem = vi.fn().mockResolvedValue({
|
|
id: 'new-profile-1',
|
|
name: 'John',
|
|
position: { type: 'Point', coordinates: [13.4, 52.5] },
|
|
})
|
|
const mockAddItem = vi.fn()
|
|
|
|
mockApi.createItem = mockCreateItem
|
|
mockUseAddItem.mockReturnValue(mockAddItem)
|
|
mockUseMyProfile.mockReturnValue({ myProfile: null, isMyProfileLoaded: true })
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<LocateControl />
|
|
</TestWrapper>,
|
|
)
|
|
|
|
const locationEvent = {
|
|
latlng: { lat: 52.5, lng: 13.4, distanceTo: vi.fn(() => 200) },
|
|
}
|
|
|
|
act(() => {
|
|
;(global as any).mockMapEventHandlers?.locationfound?.(locationEvent)
|
|
})
|
|
|
|
act(() => {
|
|
vi.runAllTimers()
|
|
})
|
|
|
|
// Check if modal appears after timeout
|
|
expect(screen.getByText(/create your profile/i)).toBeInTheDocument()
|
|
|
|
const yesButton = screen.getByText('Yes')
|
|
|
|
await act(async () => {
|
|
fireEvent.click(yesButton)
|
|
// Allow promises to resolve
|
|
await vi.runAllTimersAsync()
|
|
})
|
|
|
|
// Verify API calls were made
|
|
expect(mockCreateItem).toHaveBeenCalled()
|
|
expect(mockAddItem).toHaveBeenCalled()
|
|
expect(toast.loading).toHaveBeenCalledWith('Creating profile at location')
|
|
})
|
|
|
|
it('updates existing profile position', async () => {
|
|
const mockUpdateItem = vi.fn().mockResolvedValue({
|
|
id: 'profile-1',
|
|
position: { type: 'Point', coordinates: [13.4, 52.5] },
|
|
})
|
|
const mockUpdateItemHook = vi.fn()
|
|
|
|
// Create a profile with a current position far from the new location
|
|
const profileWithCurrentPosition = {
|
|
...mockProfile,
|
|
position: {
|
|
type: 'Point' as const,
|
|
coordinates: [10.0, 50.0], // lng, lat format - far from 13.4, 52.5
|
|
},
|
|
}
|
|
|
|
if (profileWithCurrentPosition.layer?.api) {
|
|
profileWithCurrentPosition.layer.api.updateItem = mockUpdateItem
|
|
}
|
|
mockUseUpdateItem.mockReturnValue(mockUpdateItemHook)
|
|
mockUseMyProfile.mockReturnValue({
|
|
myProfile: profileWithCurrentPosition,
|
|
isMyProfileLoaded: true,
|
|
})
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<LocateControl />
|
|
</TestWrapper>,
|
|
)
|
|
|
|
// Mock distanceTo to return a distance > 100m
|
|
const mockDistanceTo = vi.fn(() => 200)
|
|
const locationEvent = {
|
|
latlng: { lat: 52.5, lng: 13.4, distanceTo: mockDistanceTo },
|
|
}
|
|
act(() => {
|
|
;(global as any).mockMapEventHandlers?.locationfound?.(locationEvent)
|
|
})
|
|
|
|
// Verify distanceTo was called with swapped coordinates [lat, lng]
|
|
// Verify distanceTo was called with swapped coordinates [lat, lng]
|
|
expect(mockDistanceTo).toHaveBeenCalledWith([50.0, 10.0])
|
|
|
|
act(() => {
|
|
vi.runAllTimers()
|
|
})
|
|
|
|
// Check if modal appears after timeout
|
|
expect(screen.getByText(/place your profile/i)).toBeInTheDocument()
|
|
|
|
// Find the Yes button by text content instead of role
|
|
const yesButton = screen.getByText('Yes')
|
|
|
|
await act(async () => {
|
|
fireEvent.click(yesButton)
|
|
// Allow promises to resolve
|
|
await vi.runAllTimersAsync()
|
|
})
|
|
|
|
// Verify API calls were made
|
|
expect(mockUpdateItem).toHaveBeenCalled()
|
|
expect(mockUpdateItemHook).toHaveBeenCalled()
|
|
expect(toast.loading).toHaveBeenCalledWith('Updating position')
|
|
})
|
|
})
|
|
|
|
describe('Navigation', () => {
|
|
it('navigates to profile after successful creation', async () => {
|
|
const mockCreateItem = vi.fn().mockResolvedValue({
|
|
id: 'new-profile-1',
|
|
name: 'John',
|
|
position: { type: 'Point', coordinates: [13.4, 52.5] },
|
|
})
|
|
|
|
mockApi.createItem = mockCreateItem
|
|
mockUseMyProfile.mockReturnValue({ myProfile: null, isMyProfileLoaded: true })
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<LocateControl />
|
|
</TestWrapper>,
|
|
)
|
|
|
|
const locationEvent = {
|
|
latlng: { lat: 52.5, lng: 13.4, distanceTo: vi.fn(() => 200) },
|
|
}
|
|
|
|
act(() => {
|
|
;(global as any).mockMapEventHandlers?.locationfound?.(locationEvent)
|
|
})
|
|
|
|
act(() => {
|
|
vi.runAllTimers()
|
|
})
|
|
|
|
// Check if modal appears after timeout
|
|
expect(screen.getByText(/create your profile/i)).toBeInTheDocument()
|
|
|
|
const yesButton = screen.getByText('Yes')
|
|
|
|
await act(async () => {
|
|
fireEvent.click(yesButton)
|
|
// Allow promises to resolve
|
|
await vi.runAllTimersAsync()
|
|
})
|
|
|
|
// Verify navigation was called
|
|
expect(mockNavigate).toHaveBeenCalledWith('/new-profile-1')
|
|
})
|
|
})
|
|
|
|
describe('Error Handling', () => {
|
|
it('handles API errors gracefully', async () => {
|
|
const mockCreateItem = vi.fn().mockRejectedValue(new Error('Network error'))
|
|
mockApi.createItem = mockCreateItem
|
|
mockUseMyProfile.mockReturnValue({ myProfile: null, isMyProfileLoaded: true })
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<LocateControl />
|
|
</TestWrapper>,
|
|
)
|
|
|
|
const locationEvent = {
|
|
latlng: { lat: 52.5, lng: 13.4, distanceTo: vi.fn(() => 200) },
|
|
}
|
|
|
|
act(() => {
|
|
;(global as any).mockMapEventHandlers?.locationfound?.(locationEvent)
|
|
})
|
|
|
|
act(() => {
|
|
vi.runAllTimers()
|
|
})
|
|
|
|
// Check if modal appears after timeout
|
|
expect(screen.getByText(/create your profile/i)).toBeInTheDocument()
|
|
|
|
const yesButton = screen.getByText('Yes')
|
|
|
|
await act(async () => {
|
|
fireEvent.click(yesButton)
|
|
// Allow promises to resolve
|
|
await vi.runAllTimersAsync()
|
|
})
|
|
|
|
// Verify error toast was shown
|
|
expect(toast.update).toHaveBeenCalledWith('toast-id', {
|
|
render: 'Network error',
|
|
type: 'error',
|
|
isLoading: false,
|
|
autoClose: 5000,
|
|
closeButton: true,
|
|
})
|
|
})
|
|
})
|
|
})
|