diff --git a/frontend/src/features/news/components/__tests__/AddNewsForm.test.tsx b/frontend/src/features/news/components/__tests__/AddNewsForm.test.tsx
new file mode 100644
index 0000000..e8dc879
--- /dev/null
+++ b/frontend/src/features/news/components/__tests__/AddNewsForm.test.tsx
@@ -0,0 +1,517 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { AddNewsForm } from '../AddNewsForm'
+import { NewsCategory } from '../../data/news.schema'
+import type { CreateNewsRequest } from '../../data/news.schema'
+
+describe('AddNewsForm', () => {
+ const mockOnSubmit = vi.fn()
+ const mockOnCancel = vi.fn()
+
+ const defaultProps = {
+ onSubmit: mockOnSubmit,
+ isSubmitting: false,
+ onCancel: mockOnCancel
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render all form fields with correct labels and placeholders', () => {
+ render(
)
+
+ // Required fields
+ expect(screen.getByLabelText(/source \*/i)).toBeInTheDocument()
+ expect(screen.getByPlaceholderText(/techcrunch, medium/i)).toBeInTheDocument()
+
+ expect(screen.getByLabelText(/title \*/i)).toBeInTheDocument()
+ expect(screen.getByPlaceholderText(/article title/i)).toBeInTheDocument()
+
+ expect(screen.getByLabelText(/summary \*/i)).toBeInTheDocument()
+ expect(screen.getByPlaceholderText(/brief summary of the article/i)).toBeInTheDocument()
+
+ expect(screen.getByLabelText(/link \*/i)).toBeInTheDocument()
+ expect(screen.getByPlaceholderText(/https:\/\/example\.com\/article/i)).toBeInTheDocument()
+
+ // Optional fields
+ expect(screen.getByLabelText(/image url \(optional\)/i)).toBeInTheDocument()
+ expect(screen.getByPlaceholderText(/https:\/\/example\.com\/image\.jpg/i)).toBeInTheDocument()
+
+ expect(screen.getByLabelText(/category \*/i)).toBeInTheDocument()
+ expect(screen.getByText(/select a category/i)).toBeInTheDocument()
+
+ expect(screen.getByLabelText(/make this article public/i)).toBeInTheDocument()
+ })
+
+ it('should render form buttons with correct labels', () => {
+ render(
)
+
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /create news/i })).toBeInTheDocument()
+ })
+
+ it('should have correct form structure and accessibility attributes', () => {
+ render(
)
+
+ const form = screen.getByRole('form')
+ expect(form).toBeInTheDocument()
+
+ // Check required field attributes
+ expect(screen.getByLabelText(/source \*/i)).toHaveAttribute('aria-required', 'true')
+ expect(screen.getByLabelText(/title \*/i)).toHaveAttribute('aria-required', 'true')
+ expect(screen.getByLabelText(/summary \*/i)).toHaveAttribute('aria-required', 'true')
+ expect(screen.getByLabelText(/link \*/i)).toHaveAttribute('aria-required', 'true')
+ })
+ })
+
+ describe('Form Validation', () => {
+ describe('Required Fields Validation', () => {
+ it('should show validation errors for empty required fields when submitting', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ const submitButton = screen.getByRole('button', { name: /create news/i })
+ await user.click(submitButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Source is required')).toBeInTheDocument()
+ expect(screen.getByText('Title is required')).toBeInTheDocument()
+ expect(screen.getByText('Summary is required')).toBeInTheDocument()
+ expect(screen.getByText('Link is required')).toBeInTheDocument()
+ })
+
+ expect(mockOnSubmit).not.toHaveBeenCalled()
+ })
+
+ it('should validate field length limits', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ // Test source length limit (100 characters)
+ const sourceInput = screen.getByLabelText(/source \*/i)
+ await user.clear(sourceInput)
+ await user.type(sourceInput, 'A'.repeat(101))
+
+ const submitButton = screen.getByRole('button', { name: /create news/i })
+ await user.click(submitButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Source must be less than 100 characters')).toBeInTheDocument()
+ })
+
+ // Test title length limit (200 characters)
+ await user.clear(sourceInput)
+ await user.type(sourceInput, 'Valid source')
+
+ const titleInput = screen.getByLabelText(/title \*/i)
+ await user.type(titleInput, 'A'.repeat(201))
+
+ await user.click(submitButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Title must be less than 200 characters')).toBeInTheDocument()
+ })
+
+ // Test summary length limit (500 characters)
+ await user.clear(titleInput)
+ await user.type(titleInput, 'Valid title')
+
+ const summaryInput = screen.getByLabelText(/summary \*/i)
+ await user.type(summaryInput, 'A'.repeat(501))
+
+ await user.click(submitButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Summary must be less than 500 characters')).toBeInTheDocument()
+ })
+ })
+
+ it('should validate URL format for link field', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ const linkInput = screen.getByLabelText(/link \*/i)
+
+ // Test invalid URL
+ await user.type(linkInput, 'invalid-url')
+
+ const submitButton = screen.getByRole('button', { name: /create news/i })
+ await user.click(submitButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Please enter a valid URL')).toBeInTheDocument()
+ })
+
+ expect(mockOnSubmit).not.toHaveBeenCalled()
+ })
+
+ it('should validate URL format for optional image_url field', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ // Fill required fields with valid data
+ await user.type(screen.getByLabelText(/source \*/i), 'TechCrunch')
+ await user.type(screen.getByLabelText(/title \*/i), 'Test Article')
+ await user.type(screen.getByLabelText(/summary \*/i), 'Test summary')
+ await user.type(screen.getByLabelText(/link \*/i), 'https://example.com')
+
+ // Test invalid image URL
+ const imageUrlInput = screen.getByLabelText(/image url/i)
+ await user.type(imageUrlInput, 'invalid-image-url')
+
+ const submitButton = screen.getByRole('button', { name: /create news/i })
+ await user.click(submitButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Please enter a valid URL')).toBeInTheDocument()
+ })
+
+ expect(mockOnSubmit).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Real-time Validation', () => {
+ it('should clear validation errors when user starts typing in a field', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ // First trigger validation error
+ const submitButton = screen.getByRole('button', { name: /create news/i })
+ await user.click(submitButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Source is required')).toBeInTheDocument()
+ })
+
+ // Then start typing in the field
+ const sourceInput = screen.getByLabelText(/source \*/i)
+ await user.type(sourceInput, 'T')
+
+ await waitFor(() => {
+ expect(screen.queryByText('Source is required')).not.toBeInTheDocument()
+ })
+ })
+ })
+ })
+
+ describe('Form Interactions', () => {
+ describe('Category Selection', () => {
+ it('should render all category options in the dropdown', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ const categoryTrigger = screen.getByRole('combobox')
+ await user.click(categoryTrigger)
+
+ const dropdown = screen.getByRole('listbox')
+ expect(within(dropdown).getByText('General')).toBeInTheDocument()
+ expect(within(dropdown).getByText('Research')).toBeInTheDocument()
+ expect(within(dropdown).getByText('Product')).toBeInTheDocument()
+ expect(within(dropdown).getByText('Company')).toBeInTheDocument()
+ expect(within(dropdown).getByText('Tutorial')).toBeInTheDocument()
+ expect(within(dropdown).getByText('Opinion')).toBeInTheDocument()
+ })
+
+ it('should select a category and update the form', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ const categoryTrigger = screen.getByRole('combobox')
+ await user.click(categoryTrigger)
+
+ const researchOption = screen.getByText('Research')
+ await user.click(researchOption)
+
+ expect(categoryTrigger).toHaveTextContent('Research')
+ })
+
+ it('should default to General category', () => {
+ render(
)
+
+ const categoryTrigger = screen.getByRole('combobox')
+ expect(categoryTrigger).toHaveTextContent('General')
+ })
+ })
+
+ describe('Public Checkbox', () => {
+ it('should toggle public checkbox state', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ const publicCheckbox = screen.getByLabelText(/make this article public/i)
+ expect(publicCheckbox).not.toBeChecked()
+
+ await user.click(publicCheckbox)
+ expect(publicCheckbox).toBeChecked()
+
+ await user.click(publicCheckbox)
+ expect(publicCheckbox).not.toBeChecked()
+ })
+
+ it('should default to false for is_public', () => {
+ render(
)
+
+ const publicCheckbox = screen.getByLabelText(/make this article public/i)
+ expect(publicCheckbox).not.toBeChecked()
+ })
+ })
+ })
+
+ describe('Form Submission', () => {
+ const validFormData: CreateNewsRequest = {
+ source: 'TechCrunch',
+ title: 'Test Article',
+ summary: 'This is a test article summary',
+ link: 'https://example.com/article',
+ image_url: 'https://example.com/image.jpg',
+ category: NewsCategory.RESEARCH,
+ is_public: true
+ }
+
+ it('should submit form with valid data', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ // Fill all fields
+ await user.type(screen.getByLabelText(/source \*/i), validFormData.source)
+ await user.type(screen.getByLabelText(/title \*/i), validFormData.title)
+ await user.type(screen.getByLabelText(/summary \*/i), validFormData.summary)
+ await user.type(screen.getByLabelText(/link \*/i), validFormData.link)
+ await user.type(screen.getByLabelText(/image url/i), validFormData.image_url!)
+
+ // Select category
+ const categoryTrigger = screen.getByRole('combobox')
+ await user.click(categoryTrigger)
+ await user.click(screen.getByText('Research'))
+
+ // Check public checkbox
+ await user.click(screen.getByLabelText(/make this article public/i))
+
+ // Submit form
+ const submitButton = screen.getByRole('button', { name: /create news/i })
+ await user.click(submitButton)
+
+ await waitFor(() => {
+ expect(mockOnSubmit).toHaveBeenCalledWith(validFormData)
+ })
+ })
+
+ it('should submit form without optional fields', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ const minimalFormData = {
+ source: 'TechCrunch',
+ title: 'Test Article',
+ summary: 'This is a test article summary',
+ link: 'https://example.com/article',
+ image_url: undefined, // Should be cleaned up to undefined
+ category: NewsCategory.GENERAL,
+ is_public: false
+ }
+
+ // Fill required fields only
+ await user.type(screen.getByLabelText(/source \*/i), minimalFormData.source)
+ await user.type(screen.getByLabelText(/title \*/i), minimalFormData.title)
+ await user.type(screen.getByLabelText(/summary \*/i), minimalFormData.summary)
+ await user.type(screen.getByLabelText(/link \*/i), minimalFormData.link)
+
+ const submitButton = screen.getByRole('button', { name: /create news/i })
+ await user.click(submitButton)
+
+ await waitFor(() => {
+ expect(mockOnSubmit).toHaveBeenCalledWith(minimalFormData)
+ })
+ })
+
+ it('should clean up empty image_url field before submission', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ // Fill required fields and leave image_url as empty string
+ await user.type(screen.getByLabelText(/source \*/i), 'TechCrunch')
+ await user.type(screen.getByLabelText(/title \*/i), 'Test Article')
+ await user.type(screen.getByLabelText(/summary \*/i), 'Test summary')
+ await user.type(screen.getByLabelText(/link \*/i), 'https://example.com')
+
+ // Type something then delete it to have empty string
+ const imageUrlInput = screen.getByLabelText(/image url/i)
+ await user.type(imageUrlInput, 'test')
+ await user.clear(imageUrlInput)
+
+ const submitButton = screen.getByRole('button', { name: /create news/i })
+ await user.click(submitButton)
+
+ await waitFor(() => {
+ expect(mockOnSubmit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ image_url: undefined
+ })
+ )
+ })
+ })
+ })
+
+ describe('Form States', () => {
+ it('should disable submit button when form is invalid', () => {
+ render(
)
+
+ const submitButton = screen.getByRole('button', { name: /create news/i })
+ expect(submitButton).toBeDisabled()
+ })
+
+ it('should enable submit button when all required fields are filled', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ const submitButton = screen.getByRole('button', { name: /create news/i })
+ expect(submitButton).toBeDisabled()
+
+ // Fill all required fields
+ await user.type(screen.getByLabelText(/source \*/i), 'TechCrunch')
+ await user.type(screen.getByLabelText(/title \*/i), 'Test Article')
+ await user.type(screen.getByLabelText(/summary \*/i), 'Test summary')
+ await user.type(screen.getByLabelText(/link \*/i), 'https://example.com')
+
+ await waitFor(() => {
+ expect(submitButton).toBeEnabled()
+ })
+ })
+
+ it('should disable buttons and show loading state when submitting', () => {
+ render(
)
+
+ const submitButton = screen.getByRole('button', { name: /creating\.\.\./i })
+ const cancelButton = screen.getByRole('button', { name: /cancel/i })
+
+ expect(submitButton).toBeDisabled()
+ expect(cancelButton).toBeDisabled()
+ expect(submitButton).toHaveTextContent('Creating...')
+ })
+ })
+
+ describe('Form Actions', () => {
+ it('should call onCancel when cancel button is clicked', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ const cancelButton = screen.getByRole('button', { name: /cancel/i })
+ await user.click(cancelButton)
+
+ expect(mockOnCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should not call onCancel when cancel button is disabled during submission', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ const cancelButton = screen.getByRole('button', { name: /cancel/i })
+
+ // Try to click disabled button
+ await user.click(cancelButton)
+
+ expect(mockOnCancel).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Accessibility', () => {
+ it('should have proper ARIA attributes for form validation', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ // Trigger validation
+ const submitButton = screen.getByRole('button', { name: /create news/i })
+ await user.click(submitButton)
+
+ await waitFor(() => {
+ const sourceInput = screen.getByLabelText(/source \*/i)
+ expect(sourceInput).toHaveAttribute('aria-invalid', 'true')
+ })
+
+ // Check error messages have role="alert"
+ const errorMessage = screen.getByText('Source is required')
+ expect(errorMessage).toHaveAttribute('role', 'alert')
+ })
+
+ it('should update aria-invalid when validation state changes', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ const sourceInput = screen.getByLabelText(/source \*/i)
+
+ // Initially should be valid
+ expect(sourceInput).toHaveAttribute('aria-invalid', 'false')
+
+ // Trigger validation error
+ const submitButton = screen.getByRole('button', { name: /create news/i })
+ await user.click(submitButton)
+
+ await waitFor(() => {
+ expect(sourceInput).toHaveAttribute('aria-invalid', 'true')
+ })
+
+ // Fix the error
+ await user.type(sourceInput, 'Valid source')
+
+ await waitFor(() => {
+ expect(sourceInput).toHaveAttribute('aria-invalid', 'false')
+ })
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle whitespace-only input as empty for required fields', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ // Fill with whitespace only
+ await user.type(screen.getByLabelText(/source \*/i), ' ')
+
+ const submitButton = screen.getByRole('button', { name: /create news/i })
+ await user.click(submitButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Source is required')).toBeInTheDocument()
+ })
+ })
+
+ it('should handle special characters in URLs', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ // Fill required fields
+ await user.type(screen.getByLabelText(/source \*/i), 'TechCrunch')
+ await user.type(screen.getByLabelText(/title \*/i), 'Test Article')
+ await user.type(screen.getByLabelText(/summary \*/i), 'Test summary')
+
+ // URL with special characters
+ const complexUrl = 'https://example.com/article?id=123&category=tech#section1'
+ await user.type(screen.getByLabelText(/link \*/i), complexUrl)
+
+ const submitButton = screen.getByRole('button', { name: /create news/i })
+ await user.click(submitButton)
+
+ await waitFor(() => {
+ expect(mockOnSubmit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ link: complexUrl
+ })
+ )
+ })
+ })
+
+ it('should handle form reset when component unmounts during submission', () => {
+ const { unmount } = render(
)
+
+ // Should not throw errors when unmounting during submission
+ expect(() => unmount()).not.toThrow()
+ })
+ })
+})
\ No newline at end of file
diff --git a/frontend/src/features/news/components/__tests__/AddNewsModal.test.tsx b/frontend/src/features/news/components/__tests__/AddNewsModal.test.tsx
new file mode 100644
index 0000000..2ccb3bf
--- /dev/null
+++ b/frontend/src/features/news/components/__tests__/AddNewsModal.test.tsx
@@ -0,0 +1,691 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import React, { type ReactNode } from 'react'
+import { toast } from 'sonner'
+import { AddNewsModal } from '../AddNewsModal'
+import { NewsCategory } from '../../data/news.schema'
+
+// Mock the mutation hook
+vi.mock('../../hooks/mutations/useCreateNews.mutation')
+vi.mock('sonner')
+
+import { useCreateNewsMutation } from '../../hooks/mutations/useCreateNews.mutation'
+
+describe('AddNewsModal', () => {
+ let queryClient: QueryClient
+ const mockCreateNews = vi.fn()
+ const mockUseCreateNewsMutation = useCreateNewsMutation as any
+
+ const createWrapper = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ )
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false }
+ }
+ })
+
+ // Default mock implementation
+ mockUseCreateNewsMutation.mockReturnValue({
+ createNews: mockCreateNews,
+ isLoading: false,
+ error: null,
+ isSuccess: false
+ })
+
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render with default trigger button', () => {
+ render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+ expect(triggerButton).toBeInTheDocument()
+ expect(triggerButton).toHaveTextContent('Add News')
+ })
+
+ it('should render with custom trigger content', () => {
+ const customTrigger =
+
+ render(
+
+ {customTrigger}
+ ,
+ { wrapper: createWrapper }
+ )
+
+ expect(screen.getByRole('button', { name: /custom add button/i })).toBeInTheDocument()
+ expect(screen.queryByText('Add News')).not.toBeInTheDocument()
+ })
+
+ it('should apply custom className, size, and variant props to default trigger', () => {
+ render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+ expect(triggerButton).toHaveClass('custom-class')
+ })
+
+ it('should show plus icon and responsive text in default trigger', () => {
+ render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+
+ // Plus icon should be present
+ const plusIcon = within(triggerButton).getByRole('img', { hidden: true })
+ expect(plusIcon).toBeInTheDocument()
+
+ // Text should have responsive classes (hidden on small screens)
+ const textSpan = within(triggerButton).getByText('Add News')
+ expect(textSpan).toHaveClass('hidden', 'sm:inline')
+ })
+ })
+
+ describe('Modal Interactions', () => {
+ it('should open modal when trigger button is clicked', async () => {
+ const user = userEvent.setup()
+ render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+ await user.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Add News Item')).toBeInTheDocument()
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+ })
+
+ it('should close modal when escape key is pressed', async () => {
+ const user = userEvent.setup()
+ render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ // Open modal
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+ await user.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // Press Escape
+ await user.keyboard('{Escape}')
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should close modal when clicking outside the dialog', async () => {
+ const user = userEvent.setup()
+ render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ // Open modal
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+ await user.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // Click on overlay (outside dialog)
+ const overlay = screen.getByRole('dialog').parentElement
+ if (overlay) {
+ await user.click(overlay)
+ }
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should close modal when cancel button in form is clicked', async () => {
+ const user = userEvent.setup()
+ render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ // Open modal
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+ await user.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // Click cancel button in form
+ const cancelButton = screen.getByRole('button', { name: /cancel/i })
+ await user.click(cancelButton)
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Form Integration', () => {
+ it('should render AddNewsForm inside the modal', async () => {
+ const user = userEvent.setup()
+ render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ // Open modal
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+ await user.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // Check that form elements are present
+ expect(screen.getByLabelText(/source \*/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/title \*/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/summary \*/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/link \*/i)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /create news/i })).toBeInTheDocument()
+ })
+
+ it('should pass isSubmitting state to form', async () => {
+ const user = userEvent.setup()
+
+ // Mock loading state
+ mockUseCreateNewsMutation.mockReturnValue({
+ createNews: mockCreateNews,
+ isLoading: true,
+ error: null,
+ isSuccess: false
+ })
+
+ render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ // Open modal
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+ await user.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /creating\.\.\./i })).toBeInTheDocument()
+ })
+ })
+
+ it('should call createNews mutation when form is submitted with valid data', async () => {
+ const user = userEvent.setup()
+ render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ // Open modal
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+ await user.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // Fill form with valid data
+ await user.type(screen.getByLabelText(/source \*/i), 'TechCrunch')
+ await user.type(screen.getByLabelText(/title \*/i), 'Test Article')
+ await user.type(screen.getByLabelText(/summary \*/i), 'Test summary')
+ await user.type(screen.getByLabelText(/link \*/i), 'https://example.com')
+
+ // Submit form
+ const submitButton = screen.getByRole('button', { name: /create news/i })
+ await user.click(submitButton)
+
+ expect(mockCreateNews).toHaveBeenCalledWith({
+ source: 'TechCrunch',
+ title: 'Test Article',
+ summary: 'Test summary',
+ link: 'https://example.com',
+ image_url: undefined,
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+ })
+ })
+
+ describe('Success Handling', () => {
+ it('should close modal automatically when mutation is successful', async () => {
+ const user = userEvent.setup()
+
+ // Initially render with success = false
+ let mockReturnValue = {
+ createNews: mockCreateNews,
+ isLoading: false,
+ error: null,
+ isSuccess: false
+ }
+ mockUseCreateNewsMutation.mockReturnValue(mockReturnValue)
+
+ const { rerender } = render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ // Open modal
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+ await user.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // Simulate success
+ mockReturnValue.isSuccess = true
+ mockUseCreateNewsMutation.mockReturnValue(mockReturnValue)
+
+ rerender(
+
+ )
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should reset modal state when reopened after successful creation', async () => {
+ const user = userEvent.setup()
+
+ // Mock successful state
+ mockUseCreateNewsMutation.mockReturnValue({
+ createNews: mockCreateNews,
+ isLoading: false,
+ error: null,
+ isSuccess: true
+ })
+
+ render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ // Open modal (should close immediately due to success state)
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+ await user.click(triggerButton)
+
+ // Reset success state for next render
+ mockUseCreateNewsMutation.mockReturnValue({
+ createNews: mockCreateNews,
+ isLoading: false,
+ error: null,
+ isSuccess: false
+ })
+
+ // Open modal again
+ await user.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ // Form should be in clean state
+ expect(screen.getByLabelText(/source \*/i)).toHaveValue('')
+ })
+ })
+ })
+
+ describe('Error Handling', () => {
+ it('should display error message when mutation fails', async () => {
+ const user = userEvent.setup()
+ const errorMessage = 'Failed to create news item'
+
+ mockUseCreateNewsMutation.mockReturnValue({
+ createNews: mockCreateNews,
+ isLoading: false,
+ error: new Error(errorMessage),
+ isSuccess: false
+ })
+
+ render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ // Open modal
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+ await user.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).toBeInTheDocument()
+ expect(screen.getByText(`Error: ${errorMessage}`)).toBeInTheDocument()
+ })
+ })
+
+ it('should not close modal when there is an error', async () => {
+ const user = userEvent.setup()
+
+ mockUseCreateNewsMutation.mockReturnValue({
+ createNews: mockCreateNews,
+ isLoading: false,
+ error: new Error('Something went wrong'),
+ isSuccess: false
+ })
+
+ render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ // Open modal
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+ await user.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ expect(screen.getByText(/error:/i)).toBeInTheDocument()
+ })
+
+ // Modal should stay open
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ it('should clear error when modal is closed and reopened', async () => {
+ const user = userEvent.setup()
+
+ // Start with error state
+ mockUseCreateNewsMutation.mockReturnValue({
+ createNews: mockCreateNews,
+ isLoading: false,
+ error: new Error('Test error'),
+ isSuccess: false
+ })
+
+ const { rerender } = render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ // Open modal
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+ await user.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByText(/error:/i)).toBeInTheDocument()
+ })
+
+ // Close modal
+ await user.keyboard('{Escape}')
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ // Clear error and rerender
+ mockUseCreateNewsMutation.mockReturnValue({
+ createNews: mockCreateNews,
+ isLoading: false,
+ error: null,
+ isSuccess: false
+ })
+
+ rerender(
)
+
+ // Open modal again
+ await user.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ expect(screen.queryByText(/error:/i)).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Modal Styling and Layout', () => {
+ it('should have proper responsive modal sizing', async () => {
+ const user = userEvent.setup()
+ render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ // Open modal
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+ await user.click(triggerButton)
+
+ await waitFor(() => {
+ const modalContent = screen.getByRole('dialog')
+ expect(modalContent).toHaveClass('sm:max-w-[520px]')
+ expect(modalContent).toHaveClass('max-w-[calc(100%-1rem)]')
+ expect(modalContent).toHaveClass('max-h-[90vh]')
+ expect(modalContent).toHaveClass('overflow-y-auto')
+ })
+ })
+
+ it('should have proper modal header and title', async () => {
+ const user = userEvent.setup()
+ render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ // Open modal
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+ await user.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ expect(screen.getByText('Add News Item')).toBeInTheDocument()
+ })
+
+ // Title should be in header element
+ const title = screen.getByText('Add News Item')
+ expect(title.tagName.toLowerCase()).toBe('h2')
+ })
+ })
+
+ describe('Accessibility', () => {
+ it('should have proper ARIA attributes', async () => {
+ const user = userEvent.setup()
+ render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+ expect(triggerButton).toHaveAttribute('aria-label', 'Add new news item')
+
+ // Open modal
+ await user.click(triggerButton)
+
+ await waitFor(() => {
+ const dialog = screen.getByRole('dialog')
+ expect(dialog).toBeInTheDocument()
+ })
+
+ // Error messages should have role="alert"
+ mockUseCreateNewsMutation.mockReturnValue({
+ createNews: mockCreateNews,
+ isLoading: false,
+ error: new Error('Test error'),
+ isSuccess: false
+ })
+
+ const { rerender } = render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ await user.click(triggerButton)
+
+ await waitFor(() => {
+ const errorMessage = screen.getByRole('alert')
+ expect(errorMessage).toBeInTheDocument()
+ expect(errorMessage).toHaveTextContent('Error: Test error')
+ })
+ })
+
+ it('should maintain focus management in modal', async () => {
+ const user = userEvent.setup()
+ render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ // Open modal
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+ await user.click(triggerButton)
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ // First focusable element should be focused
+ // (Implementation depends on Radix UI dialog behavior)
+ expect(document.activeElement).not.toBe(triggerButton)
+ })
+ })
+
+ describe('Integration with useCreateNewsMutation', () => {
+ it('should handle all mutation states correctly', async () => {
+ const user = userEvent.setup()
+
+ // Test initial state
+ mockUseCreateNewsMutation.mockReturnValue({
+ createNews: mockCreateNews,
+ isLoading: false,
+ error: null,
+ isSuccess: false
+ })
+
+ const { rerender } = render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ // Open modal
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+ await user.click(triggerButton)
+
+ // Test loading state
+ mockUseCreateNewsMutation.mockReturnValue({
+ createNews: mockCreateNews,
+ isLoading: true,
+ error: null,
+ isSuccess: false
+ })
+
+ rerender(
)
+
+ expect(screen.getByRole('button', { name: /creating\.\.\./i })).toBeInTheDocument()
+
+ // Test error state
+ mockUseCreateNewsMutation.mockReturnValue({
+ createNews: mockCreateNews,
+ isLoading: false,
+ error: new Error('Network error'),
+ isSuccess: false
+ })
+
+ rerender(
)
+
+ expect(screen.getByText('Error: Network error')).toBeInTheDocument()
+
+ // Test success state
+ mockUseCreateNewsMutation.mockReturnValue({
+ createNews: mockCreateNews,
+ isLoading: false,
+ error: null,
+ isSuccess: true
+ })
+
+ rerender(
)
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle rapid open/close operations', async () => {
+ const user = userEvent.setup()
+ render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+
+ // Rapidly open and close
+ await user.click(triggerButton)
+ await user.keyboard('{Escape}')
+ await user.click(triggerButton)
+ await user.keyboard('{Escape}')
+
+ // Should not cause any errors
+ expect(() => screen.queryByRole('dialog')).not.toThrow()
+ })
+
+ it('should handle component unmounting during open state', () => {
+ const { unmount } = render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ // Should not throw errors when unmounting
+ expect(() => unmount()).not.toThrow()
+ })
+
+ it('should handle mutation hook returning undefined values', async () => {
+ const user = userEvent.setup()
+
+ // Mock undefined error case
+ mockUseCreateNewsMutation.mockReturnValue({
+ createNews: mockCreateNews,
+ isLoading: false,
+ error: undefined,
+ isSuccess: false
+ })
+
+ render(
+
,
+ { wrapper: createWrapper }
+ )
+
+ const triggerButton = screen.getByRole('button', { name: /add new news item/i })
+ await user.click(triggerButton)
+
+ // Should not crash with undefined error
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ })
+ })
+ })
+})
\ No newline at end of file
diff --git a/frontend/src/features/news/data/__tests__/newsForm.schema.test.ts b/frontend/src/features/news/data/__tests__/newsForm.schema.test.ts
new file mode 100644
index 0000000..12e33fa
--- /dev/null
+++ b/frontend/src/features/news/data/__tests__/newsForm.schema.test.ts
@@ -0,0 +1,788 @@
+import { describe, it, expect } from 'vitest'
+import { createNewsFormSchema, CATEGORY_OPTIONS, type CreateNewsFormData } from '../newsForm.schema'
+import { NewsCategory } from '../news.schema'
+
+describe('newsForm.schema', () => {
+ describe('createNewsFormSchema', () => {
+ describe('source field validation', () => {
+ it('should validate required source field', () => {
+ const result = createNewsFormSchema.safeParse({
+ source: '',
+ title: 'Test Title',
+ summary: 'Test Summary',
+ link: 'https://example.com',
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(false)
+ if (!result.success) {
+ expect(result.error.issues).toContainEqual(
+ expect.objectContaining({
+ path: ['source'],
+ message: 'Source is required'
+ })
+ )
+ }
+ })
+
+ it('should validate source minimum length', () => {
+ const result = createNewsFormSchema.safeParse({
+ source: '',
+ title: 'Test Title',
+ summary: 'Test Summary',
+ link: 'https://example.com',
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(false)
+ if (!result.success) {
+ expect(result.error.issues).toContainEqual(
+ expect.objectContaining({
+ path: ['source'],
+ message: 'Source is required'
+ })
+ )
+ }
+ })
+
+ it('should validate source maximum length (100 characters)', () => {
+ const longSource = 'A'.repeat(101)
+ const result = createNewsFormSchema.safeParse({
+ source: longSource,
+ title: 'Test Title',
+ summary: 'Test Summary',
+ link: 'https://example.com',
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(false)
+ if (!result.success) {
+ expect(result.error.issues).toContainEqual(
+ expect.objectContaining({
+ path: ['source'],
+ message: 'Source must be less than 100 characters'
+ })
+ )
+ }
+ })
+
+ it('should accept valid source within length limits', () => {
+ const validSource = 'TechCrunch'
+ const result = createNewsFormSchema.safeParse({
+ source: validSource,
+ title: 'Test Title',
+ summary: 'Test Summary',
+ link: 'https://example.com',
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data.source).toBe(validSource)
+ }
+ })
+
+ it('should accept source at maximum length limit (100 characters)', () => {
+ const maxLengthSource = 'A'.repeat(100)
+ const result = createNewsFormSchema.safeParse({
+ source: maxLengthSource,
+ title: 'Test Title',
+ summary: 'Test Summary',
+ link: 'https://example.com',
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data.source).toBe(maxLengthSource)
+ }
+ })
+ })
+
+ describe('title field validation', () => {
+ it('should validate required title field', () => {
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: '',
+ summary: 'Test Summary',
+ link: 'https://example.com',
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(false)
+ if (!result.success) {
+ expect(result.error.issues).toContainEqual(
+ expect.objectContaining({
+ path: ['title'],
+ message: 'Title is required'
+ })
+ )
+ }
+ })
+
+ it('should validate title maximum length (200 characters)', () => {
+ const longTitle = 'A'.repeat(201)
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: longTitle,
+ summary: 'Test Summary',
+ link: 'https://example.com',
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(false)
+ if (!result.success) {
+ expect(result.error.issues).toContainEqual(
+ expect.objectContaining({
+ path: ['title'],
+ message: 'Title must be less than 200 characters'
+ })
+ )
+ }
+ })
+
+ it('should accept valid title within length limits', () => {
+ const validTitle = 'Breaking: New Technology Announcement'
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: validTitle,
+ summary: 'Test Summary',
+ link: 'https://example.com',
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data.title).toBe(validTitle)
+ }
+ })
+
+ it('should accept title at maximum length limit (200 characters)', () => {
+ const maxLengthTitle = 'A'.repeat(200)
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: maxLengthTitle,
+ summary: 'Test Summary',
+ link: 'https://example.com',
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data.title).toBe(maxLengthTitle)
+ }
+ })
+ })
+
+ describe('summary field validation', () => {
+ it('should validate required summary field', () => {
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: 'Test Title',
+ summary: '',
+ link: 'https://example.com',
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(false)
+ if (!result.success) {
+ expect(result.error.issues).toContainEqual(
+ expect.objectContaining({
+ path: ['summary'],
+ message: 'Summary is required'
+ })
+ )
+ }
+ })
+
+ it('should validate summary maximum length (500 characters)', () => {
+ const longSummary = 'A'.repeat(501)
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: 'Test Title',
+ summary: longSummary,
+ link: 'https://example.com',
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(false)
+ if (!result.success) {
+ expect(result.error.issues).toContainEqual(
+ expect.objectContaining({
+ path: ['summary'],
+ message: 'Summary must be less than 500 characters'
+ })
+ )
+ }
+ })
+
+ it('should accept valid summary within length limits', () => {
+ const validSummary = 'This is a detailed summary of the news article content that provides valuable information.'
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: 'Test Title',
+ summary: validSummary,
+ link: 'https://example.com',
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data.summary).toBe(validSummary)
+ }
+ })
+
+ it('should accept summary at maximum length limit (500 characters)', () => {
+ const maxLengthSummary = 'A'.repeat(500)
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: 'Test Title',
+ summary: maxLengthSummary,
+ link: 'https://example.com',
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data.summary).toBe(maxLengthSummary)
+ }
+ })
+ })
+
+ describe('link field validation', () => {
+ it('should validate required link field', () => {
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: 'Test Title',
+ summary: 'Test Summary',
+ link: '',
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(false)
+ if (!result.success) {
+ expect(result.error.issues).toContainEqual(
+ expect.objectContaining({
+ path: ['link'],
+ message: 'Please enter a valid URL'
+ })
+ )
+ }
+ })
+
+ it('should validate proper URL format', () => {
+ const invalidUrls = [
+ 'not-a-url',
+ 'ftp://invalid-protocol.com',
+ 'javascript:alert("test")',
+ 'www.no-protocol.com',
+ 'http://',
+ 'https://',
+ 'https://.',
+ 'https://.com'
+ ]
+
+ invalidUrls.forEach(invalidUrl => {
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: 'Test Title',
+ summary: 'Test Summary',
+ link: invalidUrl,
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(false)
+ if (!result.success) {
+ const hasUrlError = result.error.issues.some(issue =>
+ issue.path.includes('link') &&
+ (issue.message === 'Please enter a valid URL' || issue.message === 'Invalid URL format')
+ )
+ expect(hasUrlError).toBe(true)
+ }
+ })
+ })
+
+ it('should accept valid HTTP and HTTPS URLs', () => {
+ const validUrls = [
+ 'https://example.com',
+ 'http://example.com',
+ 'https://www.example.com',
+ 'https://subdomain.example.com',
+ 'https://example.com/path',
+ 'https://example.com/path?query=value',
+ 'https://example.com/path?query=value&other=test',
+ 'https://example.com/path#fragment',
+ 'https://example.com:8080/path',
+ 'https://192.168.1.1:3000',
+ 'https://localhost:3000',
+ 'https://example-site.co.uk',
+ 'https://example.com/path/with-hyphens_and_underscores'
+ ]
+
+ validUrls.forEach(validUrl => {
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: 'Test Title',
+ summary: 'Test Summary',
+ link: validUrl,
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data.link).toBe(validUrl)
+ }
+ })
+ })
+
+ it('should use both URL validation and custom refine validation', () => {
+ // Test that both URL string validation and custom refine work together
+ const malformedUrl = 'https://[invalid-brackets'
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: 'Test Title',
+ summary: 'Test Summary',
+ link: malformedUrl,
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(false)
+ if (!result.success) {
+ const hasUrlError = result.error.issues.some(issue =>
+ issue.path.includes('link')
+ )
+ expect(hasUrlError).toBe(true)
+ }
+ })
+ })
+
+ describe('image_url field validation', () => {
+ it('should accept empty image_url as optional field', () => {
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: 'Test Title',
+ summary: 'Test Summary',
+ link: 'https://example.com',
+ image_url: '',
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data.image_url).toBe('')
+ }
+ })
+
+ it('should accept undefined image_url', () => {
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: 'Test Title',
+ summary: 'Test Summary',
+ link: 'https://example.com',
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data.image_url).toBeUndefined()
+ }
+ })
+
+ it('should validate URL format when image_url is provided', () => {
+ const invalidImageUrls = [
+ 'not-a-url',
+ 'ftp://invalid-protocol.com/image.jpg',
+ 'javascript:alert("test")',
+ 'data:image/png;base64,invalid'
+ ]
+
+ invalidImageUrls.forEach(invalidUrl => {
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: 'Test Title',
+ summary: 'Test Summary',
+ link: 'https://example.com',
+ image_url: invalidUrl,
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(false)
+ if (!result.success) {
+ expect(result.error.issues).toContainEqual(
+ expect.objectContaining({
+ path: ['image_url'],
+ message: 'Please enter a valid image URL'
+ })
+ )
+ }
+ })
+ })
+
+ it('should accept valid image URLs', () => {
+ const validImageUrls = [
+ 'https://example.com/image.jpg',
+ 'https://example.com/image.png',
+ 'https://example.com/image.gif',
+ 'https://cdn.example.com/path/to/image.webp',
+ 'https://images.unsplash.com/photo-123?ixlib=rb-4.0.3'
+ ]
+
+ validImageUrls.forEach(validUrl => {
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: 'Test Title',
+ summary: 'Test Summary',
+ link: 'https://example.com',
+ image_url: validUrl,
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data.image_url).toBe(validUrl)
+ }
+ })
+ })
+ })
+
+ describe('category field validation', () => {
+ it('should validate required category field', () => {
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: 'Test Title',
+ summary: 'Test Summary',
+ link: 'https://example.com',
+ is_public: false
+ })
+
+ expect(result.success).toBe(false)
+ if (!result.success) {
+ expect(result.error.issues).toContainEqual(
+ expect.objectContaining({
+ path: ['category'],
+ message: 'Please select a category'
+ })
+ )
+ }
+ })
+
+ it('should accept all valid NewsCategory enum values', () => {
+ const validCategories = Object.values(NewsCategory)
+
+ validCategories.forEach(category => {
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: 'Test Title',
+ summary: 'Test Summary',
+ link: 'https://example.com',
+ category: category,
+ is_public: false
+ })
+
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data.category).toBe(category)
+ }
+ })
+ })
+
+ it('should reject invalid category values', () => {
+ const invalidCategories = [
+ 'invalid-category',
+ 'INVALID',
+ '',
+ null,
+ undefined,
+ 123,
+ {}
+ ]
+
+ invalidCategories.forEach(invalidCategory => {
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: 'Test Title',
+ summary: 'Test Summary',
+ link: 'https://example.com',
+ category: invalidCategory,
+ is_public: false
+ })
+
+ expect(result.success).toBe(false)
+ })
+ })
+ })
+
+ describe('is_public field validation', () => {
+ it('should accept boolean true value', () => {
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: 'Test Title',
+ summary: 'Test Summary',
+ link: 'https://example.com',
+ category: NewsCategory.GENERAL,
+ is_public: true
+ })
+
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data.is_public).toBe(true)
+ }
+ })
+
+ it('should accept boolean false value', () => {
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: 'Test Title',
+ summary: 'Test Summary',
+ link: 'https://example.com',
+ category: NewsCategory.GENERAL,
+ is_public: false
+ })
+
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data.is_public).toBe(false)
+ }
+ })
+
+ it('should default to false when is_public is not provided', () => {
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: 'Test Title',
+ summary: 'Test Summary',
+ link: 'https://example.com',
+ category: NewsCategory.GENERAL
+ })
+
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data.is_public).toBe(false)
+ }
+ })
+
+ it('should reject non-boolean values', () => {
+ const invalidValues = ['true', 'false', 1, 0, null, undefined, {}, []]
+
+ invalidValues.forEach(invalidValue => {
+ const result = createNewsFormSchema.safeParse({
+ source: 'TechCrunch',
+ title: 'Test Title',
+ summary: 'Test Summary',
+ link: 'https://example.com',
+ category: NewsCategory.GENERAL,
+ is_public: invalidValue
+ })
+
+ expect(result.success).toBe(false)
+ })
+ })
+ })
+
+ describe('complete form validation', () => {
+ it('should validate a complete valid form', () => {
+ const validFormData: CreateNewsFormData = {
+ source: 'TechCrunch',
+ title: 'Breaking: New Technology Breakthrough',
+ summary: 'Scientists have made a significant breakthrough in quantum computing that could revolutionize the industry.',
+ link: 'https://techcrunch.com/2024/01/15/quantum-breakthrough',
+ image_url: 'https://techcrunch.com/images/quantum-computer.jpg',
+ category: NewsCategory.RESEARCH,
+ is_public: true
+ }
+
+ const result = createNewsFormSchema.safeParse(validFormData)
+
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data).toEqual(validFormData)
+ }
+ })
+
+ it('should validate minimal valid form (required fields only)', () => {
+ const minimalFormData: CreateNewsFormData = {
+ source: 'TechCrunch',
+ title: 'Test Article',
+ summary: 'Test summary',
+ link: 'https://example.com',
+ category: NewsCategory.GENERAL,
+ is_public: false
+ }
+
+ const result = createNewsFormSchema.safeParse(minimalFormData)
+
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data).toEqual(minimalFormData)
+ }
+ })
+
+ it('should collect multiple validation errors', () => {
+ const invalidFormData = {
+ source: '',
+ title: '',
+ summary: '',
+ link: 'invalid-url',
+ image_url: 'invalid-image-url',
+ category: 'invalid-category',
+ is_public: 'not-boolean'
+ }
+
+ const result = createNewsFormSchema.safeParse(invalidFormData)
+
+ expect(result.success).toBe(false)
+ if (!result.success) {
+ expect(result.error.issues.length).toBeGreaterThan(1)
+
+ // Check that we get errors for all invalid fields
+ const errorPaths = result.error.issues.map(issue => issue.path[0])
+ expect(errorPaths).toContain('source')
+ expect(errorPaths).toContain('title')
+ expect(errorPaths).toContain('summary')
+ expect(errorPaths).toContain('link')
+ expect(errorPaths).toContain('image_url')
+ expect(errorPaths).toContain('category')
+ expect(errorPaths).toContain('is_public')
+ }
+ })
+ })
+
+ describe('TypeScript type inference', () => {
+ it('should infer correct TypeScript type', () => {
+ const validData = {
+ source: 'TechCrunch',
+ title: 'Test Article',
+ summary: 'Test summary',
+ link: 'https://example.com',
+ image_url: 'https://example.com/image.jpg',
+ category: NewsCategory.GENERAL,
+ is_public: true
+ }
+
+ const result = createNewsFormSchema.safeParse(validData)
+
+ if (result.success) {
+ // TypeScript should infer the correct type
+ const data: CreateNewsFormData = result.data
+
+ expect(typeof data.source).toBe('string')
+ expect(typeof data.title).toBe('string')
+ expect(typeof data.summary).toBe('string')
+ expect(typeof data.link).toBe('string')
+ expect(typeof data.image_url).toBe('string')
+ expect(Object.values(NewsCategory)).toContain(data.category)
+ expect(typeof data.is_public).toBe('boolean')
+ }
+ })
+ })
+ })
+
+ describe('CATEGORY_OPTIONS', () => {
+ it('should contain all NewsCategory enum values', () => {
+ const categoryValues = CATEGORY_OPTIONS.map(option => option.value)
+ const enumValues = Object.values(NewsCategory)
+
+ expect(categoryValues).toHaveLength(enumValues.length)
+ enumValues.forEach(enumValue => {
+ expect(categoryValues).toContain(enumValue)
+ })
+ })
+
+ it('should have proper label-value structure', () => {
+ CATEGORY_OPTIONS.forEach(option => {
+ expect(option).toHaveProperty('value')
+ expect(option).toHaveProperty('label')
+ expect(typeof option.value).toBe('string')
+ expect(typeof option.label).toBe('string')
+ expect(option.label.length).toBeGreaterThan(0)
+ })
+ })
+
+ it('should have meaningful labels for each category', () => {
+ const expectedOptions = [
+ { value: NewsCategory.GENERAL, label: 'General' },
+ { value: NewsCategory.RESEARCH, label: 'Research' },
+ { value: NewsCategory.PRODUCT, label: 'Product' },
+ { value: NewsCategory.COMPANY, label: 'Company' },
+ { value: NewsCategory.TUTORIAL, label: 'Tutorial' },
+ { value: NewsCategory.OPINION, label: 'Opinion' }
+ ]
+
+ expect(CATEGORY_OPTIONS).toEqual(expectedOptions)
+ })
+
+ it('should not have duplicate values', () => {
+ const values = CATEGORY_OPTIONS.map(option => option.value)
+ const uniqueValues = [...new Set(values)]
+ expect(values).toHaveLength(uniqueValues.length)
+ })
+
+ it('should not have duplicate labels', () => {
+ const labels = CATEGORY_OPTIONS.map(option => option.label)
+ const uniqueLabels = [...new Set(labels)]
+ expect(labels).toHaveLength(uniqueLabels.length)
+ })
+ })
+
+ describe('schema integration with forms', () => {
+ it('should work with form libraries for validation', () => {
+ // Simulate what a form library might do
+ const formData = {
+ source: 'T', // Too short to be meaningful but technically valid
+ title: 'T', // Too short to be meaningful but technically valid
+ summary: 'T', // Too short to be meaningful but technically valid
+ link: 'https://t.co', // Valid but very short URL
+ category: NewsCategory.GENERAL,
+ is_public: false
+ }
+
+ const result = createNewsFormSchema.safeParse(formData)
+ expect(result.success).toBe(true) // All fields meet minimum requirements
+ })
+
+ it('should provide detailed error information for form field highlighting', () => {
+ const invalidData = {
+ source: 'A'.repeat(101), // Too long
+ title: 'A'.repeat(201), // Too long
+ summary: 'A'.repeat(501), // Too long
+ link: 'invalid',
+ image_url: 'invalid',
+ category: 'invalid',
+ is_public: 'invalid'
+ }
+
+ const result = createNewsFormSchema.safeParse(invalidData)
+ expect(result.success).toBe(false)
+
+ if (!result.success) {
+ result.error.issues.forEach(issue => {
+ expect(issue).toHaveProperty('path')
+ expect(issue).toHaveProperty('message')
+ expect(issue).toHaveProperty('code')
+ expect(Array.isArray(issue.path)).toBe(true)
+ expect(typeof issue.message).toBe('string')
+ })
+ }
+ })
+ })
+})
\ No newline at end of file
diff --git a/frontend/src/features/news/data/news.schema.ts b/frontend/src/features/news/data/news.schema.ts
index baffef5..9a4b6a6 100644
--- a/frontend/src/features/news/data/news.schema.ts
+++ b/frontend/src/features/news/data/news.schema.ts
@@ -70,17 +70,17 @@ export interface NewsStats {
// Category colors for UI
export const CATEGORY_COLORS: Record
= {
- [NewsCategory.GENERAL]: 'bg-gray-100 text-gray-800',
- [NewsCategory.RESEARCH]: 'bg-purple-100 text-purple-800',
- [NewsCategory.PRODUCT]: 'bg-blue-100 text-blue-800',
- [NewsCategory.COMPANY]: 'bg-green-100 text-green-800',
- [NewsCategory.TUTORIAL]: 'bg-yellow-100 text-yellow-800',
- [NewsCategory.OPINION]: 'bg-pink-100 text-pink-800',
+ [NewsCategory.GENERAL]: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200',
+ [NewsCategory.RESEARCH]: 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200',
+ [NewsCategory.PRODUCT]: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200',
+ [NewsCategory.COMPANY]: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200',
+ [NewsCategory.TUTORIAL]: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-200',
+ [NewsCategory.OPINION]: 'bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-200',
};
// Status colors for UI
export const STATUS_COLORS: Record = {
- [NewsStatus.PENDING]: 'border-l-4 border-yellow-400',
- [NewsStatus.READING]: 'border-l-4 border-blue-400',
- [NewsStatus.READ]: 'border-l-4 border-green-400',
+ [NewsStatus.PENDING]: 'border-l-4 border-yellow-400 dark:border-yellow-500',
+ [NewsStatus.READING]: 'border-l-4 border-blue-400 dark:border-blue-500',
+ [NewsStatus.READ]: 'border-l-4 border-green-400 dark:border-green-500',
};
\ No newline at end of file
diff --git a/frontend/src/features/news/data/news.service.ts b/frontend/src/features/news/data/news.service.ts
index 2e219ca..db19e1d 100644
--- a/frontend/src/features/news/data/news.service.ts
+++ b/frontend/src/features/news/data/news.service.ts
@@ -23,6 +23,7 @@ export const newsService = {
if (filters?.limit) params.append('limit', String(filters.limit));
if (filters?.offset) params.append('offset', String(filters.offset));
+
const response = await apiClient.get(`/api/news/user?${params}`);
return response;
},
diff --git a/frontend/src/features/news/data/newsForm.schema.ts b/frontend/src/features/news/data/newsForm.schema.ts
new file mode 100644
index 0000000..1a5533f
--- /dev/null
+++ b/frontend/src/features/news/data/newsForm.schema.ts
@@ -0,0 +1,50 @@
+import { z } from 'zod';
+import { NewsCategory } from './news.schema';
+
+export const createNewsFormSchema = z.object({
+ source: z.string()
+ .min(1, 'Source is required')
+ .max(100, 'Source must be less than 100 characters'),
+
+ title: z.string()
+ .min(1, 'Title is required')
+ .max(200, 'Title must be less than 200 characters'),
+
+ summary: z.string()
+ .min(1, 'Summary is required')
+ .max(500, 'Summary must be less than 500 characters'),
+
+ link: z.string()
+ .url('Please enter a valid URL')
+ .refine((url) => {
+ try {
+ new URL(url);
+ return true;
+ } catch {
+ return false;
+ }
+ }, 'Invalid URL format'),
+
+ image_url: z.string()
+ .url('Please enter a valid image URL')
+ .optional()
+ .or(z.literal('')), // Allow empty string
+
+ category: z.nativeEnum(NewsCategory, {
+ required_error: 'Please select a category'
+ }),
+
+ is_public: z.boolean().default(false)
+});
+
+export type CreateNewsFormData = z.infer;
+
+// Category options for dropdown
+export const CATEGORY_OPTIONS = [
+ { value: NewsCategory.GENERAL, label: 'General' },
+ { value: NewsCategory.RESEARCH, label: 'Research' },
+ { value: NewsCategory.PRODUCT, label: 'Product' },
+ { value: NewsCategory.COMPANY, label: 'Company' },
+ { value: NewsCategory.TUTORIAL, label: 'Tutorial' },
+ { value: NewsCategory.OPINION, label: 'Opinion' }
+];
\ No newline at end of file
diff --git a/frontend/src/features/profile/__tests__/ChangePassword.test.tsx b/frontend/src/features/profile/__tests__/ChangePassword.test.tsx
new file mode 100644
index 0000000..5df8ea5
--- /dev/null
+++ b/frontend/src/features/profile/__tests__/ChangePassword.test.tsx
@@ -0,0 +1,285 @@
+import React from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { BrowserRouter } from 'react-router-dom';
+import { ChangePassword } from '../components/ChangePassword';
+import { useChangePassword } from '../hooks/useChangePassword';
+
+// Mock the hook
+jest.mock('../hooks/useChangePassword');
+const mockUseChangePassword = useChangePassword as jest.MockedFunction;
+
+// Mock the core components
+jest.mock('../../../core/components/ui/card', () => ({
+ Card: ({ children }: { children: React.ReactNode }) => {children}
,
+ CardContent: ({ children }: { children: React.ReactNode }) => {children}
,
+ CardDescription: ({ children }: { children: React.ReactNode }) => {children}
,
+ CardHeader: ({ children }: { children: React.ReactNode }) => {children}
,
+ CardTitle: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+jest.mock('../../../core/components/ui/button', () => ({
+ Button: ({ children, onClick, type, disabled, ...props }: any) => (
+
+ ),
+}));
+
+jest.mock('../../../core/components/ui/input', () => ({
+ Input: ({ onChange, value, type, className, ...props }: any) => (
+
+ ),
+}));
+
+jest.mock('../../../core/components/ui/label', () => ({
+ Label: ({ children, htmlFor }: any) => ,
+}));
+
+jest.mock('lucide-react', () => ({
+ ArrowLeft: () => ,
+ Key: () => ,
+ Eye: () => ,
+ EyeOff: () => ,
+}));
+
+// Mock react-router-dom
+const mockNavigate = jest.fn();
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useNavigate: () => mockNavigate,
+}));
+
+const createTestQueryClient = () => new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+});
+
+const renderWithProviders = (component: React.ReactElement) => {
+ const queryClient = createTestQueryClient();
+ return render(
+
+
+ {component}
+
+
+ );
+};
+
+describe('ChangePassword', () => {
+ const mockChangePasswordMutation = {
+ mutate: jest.fn(),
+ isPending: false,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseChangePassword.mockReturnValue(mockChangePasswordMutation as any);
+ });
+
+ it('renders form with all password fields', () => {
+ renderWithProviders();
+
+ expect(screen.getByLabelText('Current Password')).toBeInTheDocument();
+ expect(screen.getByLabelText('New Password')).toBeInTheDocument();
+ expect(screen.getByLabelText('Confirm New Password')).toBeInTheDocument();
+ expect(screen.getByText('Change Password')).toBeInTheDocument();
+ });
+
+ it('validates current password field', async () => {
+ renderWithProviders();
+
+ const currentPasswordInput = screen.getByLabelText('Current Password');
+
+ // Test empty current password
+ fireEvent.change(currentPasswordInput, { target: { value: '' } });
+ fireEvent.click(screen.getByText('Change Password'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Current password is required')).toBeInTheDocument();
+ });
+ });
+
+ it('validates new password field', async () => {
+ renderWithProviders();
+
+ const currentPasswordInput = screen.getByLabelText('Current Password');
+ const newPasswordInput = screen.getByLabelText('New Password');
+
+ fireEvent.change(currentPasswordInput, { target: { value: 'oldpassword' } });
+
+ // Test empty new password
+ fireEvent.change(newPasswordInput, { target: { value: '' } });
+ fireEvent.click(screen.getByText('Change Password'));
+
+ await waitFor(() => {
+ expect(screen.getByText('New password is required')).toBeInTheDocument();
+ });
+
+ // Test short new password
+ fireEvent.change(newPasswordInput, { target: { value: '123' } });
+ fireEvent.click(screen.getByText('Change Password'));
+
+ await waitFor(() => {
+ expect(screen.getByText('New password must be at least 6 characters')).toBeInTheDocument();
+ });
+ });
+
+ it('validates confirm password field', async () => {
+ renderWithProviders();
+
+ const currentPasswordInput = screen.getByLabelText('Current Password');
+ const newPasswordInput = screen.getByLabelText('New Password');
+ const confirmPasswordInput = screen.getByLabelText('Confirm New Password');
+
+ fireEvent.change(currentPasswordInput, { target: { value: 'oldpassword' } });
+ fireEvent.change(newPasswordInput, { target: { value: 'newpassword123' } });
+
+ // Test empty confirm password
+ fireEvent.change(confirmPasswordInput, { target: { value: '' } });
+ fireEvent.click(screen.getByText('Change Password'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Please confirm your new password')).toBeInTheDocument();
+ });
+ });
+
+ it('validates password confirmation match', async () => {
+ renderWithProviders();
+
+ const currentPasswordInput = screen.getByLabelText('Current Password');
+ const newPasswordInput = screen.getByLabelText('New Password');
+ const confirmPasswordInput = screen.getByLabelText('Confirm New Password');
+
+ fireEvent.change(currentPasswordInput, { target: { value: 'oldpassword' } });
+ fireEvent.change(newPasswordInput, { target: { value: 'newpassword123' } });
+ fireEvent.change(confirmPasswordInput, { target: { value: 'differentpassword' } });
+ fireEvent.click(screen.getByText('Change Password'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Passwords do not match')).toBeInTheDocument();
+ });
+ });
+
+ it('validates new password is different from current', async () => {
+ renderWithProviders();
+
+ const currentPasswordInput = screen.getByLabelText('Current Password');
+ const newPasswordInput = screen.getByLabelText('New Password');
+ const confirmPasswordInput = screen.getByLabelText('Confirm New Password');
+
+ fireEvent.change(currentPasswordInput, { target: { value: 'samepassword' } });
+ fireEvent.change(newPasswordInput, { target: { value: 'samepassword' } });
+ fireEvent.change(confirmPasswordInput, { target: { value: 'samepassword' } });
+ fireEvent.click(screen.getByText('Change Password'));
+
+ await waitFor(() => {
+ expect(screen.getByText('New password must be different from current password')).toBeInTheDocument();
+ });
+ });
+
+ it('submits form with valid data', async () => {
+ renderWithProviders();
+
+ const currentPasswordInput = screen.getByLabelText('Current Password');
+ const newPasswordInput = screen.getByLabelText('New Password');
+ const confirmPasswordInput = screen.getByLabelText('Confirm New Password');
+
+ fireEvent.change(currentPasswordInput, { target: { value: 'oldpassword' } });
+ fireEvent.change(newPasswordInput, { target: { value: 'newpassword123' } });
+ fireEvent.change(confirmPasswordInput, { target: { value: 'newpassword123' } });
+ fireEvent.click(screen.getByText('Change Password'));
+
+ await waitFor(() => {
+ expect(mockChangePasswordMutation.mutate).toHaveBeenCalledWith({
+ current_password: 'oldpassword',
+ new_password: 'newpassword123',
+ confirm_password: 'newpassword123'
+ });
+ });
+ });
+
+ it('shows loading state during submission', () => {
+ mockUseChangePassword.mockReturnValue({
+ ...mockChangePasswordMutation,
+ isPending: true,
+ } as any);
+
+ renderWithProviders();
+
+ expect(screen.getByText('Changing...')).toBeInTheDocument();
+ expect(screen.getByText('Change Password')).toBeDisabled();
+ });
+
+ it('navigates back to profile on successful submission', async () => {
+ mockChangePasswordMutation.mutate.mockImplementation((data, options) => {
+ options?.onSuccess?.();
+ });
+
+ renderWithProviders();
+
+ const currentPasswordInput = screen.getByLabelText('Current Password');
+ const newPasswordInput = screen.getByLabelText('New Password');
+ const confirmPasswordInput = screen.getByLabelText('Confirm New Password');
+
+ fireEvent.change(currentPasswordInput, { target: { value: 'oldpassword' } });
+ fireEvent.change(newPasswordInput, { target: { value: 'newpassword123' } });
+ fireEvent.change(confirmPasswordInput, { target: { value: 'newpassword123' } });
+ fireEvent.click(screen.getByText('Change Password'));
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('/profile');
+ });
+ });
+
+ it('toggles password visibility', () => {
+ renderWithProviders();
+
+ const currentPasswordInput = screen.getByLabelText('Current Password');
+ const newPasswordInput = screen.getByLabelText('New Password');
+ const confirmPasswordInput = screen.getByLabelText('Confirm New Password');
+
+ // Initially passwords should be hidden
+ expect(currentPasswordInput).toHaveAttribute('type', 'password');
+ expect(newPasswordInput).toHaveAttribute('type', 'password');
+ expect(confirmPasswordInput).toHaveAttribute('type', 'password');
+
+ // Click visibility toggle for current password
+ const currentPasswordToggle = screen.getAllByTestId('eye-icon')[0];
+ fireEvent.click(currentPasswordToggle);
+ expect(currentPasswordInput).toHaveAttribute('type', 'text');
+
+ // Click visibility toggle for new password
+ const newPasswordToggle = screen.getAllByTestId('eye-icon')[1];
+ fireEvent.click(newPasswordToggle);
+ expect(newPasswordInput).toHaveAttribute('type', 'text');
+
+ // Click visibility toggle for confirm password
+ const confirmPasswordToggle = screen.getAllByTestId('eye-icon')[2];
+ fireEvent.click(confirmPasswordToggle);
+ expect(confirmPasswordInput).toHaveAttribute('type', 'text');
+ });
+
+ it('clears errors when user starts typing', async () => {
+ renderWithProviders();
+
+ const currentPasswordInput = screen.getByLabelText('Current Password');
+
+ // Trigger validation error
+ fireEvent.change(currentPasswordInput, { target: { value: '' } });
+ fireEvent.click(screen.getByText('Change Password'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Current password is required')).toBeInTheDocument();
+ });
+
+ // Start typing to clear error
+ fireEvent.change(currentPasswordInput, { target: { value: 'new' } });
+
+ await waitFor(() => {
+ expect(screen.queryByText('Current password is required')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/features/profile/__tests__/ProfileEdit.test.tsx b/frontend/src/features/profile/__tests__/ProfileEdit.test.tsx
new file mode 100644
index 0000000..e28b5ee
--- /dev/null
+++ b/frontend/src/features/profile/__tests__/ProfileEdit.test.tsx
@@ -0,0 +1,264 @@
+import React from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { BrowserRouter } from 'react-router-dom';
+import { ProfileEdit } from '../components/ProfileEdit';
+import { useProfile } from '../hooks/useProfile';
+import { useUpdateProfile } from '../hooks/useUpdateProfile';
+
+// Mock the hooks
+jest.mock('../hooks/useProfile');
+jest.mock('../hooks/useUpdateProfile');
+const mockUseProfile = useProfile as jest.MockedFunction;
+const mockUseUpdateProfile = useUpdateProfile as jest.MockedFunction;
+
+// Mock the core components
+jest.mock('../../../core/components/ui/card', () => ({
+ Card: ({ children }: { children: React.ReactNode }) => {children}
,
+ CardContent: ({ children }: { children: React.ReactNode }) => {children}
,
+ CardDescription: ({ children }: { children: React.ReactNode }) => {children}
,
+ CardHeader: ({ children }: { children: React.ReactNode }) => {children}
,
+ CardTitle: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+jest.mock('../../../core/components/ui/button', () => ({
+ Button: ({ children, onClick, type, disabled, ...props }: any) => (
+
+ ),
+}));
+
+jest.mock('../../../core/components/ui/input', () => ({
+ Input: ({ onChange, value, className, ...props }: any) => (
+
+ ),
+}));
+
+jest.mock('../../../core/components/ui/label', () => ({
+ Label: ({ children, htmlFor }: any) => ,
+}));
+
+jest.mock('../../../core/components/ui/skeleton', () => ({
+ Skeleton: ({ className }: { className?: string }) => ,
+}));
+
+jest.mock('lucide-react', () => ({
+ ArrowLeft: () => ,
+ Save: () => ,
+ X: () => ,
+}));
+
+const createTestQueryClient = () => new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+});
+
+const renderWithProviders = (component: React.ReactElement) => {
+ const queryClient = createTestQueryClient();
+ return render(
+
+
+ {component}
+
+
+ );
+};
+
+describe('ProfileEdit', () => {
+ const mockProfile = {
+ id: 'user123',
+ email: 'test@example.com',
+ username: 'testuser',
+ is_active: true,
+ created_at: '2023-01-01T00:00:00Z',
+ updated_at: '2023-01-02T00:00:00Z',
+ };
+
+ const mockUpdateMutation = {
+ mutate: jest.fn(),
+ isPending: false,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseUpdateProfile.mockReturnValue(mockUpdateMutation as any);
+ });
+
+ it('renders loading state', () => {
+ mockUseProfile.mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ error: null,
+ } as any);
+
+ renderWithProviders();
+
+ expect(screen.getAllByTestId('skeleton')).toHaveLength(3);
+ });
+
+ it('renders form with current profile data', async () => {
+ mockUseProfile.mockReturnValue({
+ data: mockProfile,
+ isLoading: false,
+ error: null,
+ } as any);
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByDisplayValue('testuser')).toBeInTheDocument();
+ expect(screen.getByDisplayValue('test@example.com')).toBeInTheDocument();
+ });
+ });
+
+ it('validates username field', async () => {
+ mockUseProfile.mockReturnValue({
+ data: mockProfile,
+ isLoading: false,
+ error: null,
+ } as any);
+
+ renderWithProviders();
+
+ const usernameInput = screen.getByDisplayValue('testuser');
+
+ // Test empty username
+ fireEvent.change(usernameInput, { target: { value: '' } });
+ fireEvent.click(screen.getByText('Save Changes'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Username is required')).toBeInTheDocument();
+ });
+
+ // Test short username
+ fireEvent.change(usernameInput, { target: { value: 'ab' } });
+ fireEvent.click(screen.getByText('Save Changes'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Username must be at least 3 characters')).toBeInTheDocument();
+ });
+
+ // Test invalid characters
+ fireEvent.change(usernameInput, { target: { value: 'test@user' } });
+ fireEvent.click(screen.getByText('Save Changes'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Username can only contain letters, numbers, and underscores')).toBeInTheDocument();
+ });
+ });
+
+ it('validates email field', async () => {
+ mockUseProfile.mockReturnValue({
+ data: mockProfile,
+ isLoading: false,
+ error: null,
+ } as any);
+
+ renderWithProviders();
+
+ const emailInput = screen.getByDisplayValue('test@example.com');
+
+ // Test empty email
+ fireEvent.change(emailInput, { target: { value: '' } });
+ fireEvent.click(screen.getByText('Save Changes'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Email is required')).toBeInTheDocument();
+ });
+
+ // Test invalid email format
+ fireEvent.change(emailInput, { target: { value: 'invalid-email' } });
+ fireEvent.click(screen.getByText('Save Changes'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument();
+ });
+ });
+
+ it('submits form with valid data', async () => {
+ mockUseProfile.mockReturnValue({
+ data: mockProfile,
+ isLoading: false,
+ error: null,
+ } as any);
+
+ renderWithProviders();
+
+ const usernameInput = screen.getByDisplayValue('testuser');
+ const emailInput = screen.getByDisplayValue('test@example.com');
+
+ fireEvent.change(usernameInput, { target: { value: 'newusername' } });
+ fireEvent.change(emailInput, { target: { value: 'newemail@example.com' } });
+ fireEvent.click(screen.getByText('Save Changes'));
+
+ await waitFor(() => {
+ expect(mockUpdateMutation.mutate).toHaveBeenCalledWith({
+ username: 'newusername',
+ email: 'newemail@example.com'
+ });
+ });
+ });
+
+ it('shows loading state during submission', async () => {
+ mockUseProfile.mockReturnValue({
+ data: mockProfile,
+ isLoading: false,
+ error: null,
+ } as any);
+
+ mockUseUpdateProfile.mockReturnValue({
+ ...mockUpdateMutation,
+ isPending: true,
+ } as any);
+
+ renderWithProviders();
+
+ expect(screen.getByText('Saving...')).toBeInTheDocument();
+ expect(screen.getByText('Save Changes')).toBeDisabled();
+ });
+
+ it('clears errors when user starts typing', async () => {
+ mockUseProfile.mockReturnValue({
+ data: mockProfile,
+ isLoading: false,
+ error: null,
+ } as any);
+
+ renderWithProviders();
+
+ const usernameInput = screen.getByDisplayValue('testuser');
+
+ // Trigger validation error
+ fireEvent.change(usernameInput, { target: { value: '' } });
+ fireEvent.click(screen.getByText('Save Changes'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Username is required')).toBeInTheDocument();
+ });
+
+ // Start typing to clear error
+ fireEvent.change(usernameInput, { target: { value: 'new' } });
+
+ await waitFor(() => {
+ expect(screen.queryByText('Username is required')).not.toBeInTheDocument();
+ });
+ });
+
+ it('does not submit if no changes are made', async () => {
+ mockUseProfile.mockReturnValue({
+ data: mockProfile,
+ isLoading: false,
+ error: null,
+ } as any);
+
+ renderWithProviders();
+
+ fireEvent.click(screen.getByText('Save Changes'));
+
+ // Should not call mutate if no changes
+ expect(mockUpdateMutation.mutate).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/features/profile/__tests__/ProfileView.test.tsx b/frontend/src/features/profile/__tests__/ProfileView.test.tsx
new file mode 100644
index 0000000..4757d13
--- /dev/null
+++ b/frontend/src/features/profile/__tests__/ProfileView.test.tsx
@@ -0,0 +1,185 @@
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { BrowserRouter } from 'react-router-dom';
+import { ProfileView } from '../components/ProfileView';
+import { useProfile } from '../hooks/useProfile';
+
+// Mock the useProfile hook
+jest.mock('../hooks/useProfile');
+const mockUseProfile = useProfile as jest.MockedFunction;
+
+// Mock the core components
+jest.mock('../../../core/components/ui/card', () => ({
+ Card: ({ children }: { children: React.ReactNode }) => {children}
,
+ CardContent: ({ children }: { children: React.ReactNode }) => {children}
,
+ CardDescription: ({ children }: { children: React.ReactNode }) => {children}
,
+ CardHeader: ({ children }: { children: React.ReactNode }) => {children}
,
+ CardTitle: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+jest.mock('../../../core/components/ui/button', () => ({
+ Button: ({ children, ...props }: any) => ,
+}));
+
+jest.mock('../../../core/components/ui/badge', () => ({
+ Badge: ({ children, ...props }: any) => {children},
+}));
+
+jest.mock('../../../core/components/ui/skeleton', () => ({
+ Skeleton: ({ className }: { className?: string }) => ,
+}));
+
+jest.mock('lucide-react', () => ({
+ User: () => ,
+ Mail: () => ,
+ Shield: () => ,
+ CalendarDays: () => ,
+ Edit: () => ,
+ Key: () => ,
+ Settings: () => ,
+}));
+
+jest.mock('../../../core/components', () => ({
+ BackButton: () => Back
,
+ ThemeToggle: () => Theme Toggle
,
+}));
+
+const createTestQueryClient = () => new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+});
+
+const renderWithProviders = (component: React.ReactElement) => {
+ const queryClient = createTestQueryClient();
+ return render(
+
+
+ {component}
+
+
+ );
+};
+
+describe('ProfileView', () => {
+ const mockProfile = {
+ id: 'user123',
+ email: 'test@example.com',
+ username: 'testuser',
+ is_active: true,
+ created_at: '2023-01-01T00:00:00Z',
+ updated_at: '2023-01-02T00:00:00Z',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders loading state', () => {
+ mockUseProfile.mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ error: null,
+ } as any);
+
+ renderWithProviders();
+
+ expect(screen.getAllByTestId('skeleton')).toHaveLength(5);
+ });
+
+ it('renders error state', () => {
+ mockUseProfile.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ error: new Error('Failed to load'),
+ } as any);
+
+ renderWithProviders();
+
+ expect(screen.getByText('Failed to load profile. Please try again.')).toBeInTheDocument();
+ });
+
+ it('renders no data state', () => {
+ mockUseProfile.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ error: null,
+ } as any);
+
+ renderWithProviders();
+
+ expect(screen.getByText('No profile data available.')).toBeInTheDocument();
+ });
+
+ it('renders profile information correctly', async () => {
+ mockUseProfile.mockReturnValue({
+ data: mockProfile,
+ isLoading: false,
+ error: null,
+ } as any);
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText('Profile Information')).toBeInTheDocument();
+ expect(screen.getByText('testuser')).toBeInTheDocument();
+ expect(screen.getByText('test@example.com')).toBeInTheDocument();
+ expect(screen.getByText('Active')).toBeInTheDocument();
+ });
+
+ // Check for navigation links
+ expect(screen.getByText('Edit Profile')).toBeInTheDocument();
+ expect(screen.getByText('Change Password')).toBeInTheDocument();
+ });
+
+ it('displays inactive status correctly', async () => {
+ const inactiveProfile = { ...mockProfile, is_active: false };
+
+ mockUseProfile.mockReturnValue({
+ data: inactiveProfile,
+ isLoading: false,
+ error: null,
+ } as any);
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText('Inactive')).toBeInTheDocument();
+ });
+ });
+
+ it('formats dates correctly', async () => {
+ mockUseProfile.mockReturnValue({
+ data: mockProfile,
+ isLoading: false,
+ error: null,
+ } as any);
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ // Check that dates are formatted (exact format may vary by locale)
+ expect(screen.getByText(/January 1, 2023/)).toBeInTheDocument();
+ expect(screen.getByText(/January 2, 2023/)).toBeInTheDocument();
+ });
+ });
+
+ it('renders theme settings section', async () => {
+ mockUseProfile.mockReturnValue({
+ data: mockProfile,
+ isLoading: false,
+ error: null,
+ } as any);
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText('Theme Preference')).toBeInTheDocument();
+ expect(screen.getByText('Choose your preferred color theme')).toBeInTheDocument();
+ expect(screen.getByTestId('settings-icon')).toBeInTheDocument();
+ expect(screen.getByTestId('theme-toggle')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/features/profile/__tests__/profileService.test.ts b/frontend/src/features/profile/__tests__/profileService.test.ts
new file mode 100644
index 0000000..4aaa74e
--- /dev/null
+++ b/frontend/src/features/profile/__tests__/profileService.test.ts
@@ -0,0 +1,193 @@
+import { profileService } from '../data/profile.service';
+import { apiClient } from '../../../core/data/apiClient';
+
+// Mock the apiClient
+jest.mock('../../../core/data/apiClient');
+const mockApiClient = apiClient as jest.Mocked;
+
+describe('profileService', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('getProfile', () => {
+ it('should call apiClient.get with correct URL', async () => {
+ const mockProfile = {
+ id: 'user123',
+ email: 'test@example.com',
+ username: 'testuser',
+ is_active: true,
+ created_at: '2023-01-01T00:00:00Z',
+ updated_at: '2023-01-02T00:00:00Z',
+ };
+
+ mockApiClient.get.mockResolvedValue(mockProfile);
+
+ const result = await profileService.getProfile();
+
+ expect(mockApiClient.get).toHaveBeenCalledWith('/api/v1/users/me');
+ expect(result).toEqual(mockProfile);
+ });
+
+ it('should handle API errors', async () => {
+ const error = new Error('Failed to fetch profile');
+ mockApiClient.get.mockRejectedValue(error);
+
+ await expect(profileService.getProfile()).rejects.toThrow('Failed to fetch profile');
+ expect(mockApiClient.get).toHaveBeenCalledWith('/api/v1/users/me');
+ });
+ });
+
+ describe('updateProfile', () => {
+ it('should call apiClient.put with correct URL and data', async () => {
+ const updateData = {
+ username: 'newusername',
+ email: 'newemail@example.com'
+ };
+
+ const mockUpdatedProfile = {
+ id: 'user123',
+ email: 'newemail@example.com',
+ username: 'newusername',
+ is_active: true,
+ created_at: '2023-01-01T00:00:00Z',
+ updated_at: '2023-01-02T00:00:00Z',
+ };
+
+ mockApiClient.put.mockResolvedValue(mockUpdatedProfile);
+
+ const result = await profileService.updateProfile(updateData);
+
+ expect(mockApiClient.put).toHaveBeenCalledWith('/api/v1/users/me', updateData);
+ expect(result).toEqual(mockUpdatedProfile);
+ });
+
+ it('should handle partial update data', async () => {
+ const updateData = {
+ username: 'newusername'
+ };
+
+ const mockUpdatedProfile = {
+ id: 'user123',
+ email: 'test@example.com',
+ username: 'newusername',
+ is_active: true,
+ created_at: '2023-01-01T00:00:00Z',
+ updated_at: '2023-01-02T00:00:00Z',
+ };
+
+ mockApiClient.put.mockResolvedValue(mockUpdatedProfile);
+
+ const result = await profileService.updateProfile(updateData);
+
+ expect(mockApiClient.put).toHaveBeenCalledWith('/api/v1/users/me', updateData);
+ expect(result).toEqual(mockUpdatedProfile);
+ });
+
+ it('should handle API errors', async () => {
+ const updateData = {
+ username: 'existinguser'
+ };
+
+ const error = new Error('Username already exists');
+ mockApiClient.put.mockRejectedValue(error);
+
+ await expect(profileService.updateProfile(updateData)).rejects.toThrow('Username already exists');
+ expect(mockApiClient.put).toHaveBeenCalledWith('/api/v1/users/me', updateData);
+ });
+
+ it('should handle validation errors', async () => {
+ const updateData = {
+ email: 'invalid-email'
+ };
+
+ const error = {
+ response: {
+ data: {
+ detail: 'Invalid email format'
+ }
+ }
+ };
+ mockApiClient.put.mockRejectedValue(error);
+
+ await expect(profileService.updateProfile(updateData)).rejects.toEqual(error);
+ expect(mockApiClient.put).toHaveBeenCalledWith('/api/v1/users/me', updateData);
+ });
+ });
+
+ describe('changePassword', () => {
+ it('should call apiClient.put with correct URL and data', async () => {
+ const passwordData = {
+ current_password: 'oldpassword',
+ new_password: 'newpassword123',
+ confirm_password: 'newpassword123'
+ };
+
+ const mockResponse = {
+ message: 'Password changed successfully'
+ };
+
+ mockApiClient.put.mockResolvedValue(mockResponse);
+
+ const result = await profileService.changePassword(passwordData);
+
+ expect(mockApiClient.put).toHaveBeenCalledWith('/api/v1/users/me/password', passwordData);
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should handle API errors', async () => {
+ const passwordData = {
+ current_password: 'wrongpassword',
+ new_password: 'newpassword123',
+ confirm_password: 'newpassword123'
+ };
+
+ const error = new Error('Current password is incorrect');
+ mockApiClient.put.mockRejectedValue(error);
+
+ await expect(profileService.changePassword(passwordData)).rejects.toThrow('Current password is incorrect');
+ expect(mockApiClient.put).toHaveBeenCalledWith('/api/v1/users/me/password', passwordData);
+ });
+
+ it('should handle validation errors', async () => {
+ const passwordData = {
+ current_password: 'oldpassword',
+ new_password: 'newpassword123',
+ confirm_password: 'differentpassword'
+ };
+
+ const error = {
+ response: {
+ data: {
+ detail: 'New password and confirmation do not match'
+ }
+ }
+ };
+ mockApiClient.put.mockRejectedValue(error);
+
+ await expect(profileService.changePassword(passwordData)).rejects.toEqual(error);
+ expect(mockApiClient.put).toHaveBeenCalledWith('/api/v1/users/me/password', passwordData);
+ });
+
+ it('should handle server errors', async () => {
+ const passwordData = {
+ current_password: 'oldpassword',
+ new_password: 'newpassword123',
+ confirm_password: 'newpassword123'
+ };
+
+ const error = {
+ response: {
+ status: 500,
+ data: {
+ detail: 'Internal server error'
+ }
+ }
+ };
+ mockApiClient.put.mockRejectedValue(error);
+
+ await expect(profileService.changePassword(passwordData)).rejects.toEqual(error);
+ expect(mockApiClient.put).toHaveBeenCalledWith('/api/v1/users/me/password', passwordData);
+ });
+ });
+});
diff --git a/frontend/src/features/profile/__tests__/useChangePassword.test.ts b/frontend/src/features/profile/__tests__/useChangePassword.test.ts
new file mode 100644
index 0000000..d3a5d2a
--- /dev/null
+++ b/frontend/src/features/profile/__tests__/useChangePassword.test.ts
@@ -0,0 +1,233 @@
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { useChangePassword } from '../hooks/useChangePassword';
+import { profileService } from '../data/profile.service';
+import { toast } from 'sonner';
+
+// Mock the profile service
+jest.mock('../data/profile.service');
+const mockProfileService = profileService as jest.Mocked;
+
+// Mock sonner toast
+jest.mock('sonner', () => ({
+ toast: {
+ success: jest.fn(),
+ error: jest.fn(),
+ },
+}));
+
+const createTestQueryClient = () => new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+});
+
+const wrapper = ({ children }: { children: React.ReactNode }) => {
+ const queryClient = createTestQueryClient();
+ return (
+
+ {children}
+
+ );
+};
+
+describe('useChangePassword', () => {
+ const mockResponse = {
+ message: 'Password changed successfully'
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should change password successfully', async () => {
+ mockProfileService.changePassword.mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useChangePassword(), { wrapper });
+
+ const passwordData = {
+ current_password: 'oldpassword',
+ new_password: 'newpassword123',
+ confirm_password: 'newpassword123'
+ };
+
+ result.current.mutate(passwordData);
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ });
+
+ expect(result.current.data).toEqual(mockResponse);
+ expect(mockProfileService.changePassword).toHaveBeenCalledWith(passwordData);
+ expect(toast.success).toHaveBeenCalledWith('Password changed successfully');
+ });
+
+ it('should handle change password error', async () => {
+ const error = new Error('Failed to change password');
+ mockProfileService.changePassword.mockRejectedValue(error);
+
+ const { result } = renderHook(() => useChangePassword(), { wrapper });
+
+ const passwordData = {
+ current_password: 'wrongpassword',
+ new_password: 'newpassword123',
+ confirm_password: 'newpassword123'
+ };
+
+ result.current.mutate(passwordData);
+
+ await waitFor(() => {
+ expect(result.current.isError).toBe(true);
+ });
+
+ expect(result.current.error).toEqual(error);
+ expect(toast.error).toHaveBeenCalledWith('Failed to change password');
+ });
+
+ it('should handle API error response', async () => {
+ const apiError = {
+ response: {
+ data: {
+ detail: 'Current password is incorrect'
+ }
+ }
+ };
+ mockProfileService.changePassword.mockRejectedValue(apiError);
+
+ const { result } = renderHook(() => useChangePassword(), { wrapper });
+
+ const passwordData = {
+ current_password: 'wrongpassword',
+ new_password: 'newpassword123',
+ confirm_password: 'newpassword123'
+ };
+
+ result.current.mutate(passwordData);
+
+ await waitFor(() => {
+ expect(result.current.isError).toBe(true);
+ });
+
+ expect(toast.error).toHaveBeenCalledWith('Current password is incorrect');
+ });
+
+ it('should handle error without response data', async () => {
+ const error = new Error('Network error');
+ mockProfileService.changePassword.mockRejectedValue(error);
+
+ const { result } = renderHook(() => useChangePassword(), { wrapper });
+
+ const passwordData = {
+ current_password: 'oldpassword',
+ new_password: 'newpassword123',
+ confirm_password: 'newpassword123'
+ };
+
+ result.current.mutate(passwordData);
+
+ await waitFor(() => {
+ expect(result.current.isError).toBe(true);
+ });
+
+ expect(toast.error).toHaveBeenCalledWith('Network error');
+ });
+
+ it('should handle error with message property', async () => {
+ const error = {
+ message: 'Custom error message'
+ };
+ mockProfileService.changePassword.mockRejectedValue(error);
+
+ const { result } = renderHook(() => useChangePassword(), { wrapper });
+
+ const passwordData = {
+ current_password: 'oldpassword',
+ new_password: 'newpassword123',
+ confirm_password: 'newpassword123'
+ };
+
+ result.current.mutate(passwordData);
+
+ await waitFor(() => {
+ expect(result.current.isError).toBe(true);
+ });
+
+ expect(toast.error).toHaveBeenCalledWith('Custom error message');
+ });
+
+ it('should handle unknown error format', async () => {
+ const error = 'Unknown error';
+ mockProfileService.changePassword.mockRejectedValue(error);
+
+ const { result } = renderHook(() => useChangePassword(), { wrapper });
+
+ const passwordData = {
+ current_password: 'oldpassword',
+ new_password: 'newpassword123',
+ confirm_password: 'newpassword123'
+ };
+
+ result.current.mutate(passwordData);
+
+ await waitFor(() => {
+ expect(result.current.isError).toBe(true);
+ });
+
+ expect(toast.error).toHaveBeenCalledWith('Failed to change password');
+ });
+
+ it('should handle validation error from API', async () => {
+ const validationError = {
+ response: {
+ data: {
+ detail: 'New password and confirmation do not match'
+ }
+ }
+ };
+ mockProfileService.changePassword.mockRejectedValue(validationError);
+
+ const { result } = renderHook(() => useChangePassword(), { wrapper });
+
+ const passwordData = {
+ current_password: 'oldpassword',
+ new_password: 'newpassword123',
+ confirm_password: 'differentpassword'
+ };
+
+ result.current.mutate(passwordData);
+
+ await waitFor(() => {
+ expect(result.current.isError).toBe(true);
+ });
+
+ expect(toast.error).toHaveBeenCalledWith('New password and confirmation do not match');
+ });
+
+ it('should handle server error', async () => {
+ const serverError = {
+ response: {
+ data: {
+ detail: 'Internal server error'
+ }
+ }
+ };
+ mockProfileService.changePassword.mockRejectedValue(serverError);
+
+ const { result } = renderHook(() => useChangePassword(), { wrapper });
+
+ const passwordData = {
+ current_password: 'oldpassword',
+ new_password: 'newpassword123',
+ confirm_password: 'newpassword123'
+ };
+
+ result.current.mutate(passwordData);
+
+ await waitFor(() => {
+ expect(result.current.isError).toBe(true);
+ });
+
+ expect(toast.error).toHaveBeenCalledWith('Internal server error');
+ });
+});
diff --git a/frontend/src/features/profile/__tests__/useProfile.test.ts b/frontend/src/features/profile/__tests__/useProfile.test.ts
new file mode 100644
index 0000000..512a89b
--- /dev/null
+++ b/frontend/src/features/profile/__tests__/useProfile.test.ts
@@ -0,0 +1,74 @@
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { useProfile } from '../hooks/useProfile';
+import { profileService } from '../data/profile.service';
+
+// Mock the profile service
+jest.mock('../data/profile.service');
+const mockProfileService = profileService as jest.Mocked;
+
+const createTestQueryClient = () => new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+});
+
+const wrapper = ({ children }: { children: React.ReactNode }) => {
+ const queryClient = createTestQueryClient();
+ return (
+
+ {children}
+
+ );
+};
+
+describe('useProfile', () => {
+ const mockProfile = {
+ id: 'user123',
+ email: 'test@example.com',
+ username: 'testuser',
+ is_active: true,
+ created_at: '2023-01-01T00:00:00Z',
+ updated_at: '2023-01-02T00:00:00Z',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should fetch profile data successfully', async () => {
+ mockProfileService.getProfile.mockResolvedValue(mockProfile);
+
+ const { result } = renderHook(() => useProfile(), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ });
+
+ expect(result.current.data).toEqual(mockProfile);
+ expect(mockProfileService.getProfile).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle fetch error', async () => {
+ const error = new Error('Failed to fetch profile');
+ mockProfileService.getProfile.mockRejectedValue(error);
+
+ const { result } = renderHook(() => useProfile(), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.isError).toBe(true);
+ });
+
+ expect(result.current.error).toEqual(error);
+ expect(mockProfileService.getProfile).toHaveBeenCalledTimes(1);
+ });
+
+ it('should have correct query configuration', () => {
+ const { result } = renderHook(() => useProfile(), { wrapper });
+
+ expect(result.current.queryKey).toEqual(['profile']);
+ expect(result.current.staleTime).toBe(5 * 60 * 1000); // 5 minutes
+ expect(result.current.retry).toBe(1);
+ });
+});
diff --git a/frontend/src/features/profile/__tests__/useUpdateProfile.test.ts b/frontend/src/features/profile/__tests__/useUpdateProfile.test.ts
new file mode 100644
index 0000000..da22010
--- /dev/null
+++ b/frontend/src/features/profile/__tests__/useUpdateProfile.test.ts
@@ -0,0 +1,192 @@
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { useUpdateProfile } from '../hooks/useUpdateProfile';
+import { profileService } from '../data/profile.service';
+import { toast } from 'sonner';
+
+// Mock the profile service
+jest.mock('../data/profile.service');
+const mockProfileService = profileService as jest.Mocked;
+
+// Mock sonner toast
+jest.mock('sonner', () => ({
+ toast: {
+ success: jest.fn(),
+ error: jest.fn(),
+ },
+}));
+
+const createTestQueryClient = () => new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+});
+
+const wrapper = ({ children }: { children: React.ReactNode }) => {
+ const queryClient = createTestQueryClient();
+ return (
+
+ {children}
+
+ );
+};
+
+describe('useUpdateProfile', () => {
+ const mockUpdatedProfile = {
+ id: 'user123',
+ email: 'updated@example.com',
+ username: 'updateduser',
+ is_active: true,
+ created_at: '2023-01-01T00:00:00Z',
+ updated_at: '2023-01-02T00:00:00Z',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should update profile successfully', async () => {
+ mockProfileService.updateProfile.mockResolvedValue(mockUpdatedProfile);
+
+ const { result } = renderHook(() => useUpdateProfile(), { wrapper });
+
+ const updateData = {
+ username: 'updateduser',
+ email: 'updated@example.com'
+ };
+
+ result.current.mutate(updateData);
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ });
+
+ expect(result.current.data).toEqual(mockUpdatedProfile);
+ expect(mockProfileService.updateProfile).toHaveBeenCalledWith(updateData);
+ expect(toast.success).toHaveBeenCalledWith('Profile updated successfully');
+ });
+
+ it('should handle update error', async () => {
+ const error = new Error('Failed to update profile');
+ mockProfileService.updateProfile.mockRejectedValue(error);
+
+ const { result } = renderHook(() => useUpdateProfile(), { wrapper });
+
+ const updateData = {
+ username: 'updateduser'
+ };
+
+ result.current.mutate(updateData);
+
+ await waitFor(() => {
+ expect(result.current.isError).toBe(true);
+ });
+
+ expect(result.current.error).toEqual(error);
+ expect(toast.error).toHaveBeenCalledWith('Failed to update profile');
+ });
+
+ it('should handle API error response', async () => {
+ const apiError = {
+ response: {
+ data: {
+ detail: 'Username already exists'
+ }
+ }
+ };
+ mockProfileService.updateProfile.mockRejectedValue(apiError);
+
+ const { result } = renderHook(() => useUpdateProfile(), { wrapper });
+
+ const updateData = {
+ username: 'existinguser'
+ };
+
+ result.current.mutate(updateData);
+
+ await waitFor(() => {
+ expect(result.current.isError).toBe(true);
+ });
+
+ expect(toast.error).toHaveBeenCalledWith('Username already exists');
+ });
+
+ it('should handle error without response data', async () => {
+ const error = new Error('Network error');
+ mockProfileService.updateProfile.mockRejectedValue(error);
+
+ const { result } = renderHook(() => useUpdateProfile(), { wrapper });
+
+ const updateData = {
+ username: 'newuser'
+ };
+
+ result.current.mutate(updateData);
+
+ await waitFor(() => {
+ expect(result.current.isError).toBe(true);
+ });
+
+ expect(toast.error).toHaveBeenCalledWith('Network error');
+ });
+
+ it('should handle error with message property', async () => {
+ const error = {
+ message: 'Custom error message'
+ };
+ mockProfileService.updateProfile.mockRejectedValue(error);
+
+ const { result } = renderHook(() => useUpdateProfile(), { wrapper });
+
+ const updateData = {
+ email: 'newemail@example.com'
+ };
+
+ result.current.mutate(updateData);
+
+ await waitFor(() => {
+ expect(result.current.isError).toBe(true);
+ });
+
+ expect(toast.error).toHaveBeenCalledWith('Custom error message');
+ });
+
+ it('should handle unknown error format', async () => {
+ const error = 'Unknown error';
+ mockProfileService.updateProfile.mockRejectedValue(error);
+
+ const { result } = renderHook(() => useUpdateProfile(), { wrapper });
+
+ const updateData = {
+ username: 'testuser'
+ };
+
+ result.current.mutate(updateData);
+
+ await waitFor(() => {
+ expect(result.current.isError).toBe(true);
+ });
+
+ expect(toast.error).toHaveBeenCalledWith('Failed to update profile');
+ });
+
+ it('should invalidate profile query on success', async () => {
+ mockProfileService.updateProfile.mockResolvedValue(mockUpdatedProfile);
+
+ const { result } = renderHook(() => useUpdateProfile(), { wrapper });
+
+ const updateData = {
+ username: 'updateduser'
+ };
+
+ result.current.mutate(updateData);
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ });
+
+ // The query should be invalidated (this is tested implicitly through the success callback)
+ expect(mockProfileService.updateProfile).toHaveBeenCalledWith(updateData);
+ });
+});
diff --git a/frontend/src/features/profile/components/ChangePassword.tsx b/frontend/src/features/profile/components/ChangePassword.tsx
new file mode 100644
index 0000000..73b045b
--- /dev/null
+++ b/frontend/src/features/profile/components/ChangePassword.tsx
@@ -0,0 +1,234 @@
+import React, { useState } from 'react';
+import { useChangePassword } from '../hooks/useChangePassword';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../../components/ui/card';
+import { Button } from '../../../components/ui/button';
+import { Input } from '../../../components/ui/input';
+import { Label } from '../../../components/ui/label';
+import { BackButton } from '../../../core/components/BackButton';
+import { Key, Eye, EyeOff } from 'lucide-react';
+import { Link, useNavigate } from 'react-router-dom';
+
+export const ChangePassword: React.FC = () => {
+ const changePasswordMutation = useChangePassword();
+ const navigate = useNavigate();
+
+ const [formData, setFormData] = useState({
+ current_password: '',
+ new_password: '',
+ confirm_password: ''
+ });
+ const [errors, setErrors] = useState>({});
+ const [showPasswords, setShowPasswords] = useState({
+ current: false,
+ new: false,
+ confirm: false
+ });
+
+ const validateForm = () => {
+ const newErrors: Record = {};
+
+ // Current password validation
+ if (!formData.current_password.trim()) {
+ newErrors.current_password = 'Current password is required';
+ }
+
+ // New password validation
+ if (!formData.new_password.trim()) {
+ newErrors.new_password = 'New password is required';
+ } else if (formData.new_password.length < 6) {
+ newErrors.new_password = 'New password must be at least 6 characters';
+ }
+
+ // Confirm password validation
+ if (!formData.confirm_password.trim()) {
+ newErrors.confirm_password = 'Please confirm your new password';
+ } else if (formData.new_password !== formData.confirm_password) {
+ newErrors.confirm_password = 'Passwords do not match';
+ }
+
+ // Check if new password is different from current
+ if (formData.current_password && formData.new_password &&
+ formData.current_password === formData.new_password) {
+ newErrors.new_password = 'New password must be different from current password';
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!validateForm()) {
+ return;
+ }
+
+ changePasswordMutation.mutate(formData, {
+ onSuccess: () => {
+ // Reset form
+ setFormData({
+ current_password: '',
+ new_password: '',
+ confirm_password: ''
+ });
+ // Navigate back to profile
+ navigate('/profile');
+ }
+ });
+ };
+
+ const handleInputChange = (field: string, value: string) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ // Clear error when user starts typing
+ if (errors[field]) {
+ setErrors(prev => ({ ...prev, [field]: '' }));
+ }
+ };
+
+ const togglePasswordVisibility = (field: 'current' | 'new' | 'confirm') => {
+ setShowPasswords(prev => ({ ...prev, [field]: !prev[field] }));
+ };
+
+ return (
+
+ {/* Back Button */}
+
+
+
+
+
+
+
+
+ Change Password
+
+
+ Update your account password for better security
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/features/profile/components/ProfileEdit.tsx b/frontend/src/features/profile/components/ProfileEdit.tsx
new file mode 100644
index 0000000..0c34b65
--- /dev/null
+++ b/frontend/src/features/profile/components/ProfileEdit.tsx
@@ -0,0 +1,184 @@
+import React, { useState, useEffect } from 'react';
+import { useProfile } from '../hooks/useProfile';
+import { useUpdateProfile } from '../hooks/useUpdateProfile';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../../components/ui/card';
+import { Button } from '../../../components/ui/button';
+import { Input } from '../../../components/ui/input';
+import { Label } from '../../../components/ui/label';
+import { BackButton } from '../../../core/components/BackButton';
+import { Save, X } from 'lucide-react';
+import { Link } from 'react-router-dom';
+import { Skeleton } from '../../../components/ui/skeleton';
+
+export const ProfileEdit: React.FC = () => {
+ const { data: profile, isLoading } = useProfile();
+ const updateProfileMutation = useUpdateProfile();
+
+ const [formData, setFormData] = useState({
+ username: '',
+ email: ''
+ });
+ const [errors, setErrors] = useState>({});
+
+ useEffect(() => {
+ if (profile) {
+ setFormData({
+ username: profile.username,
+ email: profile.email
+ });
+ }
+ }, [profile]);
+
+ const validateForm = () => {
+ const newErrors: Record = {};
+
+ // Username validation
+ if (!formData.username.trim()) {
+ newErrors.username = 'Username is required';
+ } else if (formData.username.length < 3) {
+ newErrors.username = 'Username must be at least 3 characters';
+ } else if (formData.username.length > 50) {
+ newErrors.username = 'Username must be less than 50 characters';
+ } else if (!/^[a-zA-Z0-9_]+$/.test(formData.username)) {
+ newErrors.username = 'Username can only contain letters, numbers, and underscores';
+ }
+
+ // Email validation
+ if (!formData.email.trim()) {
+ newErrors.email = 'Email is required';
+ } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
+ newErrors.email = 'Please enter a valid email address';
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!validateForm()) {
+ return;
+ }
+
+ // Only send changed fields
+ const updateData: any = {};
+ if (formData.username !== profile?.username) {
+ updateData.username = formData.username;
+ }
+ if (formData.email !== profile?.email) {
+ updateData.email = formData.email;
+ }
+
+ if (Object.keys(updateData).length === 0) {
+ return; // No changes to save
+ }
+
+ updateProfileMutation.mutate(updateData);
+ };
+
+ const handleInputChange = (field: string, value: string) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ // Clear error when user starts typing
+ if (errors[field]) {
+ setErrors(prev => ({ ...prev, [field]: '' }));
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Back Button */}
+
+
+
+
+
+
+ Edit Profile
+
+ Update your account information
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/features/profile/components/ProfileView.tsx b/frontend/src/features/profile/components/ProfileView.tsx
new file mode 100644
index 0000000..aafa169
--- /dev/null
+++ b/frontend/src/features/profile/components/ProfileView.tsx
@@ -0,0 +1,184 @@
+import React from 'react';
+import { useProfile } from '../hooks/useProfile';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../../components/ui/card';
+import { Button } from '../../../components/ui/button';
+import { Badge } from '../../../components/ui/badge';
+import { BackButton, ThemeToggle } from '../../../core/components';
+import { CalendarDays, Mail, User, Shield, Edit, Key, Settings } from 'lucide-react';
+import { Link } from 'react-router-dom';
+import { Skeleton } from '../../../components/ui/skeleton';
+
+export const ProfileView: React.FC = () => {
+ const { data: profile, isLoading, error } = useProfile();
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+ Failed to load profile. Please try again.
+
+
+
+
+ );
+ }
+
+ if (!profile) {
+ return (
+
+
+
+
+ No profile data available.
+
+
+
+
+ );
+ }
+
+ const formatDate = (dateString: string) => {
+ return new Date(dateString).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+ };
+
+ return (
+
+ {/* Back Button */}
+
+
+
+
+
+
+
+
+ Profile Information
+
+
+ View and manage your account information
+
+
+
+ {/* Username */}
+
+
+
+
+
Username
+
{profile.username}
+
+
+
+
+ {/* Email */}
+
+
+
+
+
Email Address
+
{profile.email}
+
+
+
+
+ {/* Account Status */}
+
+
+
+
+
Account Status
+
+
+ {profile.is_active ? "Active" : "Inactive"}
+
+
+
+
+
+
+ {/* Theme Settings */}
+
+
+
+
+
Theme Preference
+
Choose your preferred color theme
+
+
+
+
+
+ {/* Created Date */}
+
+
+
+
+
Member Since
+
+ {formatDate(profile.created_at)}
+
+
+
+
+
+ {/* Last Updated */}
+ {profile.updated_at && (
+
+
+
+
+
Last Updated
+
+ {formatDate(profile.updated_at)}
+
+
+
+
+ )}
+
+ {/* Action Buttons */}
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/features/profile/data/profile.schema.ts b/frontend/src/features/profile/data/profile.schema.ts
new file mode 100644
index 0000000..ce4cdfe
--- /dev/null
+++ b/frontend/src/features/profile/data/profile.schema.ts
@@ -0,0 +1,23 @@
+export interface ProfileUpdate {
+ username?: string;
+ email?: string;
+}
+
+export interface ChangePasswordRequest {
+ current_password: string;
+ new_password: string;
+ confirm_password: string;
+}
+
+export interface ChangePasswordResponse {
+ message: string;
+}
+
+export interface ProfileUser {
+ id: string;
+ email: string;
+ username: string;
+ is_active: boolean;
+ created_at: string;
+ updated_at: string;
+}
diff --git a/frontend/src/features/profile/data/profile.service.ts b/frontend/src/features/profile/data/profile.service.ts
new file mode 100644
index 0000000..3ebf9d0
--- /dev/null
+++ b/frontend/src/features/profile/data/profile.service.ts
@@ -0,0 +1,15 @@
+import { apiClient } from '../../../core/data/apiClient';
+import { ProfileUpdate, ChangePasswordRequest, ChangePasswordResponse, ProfileUser } from './profile.schema';
+
+const BASE_URL = '/api/v1';
+
+export const profileService = {
+ getProfile: (): Promise =>
+ apiClient.get(`${BASE_URL}/users/me`),
+
+ updateProfile: (data: ProfileUpdate): Promise =>
+ apiClient.put(`${BASE_URL}/users/me`, data),
+
+ changePassword: (data: ChangePasswordRequest): Promise =>
+ apiClient.put(`${BASE_URL}/users/me/password`, data)
+};
diff --git a/frontend/src/features/profile/hooks/useChangePassword.ts b/frontend/src/features/profile/hooks/useChangePassword.ts
new file mode 100644
index 0000000..44bdd43
--- /dev/null
+++ b/frontend/src/features/profile/hooks/useChangePassword.ts
@@ -0,0 +1,17 @@
+import { useMutation } from '@tanstack/react-query';
+import { toast } from 'sonner';
+import { profileService } from '../data/profile.service';
+import { ChangePasswordRequest, ChangePasswordResponse } from '../data/profile.schema';
+
+export const useChangePassword = () => {
+ return useMutation({
+ mutationFn: profileService.changePassword,
+ onSuccess: () => {
+ toast.success('Password changed successfully');
+ },
+ onError: (error: any) => {
+ const errorMessage = error?.response?.data?.detail || error?.message || 'Failed to change password';
+ toast.error(errorMessage);
+ },
+ });
+};
diff --git a/frontend/src/features/profile/hooks/useProfile.ts b/frontend/src/features/profile/hooks/useProfile.ts
new file mode 100644
index 0000000..57d7d4c
--- /dev/null
+++ b/frontend/src/features/profile/hooks/useProfile.ts
@@ -0,0 +1,12 @@
+import { useQuery } from '@tanstack/react-query';
+import { profileService } from '../data/profile.service';
+import { ProfileUser } from '../data/profile.schema';
+
+export const useProfile = () => {
+ return useQuery({
+ queryKey: ['profile'],
+ queryFn: profileService.getProfile,
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ retry: 1,
+ });
+};
diff --git a/frontend/src/features/profile/hooks/useUpdateProfile.ts b/frontend/src/features/profile/hooks/useUpdateProfile.ts
new file mode 100644
index 0000000..65a004d
--- /dev/null
+++ b/frontend/src/features/profile/hooks/useUpdateProfile.ts
@@ -0,0 +1,23 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { toast } from 'sonner';
+import { profileService } from '../data/profile.service';
+import { ProfileUpdate, ProfileUser } from '../data/profile.schema';
+
+export const useUpdateProfile = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: profileService.updateProfile,
+ onSuccess: (data) => {
+ // Update the profile cache
+ queryClient.setQueryData(['profile'], data);
+ // Invalidate and refetch profile data
+ queryClient.invalidateQueries({ queryKey: ['profile'] });
+ toast.success('Profile updated successfully');
+ },
+ onError: (error: any) => {
+ const errorMessage = error?.response?.data?.detail || error?.message || 'Failed to update profile';
+ toast.error(errorMessage);
+ },
+ });
+};
diff --git a/frontend/src/features/profile/index.ts b/frontend/src/features/profile/index.ts
new file mode 100644
index 0000000..fdff925
--- /dev/null
+++ b/frontend/src/features/profile/index.ts
@@ -0,0 +1,15 @@
+// Components
+export { ProfileView } from './components/ProfileView';
+export { ProfileEdit } from './components/ProfileEdit';
+export { ChangePassword } from './components/ChangePassword';
+
+// Hooks
+export { useProfile } from './hooks/useProfile';
+export { useUpdateProfile } from './hooks/useUpdateProfile';
+export { useChangePassword } from './hooks/useChangePassword';
+
+// Services
+export { profileService } from './data/profile.service';
+
+// Types
+export type { ProfileUpdate, ChangePasswordRequest, ChangePasswordResponse, ProfileUser } from './data/profile.schema';
diff --git a/frontend/src/index.css b/frontend/src/index.css
index f0df9fe..afad35b 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -51,7 +51,7 @@
a {
font-weight: 500;
- color: black;
+ color: inherit;
text-decoration: inherit;
}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index bef5202..29941ec 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,10 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
+import { ThemeProvider } from 'next-themes'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
-
+
+
+
,
)
diff --git a/frontend/src/pages/home.page.tsx b/frontend/src/pages/home.page.tsx
index 7f7e4ea..ded49f1 100644
--- a/frontend/src/pages/home.page.tsx
+++ b/frontend/src/pages/home.page.tsx
@@ -1,21 +1,19 @@
import { ProtectedRoute } from '@/core/components/ProtectedRoute'
import { NewsProvider } from '@/features/news/hooks/useNewsContext'
import { NewsBoard } from '@/features/news/components/NewsBoard'
+import { DashboardHeader } from '@/components/DashboardHeader'
+
const HomePage = () => {
return (
-
+
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..b18eeb9
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,16 @@
+import { defineConfig } from 'vite'
+import tailwindcss from '@tailwindcss/vite'
+import path from 'path'
+
+export default defineConfig({
+ plugins: [tailwindcss()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+ server: {
+ port: 5173,
+ host: true
+ }
+})
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 12cc248..bd571d5 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -40,7 +40,7 @@
resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz"
integrity sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==
-"@babel/core@^7.28.0":
+"@babel/core@^7.0.0", "@babel/core@^7.0.0-0", "@babel/core@^7.28.0":
version "7.28.0"
resolved "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz"
integrity sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==
@@ -212,12 +212,12 @@
"@csstools/color-helpers" "^5.0.2"
"@csstools/css-calc" "^2.1.4"
-"@csstools/css-parser-algorithms@^3.0.4":
+"@csstools/css-parser-algorithms@^3.0.4", "@csstools/css-parser-algorithms@^3.0.5":
version "3.0.5"
resolved "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz"
integrity sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==
-"@csstools/css-tokenizer@^3.0.3":
+"@csstools/css-tokenizer@^3.0.3", "@csstools/css-tokenizer@^3.0.4":
version "3.0.4"
resolved "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz"
integrity sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==
@@ -229,7 +229,7 @@
dependencies:
tslib "^2.0.0"
-"@dnd-kit/core@^6.3.1":
+"@dnd-kit/core@^6.3.0", "@dnd-kit/core@^6.3.1":
version "6.3.1"
resolved "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz"
integrity sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==
@@ -253,158 +253,11 @@
dependencies:
tslib "^2.0.0"
-"@emnapi/core@^1.4.3":
- version "1.5.0"
- resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.5.0.tgz#85cd84537ec989cebb2343606a1ee663ce4edaf0"
- integrity sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==
- dependencies:
- "@emnapi/wasi-threads" "1.1.0"
- tslib "^2.4.0"
-
-"@emnapi/runtime@^1.4.3":
- version "1.5.0"
- resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.5.0.tgz#9aebfcb9b17195dce3ab53c86787a6b7d058db73"
- integrity sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==
- dependencies:
- tslib "^2.4.0"
-
-"@emnapi/wasi-threads@1.1.0", "@emnapi/wasi-threads@^1.0.2":
- version "1.1.0"
- resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf"
- integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==
- dependencies:
- tslib "^2.4.0"
-
-"@esbuild/aix-ppc64@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz#164b19122e2ed54f85469df9dea98ddb01d5e79e"
- integrity sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw==
-
-"@esbuild/android-arm64@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.6.tgz#8f539e7def848f764f6432598e51cc3820fde3a5"
- integrity sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA==
-
-"@esbuild/android-arm@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.6.tgz#4ceb0f40113e9861169be83e2a670c260dd234ff"
- integrity sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg==
-
-"@esbuild/android-x64@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.6.tgz#ad4f280057622c25fe985c08999443a195dc63a8"
- integrity sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A==
-
"@esbuild/darwin-arm64@0.25.6":
version "0.25.6"
resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.6.tgz"
integrity sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA==
-"@esbuild/darwin-x64@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.6.tgz#2b4a6cedb799f635758d7832d75b23772c8ef68f"
- integrity sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg==
-
-"@esbuild/freebsd-arm64@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.6.tgz#a26266cc97dd78dc3c3f3d6788b1b83697b1055d"
- integrity sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg==
-
-"@esbuild/freebsd-x64@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.6.tgz#9feb8e826735c568ebfd94859b22a3fbb6a9bdd2"
- integrity sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ==
-
-"@esbuild/linux-arm64@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.6.tgz#c07cbed8e249f4c28e7f32781d36fc4695293d28"
- integrity sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ==
-
-"@esbuild/linux-arm@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.6.tgz#d6e2cd8ef3196468065d41f13fa2a61aaa72644a"
- integrity sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw==
-
-"@esbuild/linux-ia32@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.6.tgz#3e682bd47c4eddcc4b8f1393dfc8222482f17997"
- integrity sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw==
-
-"@esbuild/linux-loong64@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.6.tgz#473f5ea2e52399c08ad4cd6b12e6dbcddd630f05"
- integrity sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg==
-
-"@esbuild/linux-mips64el@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.6.tgz#9960631c9fd61605b0939c19043acf4ef2b51718"
- integrity sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw==
-
-"@esbuild/linux-ppc64@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.6.tgz#477cbf8bb04aa034b94f362c32c86b5c31db8d3e"
- integrity sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw==
-
-"@esbuild/linux-riscv64@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.6.tgz#bcdb46c8fb8e93aa779e9a0a62cd4ac00dcac626"
- integrity sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w==
-
-"@esbuild/linux-s390x@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.6.tgz#f412cf5fdf0aea849ff51c73fd817c6c0234d46d"
- integrity sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw==
-
-"@esbuild/linux-x64@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.6.tgz#d8233c09b5ebc0c855712dc5eeb835a3a3341108"
- integrity sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig==
-
-"@esbuild/netbsd-arm64@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.6.tgz#f51ae8dd1474172e73cf9cbaf8a38d1c72dd8f1a"
- integrity sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q==
-
-"@esbuild/netbsd-x64@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.6.tgz#a267538602c0e50a858cf41dcfe5d8036f8da8e7"
- integrity sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g==
-
-"@esbuild/openbsd-arm64@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.6.tgz#a51be60c425b85c216479b8c344ad0511635f2d2"
- integrity sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg==
-
-"@esbuild/openbsd-x64@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.6.tgz#7e4a743c73f75562e29223ba69d0be6c9c9008da"
- integrity sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw==
-
-"@esbuild/openharmony-arm64@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.6.tgz#2087a5028f387879154ebf44bdedfafa17682e5b"
- integrity sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA==
-
-"@esbuild/sunos-x64@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.6.tgz#56531f861723ea0dc6283a2bb8837304223cb736"
- integrity sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA==
-
-"@esbuild/win32-arm64@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.6.tgz#f4989f033deac6fae323acff58764fa8bc01436e"
- integrity sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q==
-
-"@esbuild/win32-ia32@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.6.tgz#b260e9df71e3939eb33925076d39f63cec7d1525"
- integrity sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ==
-
-"@esbuild/win32-x64@0.25.6":
- version "0.25.6"
- resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.6.tgz#4276edd5c105bc28b11c6a1f76fb9d29d1bd25c1"
- integrity sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA==
-
"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.7.0":
version "4.7.0"
resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz"
@@ -453,7 +306,7 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
-"@eslint/js@9.31.0", "@eslint/js@^9.30.1":
+"@eslint/js@^9.30.1", "@eslint/js@9.31.0":
version "9.31.0"
resolved "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz"
integrity sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==
@@ -576,15 +429,6 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
-"@napi-rs/wasm-runtime@^0.2.11":
- version "0.2.12"
- resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2"
- integrity sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==
- dependencies:
- "@emnapi/core" "^1.4.3"
- "@emnapi/runtime" "^1.4.3"
- "@tybys/wasm-util" "^0.10.0"
-
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
@@ -593,7 +437,7 @@
"@nodelib/fs.stat" "2.0.5"
run-parallel "^1.1.9"
-"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
+"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
version "2.0.5"
resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
@@ -911,7 +755,7 @@
dependencies:
"@radix-ui/react-primitive" "2.1.3"
-"@radix-ui/react-slot@1.2.3", "@radix-ui/react-slot@^1.2.3":
+"@radix-ui/react-slot@^1.2.3", "@radix-ui/react-slot@1.2.3":
version "1.2.3"
resolved "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz"
integrity sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==
@@ -1013,106 +857,11 @@
resolved "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz"
integrity sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==
-"@rollup/rollup-android-arm-eabi@4.45.1":
- version "4.45.1"
- resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.1.tgz#8560592f0dcf43b8cb0949af9f1d916205148d12"
- integrity sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA==
-
-"@rollup/rollup-android-arm64@4.45.1":
- version "4.45.1"
- resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.45.1.tgz#6bfb777bbce998691b6fd3e916b05cd46392d020"
- integrity sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ==
-
"@rollup/rollup-darwin-arm64@4.45.1":
version "4.45.1"
resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.45.1.tgz"
integrity sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA==
-"@rollup/rollup-darwin-x64@4.45.1":
- version "4.45.1"
- resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.45.1.tgz#c617a8ece21050bfbea299c126767d2e70cfa79a"
- integrity sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og==
-
-"@rollup/rollup-freebsd-arm64@4.45.1":
- version "4.45.1"
- resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.45.1.tgz#5a6af0a9acf82162d2910933649ae24fc0ea3ecb"
- integrity sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g==
-
-"@rollup/rollup-freebsd-x64@4.45.1":
- version "4.45.1"
- resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.45.1.tgz#ae9709463560196fc275bd0da598668a2e341023"
- integrity sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A==
-
-"@rollup/rollup-linux-arm-gnueabihf@4.45.1":
- version "4.45.1"
- resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.45.1.tgz#6ec52661764dbd54c19d6520a403aa385a5c0fbf"
- integrity sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q==
-
-"@rollup/rollup-linux-arm-musleabihf@4.45.1":
- version "4.45.1"
- resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.45.1.tgz#fd33ba4a43ef8419e96811236493d19436271923"
- integrity sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q==
-
-"@rollup/rollup-linux-arm64-gnu@4.45.1":
- version "4.45.1"
- resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.45.1.tgz#933b3d99b73c9d7bf4506cab0d5d313c7e74fd2d"
- integrity sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw==
-
-"@rollup/rollup-linux-arm64-musl@4.45.1":
- version "4.45.1"
- resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.45.1.tgz#dbe9ae24ee9e97b75662fddcb69eb7f23c89280a"
- integrity sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog==
-
-"@rollup/rollup-linux-loongarch64-gnu@4.45.1":
- version "4.45.1"
- resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.45.1.tgz#818c5a071eec744436dbcdd76fe9c3c869dc9a8d"
- integrity sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg==
-
-"@rollup/rollup-linux-powerpc64le-gnu@4.45.1":
- version "4.45.1"
- resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.45.1.tgz#6b8591def27d886fa147fb0340126c7d6682a7e4"
- integrity sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg==
-
-"@rollup/rollup-linux-riscv64-gnu@4.45.1":
- version "4.45.1"
- resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.45.1.tgz#f1861ac4ee8da64e0b0d23853ff26fe2baa876cf"
- integrity sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw==
-
-"@rollup/rollup-linux-riscv64-musl@4.45.1":
- version "4.45.1"
- resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.45.1.tgz#320c961401a923b374e358664527b188e374e1ae"
- integrity sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA==
-
-"@rollup/rollup-linux-s390x-gnu@4.45.1":
- version "4.45.1"
- resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.45.1.tgz#1763eed3362b50b6164d3f0947486c03cc7e616d"
- integrity sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw==
-
-"@rollup/rollup-linux-x64-gnu@4.45.1":
- version "4.45.1"
- resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.1.tgz#0d4c8d0b8f801902f0844a40a9d981a0179f4971"
- integrity sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==
-
-"@rollup/rollup-linux-x64-musl@4.45.1":
- version "4.45.1"
- resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.45.1.tgz#ec30bb48b5fe22a3aaba98072f2d5b7139e1a8eb"
- integrity sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw==
-
-"@rollup/rollup-win32-arm64-msvc@4.45.1":
- version "4.45.1"
- resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.45.1.tgz#27a6e48d1502e8e4bed96bedfb533738655874f2"
- integrity sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg==
-
-"@rollup/rollup-win32-ia32-msvc@4.45.1":
- version "4.45.1"
- resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.45.1.tgz#a2fbad3bec20ff879f3fd51720adf33692ca8f3d"
- integrity sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw==
-
-"@rollup/rollup-win32-x64-msvc@4.45.1":
- version "4.45.1"
- resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.45.1.tgz#e5085c6d13da15b4c5133cd2a6bb11f25b6bb77a"
- integrity sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA==
-
"@tailwindcss/node@4.1.11":
version "4.1.11"
resolved "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz"
@@ -1126,73 +875,11 @@
source-map-js "^1.2.1"
tailwindcss "4.1.11"
-"@tailwindcss/oxide-android-arm64@4.1.11":
- version "4.1.11"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.11.tgz#1f387d8302f011b61c226deb0c3a1d2bd79c6915"
- integrity sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==
-
"@tailwindcss/oxide-darwin-arm64@4.1.11":
version "4.1.11"
resolved "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.11.tgz"
integrity sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==
-"@tailwindcss/oxide-darwin-x64@4.1.11":
- version "4.1.11"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.11.tgz#a0022312993a3893d6ff0312d6e3c83c4636fef4"
- integrity sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==
-
-"@tailwindcss/oxide-freebsd-x64@4.1.11":
- version "4.1.11"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.11.tgz#dd8e55eb0b88fe7995b8148c0e0ae5fa27092d22"
- integrity sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==
-
-"@tailwindcss/oxide-linux-arm-gnueabihf@4.1.11":
- version "4.1.11"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.11.tgz#02ee99090988847d3f13d277679012cbffcdde37"
- integrity sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==
-
-"@tailwindcss/oxide-linux-arm64-gnu@4.1.11":
- version "4.1.11"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.11.tgz#4837559c102bebe65089879f6a0278ed473b4813"
- integrity sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==
-
-"@tailwindcss/oxide-linux-arm64-musl@4.1.11":
- version "4.1.11"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.11.tgz#bec465112a13a1383558ff36afdf28b8a8cb9021"
- integrity sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==
-
-"@tailwindcss/oxide-linux-x64-gnu@4.1.11":
- version "4.1.11"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.11.tgz#f9e47e6aa67ff77f32f7412bc9698d4278e101bf"
- integrity sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==
-
-"@tailwindcss/oxide-linux-x64-musl@4.1.11":
- version "4.1.11"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.11.tgz#7d6e8adcfb9bc84d8e2e2e8781d661edb9e41ba8"
- integrity sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==
-
-"@tailwindcss/oxide-wasm32-wasi@4.1.11":
- version "4.1.11"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.11.tgz#a1762f4939c6ebaa824696fda2fd7db1b85fbed2"
- integrity sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==
- dependencies:
- "@emnapi/core" "^1.4.3"
- "@emnapi/runtime" "^1.4.3"
- "@emnapi/wasi-threads" "^1.0.2"
- "@napi-rs/wasm-runtime" "^0.2.11"
- "@tybys/wasm-util" "^0.9.0"
- tslib "^2.8.0"
-
-"@tailwindcss/oxide-win32-arm64-msvc@4.1.11":
- version "4.1.11"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz#70ba392dca0fa3707ebe27d2bd6ac3e69d35e3b7"
- integrity sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==
-
-"@tailwindcss/oxide-win32-x64-msvc@4.1.11":
- version "4.1.11"
- resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.11.tgz#cdcb9eea9225a346c0695f67f621990b0534763f"
- integrity sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==
-
"@tailwindcss/oxide@4.1.11":
version "4.1.11"
resolved "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz"
@@ -1235,7 +922,7 @@
dependencies:
"@tanstack/query-core" "5.83.1"
-"@testing-library/dom@^10.4.1":
+"@testing-library/dom@^10.0.0", "@testing-library/dom@^10.4.1", "@testing-library/dom@>=7.21.4":
version "10.4.1"
resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz"
integrity sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==
@@ -1274,20 +961,6 @@
resolved "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz"
integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==
-"@tybys/wasm-util@^0.10.0":
- version "0.10.0"
- resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.0.tgz#2fd3cd754b94b378734ce17058d0507c45c88369"
- integrity sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==
- dependencies:
- tslib "^2.4.0"
-
-"@tybys/wasm-util@^0.9.0":
- version "0.9.0"
- resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.9.0.tgz#3e75eb00604c8d6db470bf18c37b7d984a0e3355"
- integrity sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==
- dependencies:
- tslib "^2.4.0"
-
"@types/aria-query@^5.0.1":
version "5.0.4"
resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz"
@@ -1338,7 +1011,7 @@
resolved "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz"
integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==
-"@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.6":
+"@types/estree@^1.0.0", "@types/estree@^1.0.6", "@types/estree@1.0.8":
version "1.0.8"
resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
@@ -1348,19 +1021,19 @@
resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
-"@types/node@^24.0.14":
+"@types/node@^18.0.0 || ^20.0.0 || >=22.0.0", "@types/node@^20.19.0 || >=22.12.0", "@types/node@^24.0.14":
version "24.0.14"
resolved "https://registry.npmjs.org/@types/node/-/node-24.0.14.tgz"
integrity sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw==
dependencies:
undici-types "~7.8.0"
-"@types/react-dom@^19.1.6":
+"@types/react-dom@*", "@types/react-dom@^18.0.0 || ^19.0.0", "@types/react-dom@^19.1.6":
version "19.1.6"
resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz"
integrity sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==
-"@types/react@^19.1.8":
+"@types/react@*", "@types/react@^18.0.0 || ^19.0.0", "@types/react@^19.0.0", "@types/react@^19.1.8":
version "19.1.8"
resolved "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz"
integrity sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==
@@ -1382,7 +1055,7 @@
natural-compare "^1.4.0"
ts-api-utils "^2.1.0"
-"@typescript-eslint/parser@8.37.0":
+"@typescript-eslint/parser@^8.37.0", "@typescript-eslint/parser@8.37.0":
version "8.37.0"
resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.37.0.tgz"
integrity sha512-kVIaQE9vrN9RLCQMQ3iyRlVJpTiDUY6woHGb30JDkfJErqrQEmtdWH3gV0PBAfGZgQXoqzXOO0T3K6ioApbbAA==
@@ -1410,7 +1083,7 @@
"@typescript-eslint/types" "8.37.0"
"@typescript-eslint/visitor-keys" "8.37.0"
-"@typescript-eslint/tsconfig-utils@8.37.0", "@typescript-eslint/tsconfig-utils@^8.37.0":
+"@typescript-eslint/tsconfig-utils@^8.37.0", "@typescript-eslint/tsconfig-utils@8.37.0":
version "8.37.0"
resolved "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.37.0.tgz"
integrity sha512-1/YHvAVTimMM9mmlPvTec9NP4bobA1RkDbMydxG8omqwJJLEW/Iy2C4adsAESIXU3WGLXFHSZUU+C9EoFWl4Zg==
@@ -1426,7 +1099,7 @@
debug "^4.3.4"
ts-api-utils "^2.1.0"
-"@typescript-eslint/types@8.37.0", "@typescript-eslint/types@^8.37.0":
+"@typescript-eslint/types@^8.37.0", "@typescript-eslint/types@8.37.0":
version "8.37.0"
resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.37.0.tgz"
integrity sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ==
@@ -1516,7 +1189,7 @@
estree-walker "^3.0.3"
magic-string "^0.30.17"
-"@vitest/pretty-format@3.2.4", "@vitest/pretty-format@^3.2.4":
+"@vitest/pretty-format@^3.2.4", "@vitest/pretty-format@3.2.4":
version "3.2.4"
resolved "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz"
integrity sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==
@@ -1562,7 +1235,7 @@ acorn-jsx@^5.3.2:
resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
-acorn@^8.15.0:
+"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.15.0:
version "8.15.0"
resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz"
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
@@ -1621,7 +1294,7 @@ aria-hidden@^1.2.4:
dependencies:
tslib "^2.0.0"
-aria-query@5.3.0, aria-query@^5.0.0:
+aria-query@^5.0.0, aria-query@5.3.0:
version "5.3.0"
resolved "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz"
integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==
@@ -1683,7 +1356,7 @@ braces@^3.0.3:
dependencies:
fill-range "^7.1.1"
-browserslist@^4.24.0:
+browserslist@^4.24.0, "browserslist@>= 4.21.0":
version "4.25.1"
resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz"
integrity sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==
@@ -1826,7 +1499,7 @@ data-urls@^5.0.0:
whatwg-mimetype "^4.0.0"
whatwg-url "^14.0.0"
-debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.1:
+debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.1, debug@4:
version "4.4.1"
resolved "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz"
integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==
@@ -2022,7 +1695,7 @@ eslint-visitor-keys@^4.2.1:
resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz"
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
-eslint@^9.30.1:
+"eslint@^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0", "eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^8.57.0 || ^9.0.0", eslint@^9.30.1, eslint@>=8.40:
version "9.31.0"
resolved "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz"
integrity sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==
@@ -2464,7 +2137,7 @@ jackspeak@^3.1.2:
optionalDependencies:
"@pkgjs/parseargs" "^0.11.0"
-jiti@^2.4.2:
+jiti@*, jiti@^2.4.2, jiti@>=1.21.0:
version "2.4.2"
resolved "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz"
integrity sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==
@@ -2486,7 +2159,7 @@ js-yaml@^4.1.0:
dependencies:
argparse "^2.0.1"
-jsdom@^26.0.0:
+jsdom@*, jsdom@^26.0.0:
version "26.1.0"
resolved "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz"
integrity sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==
@@ -2562,52 +2235,7 @@ lightningcss-darwin-arm64@1.30.1:
resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz"
integrity sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==
-lightningcss-darwin-x64@1.30.1:
- version "1.30.1"
- resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz#e81105d3fd6330860c15fe860f64d39cff5fbd22"
- integrity sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==
-
-lightningcss-freebsd-x64@1.30.1:
- version "1.30.1"
- resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz#a0e732031083ff9d625c5db021d09eb085af8be4"
- integrity sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==
-
-lightningcss-linux-arm-gnueabihf@1.30.1:
- version "1.30.1"
- resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz#1f5ecca6095528ddb649f9304ba2560c72474908"
- integrity sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==
-
-lightningcss-linux-arm64-gnu@1.30.1:
- version "1.30.1"
- resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz#eee7799726103bffff1e88993df726f6911ec009"
- integrity sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==
-
-lightningcss-linux-arm64-musl@1.30.1:
- version "1.30.1"
- resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz#f2e4b53f42892feeef8f620cbb889f7c064a7dfe"
- integrity sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==
-
-lightningcss-linux-x64-gnu@1.30.1:
- version "1.30.1"
- resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz#2fc7096224bc000ebb97eea94aea248c5b0eb157"
- integrity sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==
-
-lightningcss-linux-x64-musl@1.30.1:
- version "1.30.1"
- resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz#66dca2b159fd819ea832c44895d07e5b31d75f26"
- integrity sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==
-
-lightningcss-win32-arm64-msvc@1.30.1:
- version "1.30.1"
- resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz#7d8110a19d7c2d22bfdf2f2bb8be68e7d1b69039"
- integrity sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==
-
-lightningcss-win32-x64-msvc@1.30.1:
- version "1.30.1"
- resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz#fd7dd008ea98494b85d24b4bea016793f2e0e352"
- integrity sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==
-
-lightningcss@1.30.1:
+lightningcss@^1.21.0, lightningcss@1.30.1:
version "1.30.1"
resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz"
integrity sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==
@@ -2647,7 +2275,12 @@ loupe@^3.1.0, loupe@^3.1.4:
resolved "https://registry.npmjs.org/loupe/-/loupe-3.2.0.tgz"
integrity sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw==
-lru-cache@^10.2.0, lru-cache@^10.4.3:
+lru-cache@^10.2.0:
+ version "10.4.3"
+ resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz"
+ integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
+
+lru-cache@^10.4.3:
version "10.4.3"
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz"
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
@@ -2869,7 +2502,7 @@ pathval@^2.0.0:
resolved "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz"
integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==
-picocolors@1.1.1, picocolors@^1.1.1:
+picocolors@^1.1.1, picocolors@1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz"
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
@@ -2879,7 +2512,7 @@ picomatch@^2.3.1:
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
-picomatch@^4.0.2:
+"picomatch@^3 || ^4", picomatch@^4.0.2:
version "4.0.3"
resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz"
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
@@ -2927,7 +2560,7 @@ queue-microtask@^1.2.2:
resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
-react-dom@^19.1.0:
+"react-dom@^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom@^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom@^18.0.0 || ^19.0.0", "react-dom@^18.0.0 || ^19.0.0 || ^19.0.0-rc", react-dom@^19.1.0, react-dom@>=16.8.0, react-dom@>=18:
version "19.1.0"
resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz"
integrity sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==
@@ -2986,7 +2619,7 @@ react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
get-nonce "^1.0.0"
tslib "^2.0.0"
-react@^19.1.0:
+"react@^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react@^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react@^18 || ^19", "react@^18.0.0 || ^19.0.0", "react@^18.0.0 || ^19.0.0 || ^19.0.0-rc", react@^19.1.0, react@>=16.8.0, react@>=18:
version "19.1.0"
resolved "https://registry.npmjs.org/react/-/react-19.1.0.tgz"
integrity sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==
@@ -3072,7 +2705,12 @@ semver@^6.3.1:
resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
-semver@^7.5.3, semver@^7.6.0:
+semver@^7.5.3:
+ version "7.7.2"
+ resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz"
+ integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
+
+semver@^7.6.0:
version "7.7.2"
resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz"
integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
@@ -3208,7 +2846,7 @@ tailwind-merge@^3.3.1:
resolved "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz"
integrity sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==
-tailwindcss@4.1.11, tailwindcss@^4.1.11:
+tailwindcss@^4.1.11, tailwindcss@4.1.11:
version "4.1.11"
resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz"
integrity sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==
@@ -3310,15 +2948,15 @@ ts-api-utils@^2.1.0:
resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz"
integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==
-tslib@^2.0.0, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.8.0:
+tslib@^2.0.0, tslib@^2.1.0:
version "2.8.1"
resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
tw-animate-css@^1.3.5:
- version "1.3.6"
- resolved "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.6.tgz"
- integrity sha512-9dy0R9UsYEGmgf26L8UcHiLmSFTHa9+D7+dAt/G/sF5dCnPePZbfgDYinc7/UzAM7g/baVrmS6m9yEpU46d+LA==
+ version "1.4.0"
+ resolved "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz"
+ integrity sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
@@ -3337,7 +2975,7 @@ typescript-eslint@^8.35.1:
"@typescript-eslint/typescript-estree" "8.37.0"
"@typescript-eslint/utils" "8.37.0"
-typescript@~5.8.3:
+typescript@>=4.8.4, "typescript@>=4.8.4 <5.9.0", typescript@~5.8.3:
version "5.8.3"
resolved "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz"
integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==
@@ -3395,7 +3033,7 @@ vite-node@3.2.4:
pathe "^2.0.3"
vite "^5.0.0 || ^6.0.0 || ^7.0.0-0"
-"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^7.0.4:
+"vite@^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite@^5.2.0 || ^6 || ^7", vite@^7.0.4:
version "7.0.5"
resolved "https://registry.npmjs.org/vite/-/vite-7.0.5.tgz"
integrity sha512-1mncVwJxy2C9ThLwz0+2GKZyEXuC3MyWtAAlNftlZZXZDP3AJt5FmwcMit/IGGaNZ8ZOB2BNO/HFUB+CpN0NQw==
@@ -3409,7 +3047,7 @@ vite-node@3.2.4:
optionalDependencies:
fsevents "~2.3.3"
-vitest@^3.2.4:
+vitest@^3.2.4, vitest@3.2.4:
version "3.2.4"
resolved "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz"
integrity sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==
@@ -3537,3 +3175,8 @@ yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
+
+zod@^4.1.11:
+ version "4.1.11"
+ resolved "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz"
+ integrity sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==
diff --git a/scripts/check-docs-sync.sh b/scripts/check-docs-sync.sh
new file mode 100755
index 0000000..9b1449d
--- /dev/null
+++ b/scripts/check-docs-sync.sh
@@ -0,0 +1,131 @@
+#!/bin/bash
+
+# Documentation Synchronization Checker
+# This script detects when documentation might be out of sync with code changes
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+YELLOW='\033[1;33m'
+GREEN='\033[0;32m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+echo -e "${BLUE}🔍 Checking documentation synchronization...${NC}\n"
+
+# Function to check if files have been modified
+check_changes() {
+ local pattern="$1"
+ local description="$2"
+
+ if git diff --cached --name-only | grep -E "$pattern" > /dev/null 2>&1; then
+ echo -e "${YELLOW}⚠️ $description${NC}"
+ git diff --cached --name-only | grep -E "$pattern" | sed 's/^/ - /'
+ return 0
+ fi
+ return 1
+}
+
+# Function to suggest documentation updates
+suggest_update() {
+ local file="$1"
+ local reason="$2"
+
+ echo -e "${YELLOW}💡 Consider updating: ${BLUE}$file${NC}"
+ echo -e " Reason: $reason\n"
+}
+
+# Check for API changes
+echo -e "${BLUE}📡 Checking API changes...${NC}"
+if check_changes "(router|endpoint|api|dto)" "API-related files changed"; then
+ suggest_update "OpenAPI/Swagger documentation" "API endpoints or DTOs modified"
+ suggest_update "README.md API section" "API usage patterns might have changed"
+fi
+
+# Check for architectural changes
+echo -e "${BLUE}🏗️ Checking architectural changes...${NC}"
+if check_changes "(src/domain|src/application|src/infrastructure)" "Core architecture files changed"; then
+ suggest_update ".cursorrules" "New architectural patterns might have emerged"
+ suggest_update "Architecture diagrams" "System structure might have changed"
+fi
+
+# Check for new features
+echo -e "${BLUE}🆕 Checking new features...${NC}"
+if check_changes "src/features/[^/]+/[^/]+\.(ts|tsx|py)$" "New feature files added"; then
+ suggest_update "Feature documentation" "New features should be documented"
+ suggest_update "README.md features section" "New capabilities added to system"
+fi
+
+# Check for dependency changes
+echo -e "${BLUE}📦 Checking dependency changes...${NC}"
+if check_changes "(package\.json|pyproject\.toml|requirements\.txt)" "Dependencies changed"; then
+ suggest_update "README.md setup section" "Installation/setup instructions might need updates"
+ suggest_update "Development environment docs" "New tools or versions might affect setup"
+fi
+
+# Check for test changes that might indicate new patterns
+echo -e "${BLUE}🧪 Checking test patterns...${NC}"
+if check_changes "__tests__|test_.*\.py" "Test files changed"; then
+ # Count new test files
+ new_tests=$(git diff --cached --name-only | grep -E "__tests__|test_.*\.py" | wc -l)
+ if [ "$new_tests" -gt 5 ]; then
+ suggest_update ".cursorrules testing section" "Significant test changes might indicate new patterns"
+ fi
+fi
+
+# Check for configuration changes
+echo -e "${BLUE}⚙️ Checking configuration changes...${NC}"
+if check_changes "(config|\.env|docker|nginx|Dockerfile)" "Configuration files changed"; then
+ suggest_update "Deployment documentation" "Infrastructure or config changes detected"
+ suggest_update "README.md setup section" "Environment configuration might have changed"
+fi
+
+# Check for new patterns in recent commits (if not in pre-commit)
+if [ "$1" != "--pre-commit" ]; then
+ echo -e "${BLUE}🔄 Checking recent patterns...${NC}"
+
+ # Look for repeated terms in recent commit messages that might indicate new patterns
+ repeated_terms=$(git log --oneline --since="2 weeks ago" | \
+ grep -oE '\b[A-Z][a-z]+\b' | \
+ sort | uniq -c | sort -nr | head -5)
+
+ if [ -n "$repeated_terms" ]; then
+ echo -e "${YELLOW}📊 Frequently mentioned terms in recent commits:${NC}"
+ echo "$repeated_terms" | while read count term; do
+ if [ "$count" -gt 3 ]; then
+ echo " - $term (mentioned $count times)"
+ fi
+ done
+ echo -e "\n${YELLOW}💡 Consider if these patterns need documentation${NC}\n"
+ fi
+fi
+
+# Check for README staleness
+echo -e "${BLUE}📚 Checking README freshness...${NC}"
+if [ -f "README.md" ]; then
+ readme_age=$(git log -1 --format="%ct" README.md 2>/dev/null || echo "0")
+ current_time=$(date +%s)
+ age_days=$(( (current_time - readme_age) / 86400 ))
+
+ if [ "$age_days" -gt 30 ]; then
+ echo -e "${YELLOW}📅 README.md hasn't been updated in $age_days days${NC}"
+ suggest_update "README.md" "Consider reviewing for accuracy and completeness"
+ fi
+fi
+
+# Summary
+echo -e "${GREEN}✅ Documentation sync check complete!${NC}\n"
+
+# If this is a pre-commit hook, exit with error if critical docs are missing
+if [ "$1" = "--pre-commit" ]; then
+ # Check for critical API changes without doc updates
+ if git diff --cached --name-only | grep -E "(router|endpoint)" > /dev/null && \
+ ! git diff --cached --name-only | grep -E "(README|openapi|swagger|\.md)" > /dev/null; then
+ echo -e "${RED}❌ BLOCKING: API changes detected but no documentation updates found${NC}"
+ echo -e "${YELLOW}Please update relevant documentation before committing${NC}"
+ exit 1
+ fi
+fi
+
+exit 0
diff --git a/scripts/setup-git-hooks.sh b/scripts/setup-git-hooks.sh
new file mode 100755
index 0000000..dd648e6
--- /dev/null
+++ b/scripts/setup-git-hooks.sh
@@ -0,0 +1,148 @@
+#!/bin/bash
+
+# Git Hooks Setup for Documentation Automation
+# Run this script to set up automated documentation checks
+
+set -e
+
+# Colors
+GREEN='\033[0;32m'
+BLUE='\033[0;34m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+echo -e "${BLUE}🪝 Setting up git hooks for documentation automation...${NC}\n"
+
+# Create .git/hooks directory if it doesn't exist
+mkdir -p .git/hooks
+
+# Pre-commit hook for documentation validation
+cat > .git/hooks/pre-commit << 'EOF'
+#!/bin/bash
+
+# Pre-commit hook: Documentation synchronization check
+# This runs before each commit to ensure docs stay in sync
+
+echo "🔍 Running pre-commit documentation checks..."
+
+# Run the documentation sync checker in pre-commit mode
+./scripts/check-docs-sync.sh --pre-commit
+
+# Check for common documentation issues
+echo "📝 Checking for documentation quality..."
+
+# Ensure all Python functions have docstrings (in modified files)
+python_files_changed=$(git diff --cached --name-only | grep '\.py$' || true)
+if [ -n "$python_files_changed" ]; then
+ for file in $python_files_changed; do
+ if git diff --cached "$file" | grep -E "^+\s*def " > /dev/null; then
+ # New function added, check if it has docstring
+ if ! git show ":$file" | grep -A 3 "def " | grep -E '"""|\'\'\'' > /dev/null; then
+ echo "⚠️ Warning: New function in $file might be missing docstring"
+ fi
+ fi
+ done
+fi
+
+# Check for TODO comments that mention documentation
+todos=$(git diff --cached | grep -E "^\+.*TODO.*doc" || true)
+if [ -n "$todos" ]; then
+ echo "📋 Found TODO items related to documentation:"
+ echo "$todos"
+ echo "💡 Consider addressing these before committing"
+fi
+
+echo "✅ Pre-commit documentation checks complete"
+EOF
+
+# Post-commit hook for documentation reminders
+cat > .git/hooks/post-commit << 'EOF'
+#!/bin/bash
+
+# Post-commit hook: Documentation maintenance reminders
+# This runs after each commit to provide helpful reminders
+
+# Get the commit message
+commit_msg=$(git log -1 --pretty=%B)
+
+# Check if this was a significant change that might need doc updates
+if echo "$commit_msg" | grep -iE "(feat|feature|breaking|major)" > /dev/null; then
+ echo ""
+ echo "🎉 Significant change committed! Consider updating:"
+ echo " 📖 README.md - if user-facing changes"
+ echo " 🏗️ Architecture docs - if structural changes"
+ echo " 📚 .cursorrules - if new patterns emerged"
+ echo ""
+fi
+
+# Check if we're approaching documentation review time
+last_cursorrules_update=$(git log -1 --format="%ct" .cursorrules 2>/dev/null || echo "0")
+current_time=$(date +%s)
+days_since_update=$(( (current_time - last_cursorrules_update) / 86400 ))
+
+if [ "$days_since_update" -gt 60 ]; then
+ echo "📅 Reminder: .cursorrules last updated $days_since_update days ago"
+ echo " Consider reviewing for new patterns or team changes"
+ echo ""
+fi
+EOF
+
+# Prepare-commit-msg hook for conventional commits
+cat > .git/hooks/prepare-commit-msg << 'EOF'
+#!/bin/bash
+
+# Prepare-commit-msg hook: Help with conventional commits and doc reminders
+
+commit_file="$1"
+commit_source="$2"
+
+# Only run for regular commits (not merges, etc.)
+if [ "$commit_source" = "" ]; then
+ # Check if any documentation files are being committed
+ doc_files=$(git diff --cached --name-only | grep -E '\.(md|rst)$' || true)
+
+ if [ -n "$doc_files" ]; then
+ # Add a reminder comment to the commit message
+ echo "" >> "$commit_file"
+ echo "# Documentation files updated:" >> "$commit_file"
+ echo "$doc_files" | sed 's/^/# - /' >> "$commit_file"
+ echo "#" >> "$commit_file"
+ echo "# Consider: docs(scope): brief description of what was documented" >> "$commit_file"
+ fi
+
+ # Check for API changes and suggest appropriate commit message
+ api_files=$(git diff --cached --name-only | grep -E "(router|endpoint|api)" || true)
+ if [ -n "$api_files" ]; then
+ echo "#" >> "$commit_file"
+ echo "# API files changed - consider conventional commit types:" >> "$commit_file"
+ echo "# feat(api): for new endpoints" >> "$commit_file"
+ echo "# fix(api): for bug fixes" >> "$commit_file"
+ echo "# refactor(api): for restructuring" >> "$commit_file"
+ echo "# BREAKING CHANGE: for breaking changes" >> "$commit_file"
+ fi
+fi
+EOF
+
+# Make all hooks executable
+chmod +x .git/hooks/pre-commit
+chmod +x .git/hooks/post-commit
+chmod +x .git/hooks/prepare-commit-msg
+
+echo -e "${GREEN}✅ Git hooks installed successfully!${NC}\n"
+
+echo -e "${BLUE}📋 Installed hooks:${NC}"
+echo -e " 🔍 ${YELLOW}pre-commit${NC}: Validates documentation sync before commits"
+echo -e " 🎉 ${YELLOW}post-commit${NC}: Provides reminders after significant changes"
+echo -e " 💬 ${YELLOW}prepare-commit-msg${NC}: Helps with conventional commit messages"
+
+echo -e "\n${BLUE}🔧 Manual usage:${NC}"
+echo -e " ${YELLOW}./scripts/check-docs-sync.sh${NC} - Run documentation sync check anytime"
+echo -e " ${YELLOW}git commit${NC} - Hooks will run automatically"
+
+echo -e "\n${GREEN}🎯 Benefits:${NC}"
+echo -e " • Automatic detection of doc/code drift"
+echo -e " • Reminders for documentation updates"
+echo -e " • Better commit message consistency"
+echo -e " • Proactive documentation maintenance"
+
+echo -e "\n${BLUE}💡 Pro tip:${NC} Run ${YELLOW}./scripts/check-docs-sync.sh${NC} periodically to catch documentation drift early!"