diff --git a/.env.example b/.env.example index bf446071..292d7942 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,5 @@ SENTRY_DSN= NEXT_PUBLIC_KNOCK_PUBLIC_API_KEY= NEXT_PUBLIC_KNOCK_FEED_CHANNEL_ID= + +NEXT_PUBLIC_RESUME_EDITOR_TABS_NEW_DESIGN_ENABLED=true diff --git a/.gitignore b/.gitignore index b85037bb..6f9d4ddf 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,9 @@ next-env.d.ts # IDE /.idea /.zed +/.vscode +/.cursor +/.kiro # Sentry Config File .env.sentry-build-plugin diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..6f33900b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contributing to Letraz + +## Current Status + +**We are not currently accepting external contributions to this project.** + +Letraz is currently being developed by our internal team, and we are not seeking outside contributors at this time. + +## Joining Our Core Team + +If you're interested in joining our core development team as a contributor, we'd love to hear from you! We're always looking for talented developers who are passionate about building innovative products that reshape how seekers apply for jobs. + +**Contact us at:** [hello@letraz.app](mailto:hello@letraz.app) + +Please include: +- A brief introduction about yourself +- Your relevant experience and skills +- Why you're interested in joining Letraz +- Any relevant portfolio or GitHub links + +## Future Plans + +We may open up the project to external contributions in the future. When that happens, we'll update this document with our contribution guidelines and processes. + +## Thank You + +We appreciate your interest in contributing to Letraz! While we can't accept external contributions right now, we're grateful for the community's enthusiasm and support. + +--- + +*Last updated: August 2025* diff --git a/LICENSE b/LICENSE index a45bc089..58261397 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Subhajit Kundu +Copyright (c) 2025 Quelac Studios Pvt. Ltd. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +SOFTWARE. \ No newline at end of file diff --git a/__tests__/helpers/api-mocks.ts b/__tests__/helpers/api-mocks.ts new file mode 100644 index 00000000..296bad8d --- /dev/null +++ b/__tests__/helpers/api-mocks.ts @@ -0,0 +1,286 @@ +import {expect, type MockedFunction, vi} from 'vitest' +import {createMockApiError, MockApiError} from './mock-factories' + +// API mocking utilities for testing HTTP requests + +export interface MockRequestConfig { + method?: string + url?: string | RegExp + status?: number + delay?: number + headers?: Record +} + +export interface MockResponseConfig { + data?: T + status?: number + statusText?: string + headers?: Record + delay?: number +} + +// Global fetch mock manager +class FetchMockManager { + private mocks: Map = new Map() + + private defaultMock: any = null + + // Set up a mock for a specific URL pattern + mockRequest( + pattern: string | RegExp, + response: MockResponseConfig | ((url: string, init?: RequestInit) => MockResponseConfig) + ): void { + const key = pattern instanceof RegExp ? pattern.source : pattern + this.mocks.set(key, {pattern, response}) + } + + // Set up a default mock for all unmatched requests + mockDefault(response: MockResponseConfig): void { + this.defaultMock = response + } + + // Clear all mocks + clearMocks(): void { + this.mocks.clear() + this.defaultMock = null + } + + // Get mock response for a URL + getMockResponse(url: string, init?: RequestInit): MockResponseConfig | null { + // Check specific mocks first + for (const [key, mock] of this.mocks.entries()) { + const {pattern, response} = mock + let matches = false + + if (pattern instanceof RegExp) { + matches = pattern.test(url) + } else { + matches = url.includes(pattern) + } + + if (matches) { + return typeof response === 'function' ? response(url, init) : response + } + } + + // Return default mock if no specific match + return this.defaultMock + } +} + +// Global instance +const fetchMockManager = new FetchMockManager() + +// Set up the global fetch mock +const setupFetchMock = (): void => { + global.fetch = vi.fn().mockImplementation(async (url: string, init?: RequestInit) => { + const mockConfig = fetchMockManager.getMockResponse(url, init) + + if (!mockConfig) { + throw new Error(`No mock configured for URL: ${url}`) + } + + // Simulate network delay if specified + if (mockConfig.delay) { + await new Promise(resolve => setTimeout(resolve, mockConfig.delay)) + } + + const { + data = null, + status = 200, + statusText = 'OK', + headers = {} + } = mockConfig + + const response = { + ok: status >= 200 && status < 300, + status, + statusText, + headers: new Headers(headers), + url, + redirected: false, + type: 'basic' as ResponseType, + body: null, + bodyUsed: false, + clone: vi.fn(), + json: vi.fn().mockResolvedValue(data), + text: vi.fn().mockResolvedValue(JSON.stringify(data)), + arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0)), + blob: vi.fn().mockResolvedValue(new Blob()), + formData: vi.fn().mockResolvedValue(new FormData()), + bytes: vi.fn().mockResolvedValue(new Uint8Array()) + } + + return response as Response + }) +} + +// API mock utilities +export const apiMocks = { + // Set up fetch mock + setup: setupFetchMock, + + // Mock a successful API response + mockSuccess: ( + url: string | RegExp, + data: T, + options: Omit, 'data'> = {} + ): void => { + fetchMockManager.mockRequest(url, { + data, + status: 200, + statusText: 'OK', + ...options + }) + }, + + // Mock an API error response + mockError: ( + url: string | RegExp, + error: Partial = {}, + options: Omit = {} + ): void => { + const errorResponse = createMockApiError(error) + fetchMockManager.mockRequest(url, { + data: errorResponse, + status: error.status || 500, + statusText: error.statusText || 'Internal Server Error', + ...options + }) + }, + + // Mock network failure + mockNetworkError: (url: string | RegExp, message = 'Network Error'): void => { + fetchMockManager.mockRequest(url, () => { + throw new Error(message) + }) + }, + + // Mock timeout + mockTimeout: (url: string | RegExp, delay = 5000): void => { + fetchMockManager.mockRequest(url, { + data: null, + delay, + status: 408, + statusText: 'Request Timeout' + }) + }, + + // Mock different HTTP methods + mockGet: (url: string | RegExp, data: T, options?: MockResponseConfig): void => { + apiMocks.mockSuccess(url, data, options) + }, + + mockPost: (url: string | RegExp, data: T, options?: MockResponseConfig): void => { + apiMocks.mockSuccess(url, data, {status: 201, statusText: 'Created', ...options}) + }, + + mockPut: (url: string | RegExp, data: T, options?: MockResponseConfig): void => { + apiMocks.mockSuccess(url, data, options) + }, + + mockDelete: (url: string | RegExp, options?: MockResponseConfig): void => { + apiMocks.mockSuccess(url, null, {status: 204, statusText: 'No Content', ...options}) + }, + + // Mock paginated responses + mockPaginated: ( + url: string | RegExp, + items: T[], + page = 1, + limit = 10, + total?: number + ): void => { + const totalItems = total || items.length + const totalPages = Math.ceil(totalItems / limit) + const startIndex = (page - 1) * limit + const endIndex = startIndex + limit + const paginatedItems = items.slice(startIndex, endIndex) + + const paginatedResponse = { + data: paginatedItems, + pagination: { + page, + limit, + total: totalItems, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1 + } + } + + apiMocks.mockSuccess(url, paginatedResponse) + }, + + // Clear all mocks + clearAll: (): void => { + fetchMockManager.clearMocks() + vi.clearAllMocks() + }, + + // Reset fetch mock + reset: (): void => { + apiMocks.clearAll() + setupFetchMock() + }, + + // Verify fetch was called + verifyFetchCalled: (url?: string | RegExp, times?: number): void => { + const fetchMock = global.fetch as MockedFunction + + if (url) { + const calls = fetchMock.mock.calls.filter(call => { + const callUrl = call[0] as string + if (url instanceof RegExp) { + return url.test(callUrl) + } + return callUrl.includes(url) + }) + + if (times !== undefined) { + expect(calls).toHaveLength(times) + } else { + expect(calls.length).toBeGreaterThan(0) + } + } else { + if (times !== undefined) { + expect(fetchMock).toHaveBeenCalledTimes(times) + } else { + expect(fetchMock).toHaveBeenCalled() + } + } + }, + + // Get fetch call arguments + getFetchCalls: (): Array<[string, RequestInit?]> => { + const fetchMock = global.fetch as MockedFunction + return fetchMock.mock.calls as Array<[string, RequestInit?]> + }, + + // Mock specific endpoints commonly used in the app + mockAuth: { + login: (user: any) => apiMocks.mockPost('/api/auth/login', {user, token: 'mock-token'}), + logout: () => apiMocks.mockPost('/api/auth/logout', {success: true}), + refresh: (token: string) => apiMocks.mockPost('/api/auth/refresh', {token}), + me: (user: any) => apiMocks.mockGet('/api/auth/me', {user}) + }, + + mockResume: { + list: (resumes: any[]) => apiMocks.mockGet('/api/resumes', resumes), + get: (resume: any) => apiMocks.mockGet(/\/api\/resumes\/\w+/, resume), + create: (resume: any) => apiMocks.mockPost('/api/resumes', resume), + update: (resume: any) => apiMocks.mockPut(/\/api\/resumes\/\w+/, resume), + delete: () => apiMocks.mockDelete(/\/api\/resumes\/\w+/) + }, + + mockJob: { + list: (jobs: any[]) => apiMocks.mockGet('/api/jobs', jobs), + get: (job: any) => apiMocks.mockGet(/\/api\/jobs\/\w+/, job), + search: (jobs: any[]) => apiMocks.mockGet('/api/jobs/search', jobs) + } +} + +// Initialize fetch mock +apiMocks.setup() + +export {fetchMockManager} diff --git a/__tests__/helpers/index.ts b/__tests__/helpers/index.ts new file mode 100644 index 00000000..1eff2f5f --- /dev/null +++ b/__tests__/helpers/index.ts @@ -0,0 +1,61 @@ +// Main export file for all test utilities and helpers +// This allows for clean imports like: import { render, createMockUser, apiMocks } from '__tests__/helpers' + +// Re-export all utilities from test-utils +export * from './test-utils' + +// Re-export specific utilities for convenience +export { + // Render utilities + render, + renderWithProviders, + renderWithoutProviders, + renderWithQueryClient, + createTestQueryClient, + + // Testing Library utilities + screen, + waitFor, + within, + userEvent +} from './test-utils' + +// Re-export mock factories +export { + createMockUser, + createMockResume, + createMockPersonalInfo, + createMockExperience, + createMockEducation, + createMockJob, + createMockParsedJob, + createMockApiResponse, + createMockApiError, + createMockFormData, + createMockFile, + createMockEvent, + createMockMouseEvent, + createMockKeyboardEvent, + createMockFunction, + createMockPromise, + createMockDate, + mockDateNow, + createMockLocalStorageData, + createMockSessionStorageData +} from './mock-factories' + +// Re-export API mocks +export { apiMocks } from './api-mocks' + +// Re-export test helpers +export { + userInteraction, + assertions, + asyncHelpers, + formHelpers, + componentHelpers, + mockHelpers, + testDataHelpers, + cleanupHelpers, + testHelpers +} from './test-helpers' \ No newline at end of file diff --git a/__tests__/helpers/mock-factories.ts b/__tests__/helpers/mock-factories.ts new file mode 100644 index 00000000..62c584f8 --- /dev/null +++ b/__tests__/helpers/mock-factories.ts @@ -0,0 +1,311 @@ +import {type MockedFunction, vi} from 'vitest' +import {Job} from '@/lib/job/types' + +// Mock data factories for creating test data + +// User-related mock data +export const createMockUser = (overrides: Partial = {}): MockUser => ({ + id: 'user_123', + email: 'test@example.com', + name: 'Test User', + firstName: 'Test', + lastName: 'User', + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + ...overrides +}) + +export interface MockUser { + id: string + email: string + name: string + firstName: string + lastName: string + createdAt: Date + updatedAt: Date +} + +// Resume-related mock data +export const createMockResume = (overrides: Partial = {}): MockResume => ({ + id: 'resume_123', + userId: 'user_123', + title: 'Software Engineer Resume', + personalInfo: createMockPersonalInfo(), + experiences: [createMockExperience()], + education: [createMockEducation()], + skills: ['JavaScript', 'TypeScript', 'React'], + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + ...overrides +}) + +export interface MockResume { + id: string + userId: string + title: string + personalInfo: MockPersonalInfo + experiences: MockExperience[] + education: MockEducation[] + skills: string[] + createdAt: Date + updatedAt: Date +} + +export const createMockPersonalInfo = (overrides: Partial = {}): MockPersonalInfo => ({ + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + phone: '+1-555-0123', + location: 'San Francisco, CA', + website: 'https://johndoe.dev', + linkedin: 'https://linkedin.com/in/johndoe', + github: 'https://github.com/johndoe', + summary: 'Experienced software engineer with expertise in full-stack development.', + ...overrides +}) + +export interface MockPersonalInfo { + firstName: string + lastName: string + email: string + phone: string + location: string + website?: string + linkedin?: string + github?: string + summary?: string +} + +export const createMockExperience = (overrides: Partial = {}): MockExperience => ({ + id: 'exp_123', + company: 'Tech Corp', + position: 'Senior Software Engineer', + location: 'San Francisco, CA', + startDate: new Date('2022-01-01'), + endDate: new Date('2024-01-01'), + current: false, + description: 'Led development of web applications using React and Node.js.', + achievements: [ + 'Improved application performance by 40%', + 'Led a team of 5 developers', + 'Implemented CI/CD pipeline' + ], + ...overrides +}) + +export interface MockExperience { + id: string + company: string + position: string + location: string + startDate: Date + endDate: Date | null + current: boolean + description: string + achievements: string[] +} + +export const createMockEducation = (overrides: Partial = {}): MockEducation => ({ + id: 'edu_123', + institution: 'University of Technology', + degree: 'Bachelor of Science', + field: 'Computer Science', + location: 'Boston, MA', + startDate: new Date('2018-09-01'), + endDate: new Date('2022-05-01'), + gpa: '3.8', + achievements: ['Magna Cum Laude', 'Dean\'s List'], + ...overrides +}) + +export interface MockEducation { + id: string + institution: string + degree: string + field: string + location: string + startDate: Date + endDate: Date | null + gpa?: string + achievements: string[] +} + +// Job-related mock data +export const createMockJob = (overrides?: Partial): Job => ({ + title: 'Software Engineer', + company_name: 'Test Company', + description: 'Test job description', + requirements: ['React', 'TypeScript'], + location: 'Remote', + job_url: 'https://example.com/job', + currency: 'USD', + salary_min: 80000, + salary_max: 120000, + responsibilities: ['Build web applications'], + benefits: ['Health insurance', 'Remote work'], + ...overrides +}) + +// Helper function to create a mock parsed job (the format returned by parseJobFromRawJD) +export const createMockParsedJob = (overrides?: Partial): ParsedJob => ({ + title: 'Software Engineer', + companyName: 'Test Company', + description: 'Test job description', + requirements: ['React', 'TypeScript'], + location: 'Remote', + salaryMin: '$80,000', + salaryMax: '$120,000', + responsibilities: ['Build web applications'], + benefits: ['Health insurance', 'Remote work'], + ...overrides +}) + +export type ParsedJob = { + title: string + companyName: string + location: string + salaryMax: string + salaryMin: string + requirements: string[] + description: string + responsibilities: string[] + benefits: string[] +} + +// API Response mock data +export const createMockApiResponse = ( + data: T, + overrides: Partial> = {} +): MockApiResponse => ({ + data, + status: 200, + statusText: 'OK', + headers: {}, + success: true, + message: 'Success', + ...overrides + }) + +export interface MockApiResponse { + data: T + status: number + statusText: string + headers: Record + success: boolean + message: string +} + +// Error response mock data +export const createMockApiError = (overrides: Partial = {}): MockApiError => ({ + status: 500, + statusText: 'Internal Server Error', + message: 'An error occurred', + code: 'INTERNAL_ERROR', + details: {}, + ...overrides +}) + +export interface MockApiError { + status: number + statusText: string + message: string + code: string + details: Record +} + +// Form data mock factories +export const createMockFormData = (data: Record): FormData => { + const formData = new FormData() + Object.entries(data).forEach(([key, value]) => { + if (value !== null && value !== undefined) { + formData.append(key, String(value)) + } + }) + return formData +} + +// File mock factory +export const createMockFile = ( + name = 'test-file.txt', + content = 'test content', + type = 'text/plain' +): File => { + const blob = new Blob([content], {type}) + return new File([blob], name, {type}) +} + +// Event mock factories +export const createMockEvent = ( + type: string, + overrides: Partial = {} +): T => { + const event = new Event(type) as T + Object.assign(event, overrides) + return event +} + +export const createMockMouseEvent = ( + type: string = 'click', + overrides: Partial = {} +): MouseEvent => { + return createMockEvent(type, { + bubbles: true, + cancelable: true, + clientX: 0, + clientY: 0, + ...overrides + }) +} + +export const createMockKeyboardEvent = ( + type: string = 'keydown', + key: string = 'Enter', + overrides: Partial = {} +): KeyboardEvent => { + return createMockEvent(type, { + bubbles: true, + cancelable: true, + key, + code: `Key${key.toUpperCase()}`, + ...overrides + }) +} + +// Mock function factories +export const createMockFunction = any>(): MockedFunction => { + return vi.fn() as MockedFunction +} + +export const createMockPromise = ( + resolveValue?: T, + rejectValue?: any +): Promise => { + if (rejectValue) { + return Promise.reject(rejectValue) + } + return Promise.resolve(resolveValue as T) +} + +// Date mock utilities +export const createMockDate = (dateString: string = '2024-01-01'): Date => { + return new Date(dateString) +} + +export const mockDateNow = (dateString: string = '2024-01-01'): void => { + const mockDate = new Date(dateString) + vi.spyOn(Date, 'now').mockReturnValue(mockDate.getTime()) +} + +// Local storage mock data +export const createMockLocalStorageData = (data: Record): void => { + Object.entries(data).forEach(([key, value]) => { + window.localStorage.setItem(key, JSON.stringify(value)) + }) +} + +// Session storage mock data +export const createMockSessionStorageData = (data: Record): void => { + Object.entries(data).forEach(([key, value]) => { + window.sessionStorage.setItem(key, JSON.stringify(value)) + }) +} diff --git a/__tests__/helpers/test-helpers.ts b/__tests__/helpers/test-helpers.ts new file mode 100644 index 00000000..d9f05625 --- /dev/null +++ b/__tests__/helpers/test-helpers.ts @@ -0,0 +1,475 @@ +import {type MockedFunction, vi} from 'vitest' +import {screen, waitFor} from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +// Common test helpers for reusable testing patterns + +// User interaction helpers +export const userInteraction = { + // Click helpers + async clickButton(name: string | RegExp): Promise { + const button = screen.getByRole('button', {name}) + await userEvent.click(button) + }, + + async clickLink(name: string | RegExp): Promise { + const link = screen.getByRole('link', {name}) + await userEvent.click(link) + }, + + // Form interaction helpers + async fillInput(labelText: string | RegExp, value: string): Promise { + const input = screen.getByLabelText(labelText) + await userEvent.clear(input) + await userEvent.type(input, value) + }, + + async fillTextarea(labelText: string | RegExp, value: string): Promise { + const textarea = screen.getByLabelText(labelText) + await userEvent.clear(textarea) + await userEvent.type(textarea, value) + }, + + async selectOption(labelText: string | RegExp, optionText: string): Promise { + const select = screen.getByLabelText(labelText) + await userEvent.selectOptions(select, optionText) + }, + + async checkCheckbox(labelText: string | RegExp): Promise { + const checkbox = screen.getByLabelText(labelText) + await userEvent.click(checkbox) + }, + + async uploadFile(labelText: string | RegExp, file: File): Promise { + const input = screen.getByLabelText(labelText) as HTMLInputElement + await userEvent.upload(input, file) + }, + + // Keyboard interaction helpers + async pressKey(key: string): Promise { + await userEvent.keyboard(`{${key}}`) + }, + + async pressEnter(): Promise { + await userEvent.keyboard('{Enter}') + }, + + async pressEscape(): Promise { + await userEvent.keyboard('{Escape}') + }, + + async pressTab(): Promise { + await userEvent.keyboard('{Tab}') + }, + + // Hover helpers + async hoverElement(element: HTMLElement): Promise { + await userEvent.hover(element) + }, + + async unhoverElement(element: HTMLElement): Promise { + await userEvent.unhover(element) + } +} + +// Assertion helpers +export const assertions = { + // Element visibility assertions + expectElementToBeVisible(element: HTMLElement): void { + expect(element).toBeInTheDocument() + expect(element).toBeVisible() + }, + + expectElementToBeHidden(element: HTMLElement): void { + expect(element).toBeInTheDocument() + expect(element).not.toBeVisible() + }, + + // Form assertions + expectInputToHaveValue(labelText: string | RegExp, value: string): void { + const input = screen.getByLabelText(labelText) + expect(input).toHaveValue(value) + }, + + expectCheckboxToBeChecked(labelText: string | RegExp): void { + const checkbox = screen.getByLabelText(labelText) + expect(checkbox).toBeChecked() + }, + + expectCheckboxToBeUnchecked(labelText: string | RegExp): void { + const checkbox = screen.getByLabelText(labelText) + expect(checkbox).not.toBeChecked() + }, + + // Button state assertions + expectButtonToBeEnabled(name: string | RegExp): void { + const button = screen.getByRole('button', {name}) + expect(button).toBeEnabled() + }, + + expectButtonToBeDisabled(name: string | RegExp): void { + const button = screen.getByRole('button', {name}) + expect(button).toBeDisabled() + }, + + // Loading state assertions + expectLoadingToBeVisible(): void { + expect(screen.getByText(/loading/i)).toBeInTheDocument() + }, + + expectLoadingToBeHidden(): void { + expect(screen.queryByText(/loading/i)).not.toBeInTheDocument() + }, + + // Error message assertions + expectErrorMessage(message: string | RegExp): void { + expect(screen.getByText(message)).toBeInTheDocument() + }, + + expectNoErrorMessage(): void { + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }, + + // Success message assertions + expectSuccessMessage(message: string | RegExp): void { + expect(screen.getByText(message)).toBeInTheDocument() + } +} + +// Async testing helpers +export const asyncHelpers = { + // Wait for element to appear + async waitForElementToAppear(text: string | RegExp): Promise { + return await waitFor(() => screen.getByText(text)) + }, + + // Wait for element to disappear + async waitForElementToDisappear(text: string | RegExp): Promise { + await waitFor(() => { + expect(screen.queryByText(text)).not.toBeInTheDocument() + }) + }, + + // Wait for loading to finish + async waitForLoadingToFinish(): Promise { + await waitFor(() => { + expect(screen.queryByText(/loading/i)).not.toBeInTheDocument() + }) + }, + + // Wait for API call to complete + async waitForApiCall(mockFn: MockedFunction, times = 1): Promise { + await waitFor(() => { + expect(mockFn).toHaveBeenCalledTimes(times) + }) + }, + + // Wait with custom timeout + async waitForCondition( + condition: () => void | Promise, + timeout = 5000 + ): Promise { + await waitFor(condition, {timeout}) + } +} + +// Form testing helpers +export const formHelpers = { + // Fill out a complete form + async fillForm(formData: Record): Promise { + for (const [field, value] of Object.entries(formData)) { + if (typeof value === 'string') { + await userInteraction.fillInput(new RegExp(field, 'i'), value) + } else if (typeof value === 'boolean' && value) { + await userInteraction.checkCheckbox(new RegExp(field, 'i')) + } + } + }, + + // Submit a form + async submitForm(submitButtonText = /submit/i): Promise { + await userInteraction.clickButton(submitButtonText) + }, + + // Fill and submit form + async fillAndSubmitForm( + formData: Record, + submitButtonText = /submit/i + ): Promise { + await this.fillForm(formData) + await this.submitForm(submitButtonText) + }, + + // Validate form errors + expectFormErrors(errors: string[]): void { + errors.forEach(error => { + expect(screen.getByText(error)).toBeInTheDocument() + }) + }, + + // Validate no form errors + expectNoFormErrors(): void { + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + } +} + +// Component testing helpers +export const componentHelpers = { + // Test component rendering + expectComponentToRender(testId: string): void { + expect(screen.getByTestId(testId)).toBeInTheDocument() + }, + + // Test component props + expectComponentToHaveProps(element: HTMLElement, props: Record): void { + Object.entries(props).forEach(([prop, value]) => { + if (prop === 'className') { + expect(element).toHaveClass(value) + } else if (prop === 'textContent') { + expect(element).toHaveTextContent(value) + } else { + expect(element).toHaveAttribute(prop, String(value)) + } + }) + }, + + // Test component children + expectComponentToHaveChildren(parent: HTMLElement, childCount: number): void { + expect(parent.children).toHaveLength(childCount) + }, + + // Test component accessibility + expectComponentToBeAccessible(element: HTMLElement): void { + // Basic accessibility checks + if (element.tagName === 'BUTTON') { + expect(element).toHaveAttribute('type') + } + if (element.tagName === 'INPUT') { + expect(element).toHaveAttribute('id') + // Should have associated label + const id = element.getAttribute('id') + if (id) { + expect(screen.getByLabelText(new RegExp(id, 'i'))).toBeInTheDocument() + } + } + if (element.tagName === 'IMG') { + expect(element).toHaveAttribute('alt') + } + } +} + +// Mock helpers +export const mockHelpers = { + // Create a mock component + createMockComponent: (name: string, props?: any) => { + return vi.fn().mockImplementation((componentProps) => { + return { + type: 'div', + props: { + 'data-testid': `mock-${name.toLowerCase()}`, + ...props, + ...componentProps + } + } + }) + }, + + // Mock React hooks + mockUseState: (initialValue: T): [T, MockedFunction] => { + const setState = vi.fn() + return [initialValue, setState] + }, + + mockUseEffect: (): MockedFunction => { + return vi.fn() + }, + + // Mock console methods + mockConsole: () => { + const originalConsole = {...console} + const mockLog = vi.spyOn(console, 'log').mockImplementation(() => {}) + const mockWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const mockError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + return { + mockLog, + mockWarn, + mockError, + restore: () => { + Object.assign(console, originalConsole) + } + } + }, + + // Mock timers + mockTimers: () => { + try { + vi.useFakeTimers() + return { + advanceTime: (ms: number) => vi.advanceTimersByTime(ms), + runAllTimers: () => vi.runAllTimers(), + restore: () => vi.useRealTimers() + } + } catch (error) { + // Fallback for older versions of vitest + const originalSetTimeout = global.setTimeout + const originalClearTimeout = global.clearTimeout + const originalSetInterval = global.setInterval + const originalClearInterval = global.clearInterval + + const timers: Array<{ id: number; callback: Function; delay: number; type: 'timeout' | 'interval' }> = [] + let timerId = 0 + let currentTime = 0 + + global.setTimeout = vi.fn((callback: Function, delay: number = 0) => { + const id = ++timerId + timers.push({id, callback, delay: currentTime + delay, type: 'timeout'}) + return id + }) as any + + global.clearTimeout = vi.fn((id: number) => { + const index = timers.findIndex(timer => timer.id === id && timer.type === 'timeout') + if (index !== -1) { + timers.splice(index, 1) + } + }) as any + + global.setInterval = vi.fn((callback: Function, delay: number = 0) => { + const id = ++timerId + timers.push({id, callback, delay: currentTime + delay, type: 'interval'}) + return id + }) as any + + global.clearInterval = vi.fn((id: number) => { + const index = timers.findIndex(timer => timer.id === id && timer.type === 'interval') + if (index !== -1) { + timers.splice(index, 1) + } + }) as any + + return { + advanceTime: (ms: number) => { + currentTime += ms + const readyTimers = timers.filter(timer => timer.delay <= currentTime) + readyTimers.forEach(timer => { + timer.callback() + if (timer.type === 'timeout') { + const index = timers.indexOf(timer) + if (index !== -1) { + timers.splice(index, 1) + } + } else { + // For intervals, reschedule + timer.delay = currentTime + (timer.delay - currentTime) + } + }) + }, + runAllTimers: () => { + while (timers.length > 0) { + const nextTimer = timers.reduce((earliest, timer) => timer.delay < earliest.delay ? timer : earliest) + currentTime = nextTimer.delay + nextTimer.callback() + if (nextTimer.type === 'timeout') { + const index = timers.indexOf(nextTimer) + if (index !== -1) { + timers.splice(index, 1) + } + } + } + }, + restore: () => { + global.setTimeout = originalSetTimeout + global.clearTimeout = originalClearTimeout + global.setInterval = originalSetInterval + global.clearInterval = originalClearInterval + timers.length = 0 + currentTime = 0 + } + } + } + } +} + +// Test data helpers +export const testDataHelpers = { + // Generate random string + randomString: (length = 10): string => { + return Math.random().toString(36).substring(2, length + 2) + }, + + // Generate random email + randomEmail: (): string => { + return `${testDataHelpers.randomString()}@example.com` + }, + + // Generate random number + randomNumber: (min = 0, max = 100): number => { + return Math.floor(Math.random() * (max - min + 1)) + min + }, + + // Generate random date + randomDate: (start = new Date(2020, 0, 1), end = new Date()): Date => { + return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())) + }, + + // Generate random boolean + randomBoolean: (): boolean => { + return Math.random() < 0.5 + } +} + +// Cleanup helpers +export const cleanupHelpers = { + // Clean up all mocks + cleanupMocks: (): void => { + vi.clearAllMocks() + }, + + // Clean up timers + cleanupTimers: (): void => { + try { + vi.useRealTimers() + } catch { + // Ignore if timers are not mocked + } + }, + + // Clean up DOM + cleanupDOM: (): void => { + if (typeof document !== 'undefined' && document.body) { + document.body.innerHTML = '' + } + }, + + // Clean up storage + cleanupStorage: (): void => { + if (typeof window !== 'undefined') { + if (window.localStorage) { + window.localStorage.clear() + } + if (window.sessionStorage) { + window.sessionStorage.clear() + } + } + }, + + // Clean up everything + cleanupAll: (): void => { + cleanupHelpers.cleanupMocks() + cleanupHelpers.cleanupTimers() + cleanupHelpers.cleanupDOM() + cleanupHelpers.cleanupStorage() + } +} + +// Export all helpers as a single object for convenience +export const testHelpers = { + user: userInteraction, + assert: assertions, + async: asyncHelpers, + form: formHelpers, + component: componentHelpers, + mock: mockHelpers, + data: testDataHelpers, + cleanup: cleanupHelpers +} diff --git a/__tests__/helpers/test-utilities.test.tsx b/__tests__/helpers/test-utilities.test.tsx new file mode 100644 index 00000000..a765640c --- /dev/null +++ b/__tests__/helpers/test-utilities.test.tsx @@ -0,0 +1,346 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import React from 'react' +import {Button} from '@/components/ui/button' +import { + apiMocks, + createMockApiResponse, + createMockFile, + createMockFormData, + createMockUser, + render, + renderWithoutProviders, + renderWithProviders, + screen, + testHelpers +} from './index' + +// Test component for testing utilities +const TestComponent: React.FC<{ onClick?: () => void; disabled?: boolean }> = ({ + onClick, + disabled = false +}) => { + const [count, setCount] = React.useState(0) + + return ( +
+

Test Component

+

Count: {count}

+ + +
+ ) +} + +describe('Test Utilities', () => { + beforeEach(() => { + apiMocks.reset() + }) + + afterEach(() => { + testHelpers.cleanup.cleanupAll() + }) + + describe('Render Utilities', () => { + it('should render components with custom render function', () => { + render() + + expect(screen.getByTestId('test-component')).toBeInTheDocument() + expect(screen.getByText('Test Component')).toBeInTheDocument() + expect(screen.getByText('Count: 0')).toBeInTheDocument() + }) + + it('should render components with providers', () => { + renderWithProviders() + + expect(screen.getByTestId('test-component')).toBeInTheDocument() + expect(screen.getByRole('button', {name: 'Increment'})).toBeInTheDocument() + }) + + it('should render components without providers', () => { + renderWithoutProviders() + + expect(screen.getByTestId('test-component')).toBeInTheDocument() + }) + }) + + describe('User Interaction Helpers', () => { + it('should handle button clicks', async () => { + const mockClick = vi.fn() + render() + + await testHelpers.user.clickButton('Increment') + + expect(mockClick).toHaveBeenCalledOnce() + expect(screen.getByText('Count: 1')).toBeInTheDocument() + }) + + it('should handle input interactions', async () => { + render() + + await testHelpers.user.fillInput('Test Input', 'Hello World') + + testHelpers.assert.expectInputToHaveValue('Test Input', 'Hello World') + }) + + it('should handle keyboard interactions', async () => { + render() + + const input = screen.getByLabelText('Test Input') + input.focus() + + await testHelpers.user.pressEnter() + await testHelpers.user.pressTab() + + // Verify keyboard events were handled + expect(document.activeElement).not.toBe(input) + }) + }) + + describe('Assertion Helpers', () => { + it('should provide element visibility assertions', () => { + render() + + const component = screen.getByTestId('test-component') + testHelpers.assert.expectElementToBeVisible(component) + }) + + it('should provide button state assertions', () => { + render() + + testHelpers.assert.expectButtonToBeEnabled('Increment') + }) + + it('should handle disabled button assertions', () => { + render() + + testHelpers.assert.expectButtonToBeDisabled('Increment') + }) + }) + + describe('Mock Factories', () => { + it('should create mock user data', () => { + const mockUser = createMockUser({ + name: 'Custom User', + email: 'custom@example.com' + }) + + expect(mockUser.name).toBe('Custom User') + expect(mockUser.email).toBe('custom@example.com') + expect(mockUser.id).toBe('user_123') + }) + + it('should create mock API responses', () => { + const mockResponse = createMockApiResponse({message: 'Success'}) + + expect(mockResponse.data).toEqual({message: 'Success'}) + expect(mockResponse.status).toBe(200) + expect(mockResponse.success).toBe(true) + }) + + it('should create mock files', () => { + const mockFile = createMockFile('test.txt', 'test content', 'text/plain') + + expect(mockFile.name).toBe('test.txt') + expect(mockFile.type).toMatch(/text\/plain/) + expect(mockFile.size).toBeGreaterThan(0) + }) + + it('should create mock form data', () => { + const formData = createMockFormData({ + name: 'John Doe', + email: 'john@example.com', + age: 30 + }) + + expect(formData.get('name')).toBe('John Doe') + expect(formData.get('email')).toBe('john@example.com') + expect(formData.get('age')).toBe('30') + }) + }) + + describe('API Mocking Utilities', () => { + it('should mock successful API responses', async () => { + const testData = {message: 'Success'} + apiMocks.mockSuccess('/api/test', testData) + + const response = await fetch('/api/test') + const data = await response.json() + + expect(response.ok).toBe(true) + expect(data).toEqual(testData) + }) + + it('should mock API errors', async () => { + apiMocks.mockError('/api/error', {status: 404, message: 'Not Found'}) + + const response = await fetch('/api/error') + const data = await response.json() + + expect(response.status).toBe(404) + expect(data.message).toBe('Not Found') + }) + + it('should verify fetch calls', async () => { + apiMocks.mockSuccess('/api/verify', {success: true}) + + await fetch('/api/verify') + + apiMocks.verifyFetchCalled('/api/verify', 1) + }) + + it('should mock different HTTP methods', async () => { + const postData = {id: 1, name: 'Created'} + apiMocks.mockPost('/api/create', postData) + + const response = await fetch('/api/create', {method: 'POST'}) + const data = await response.json() + + expect(response.status).toBe(201) + expect(data).toEqual(postData) + }) + }) + + describe('Async Helpers', () => { + it('should wait for elements to appear', async () => { + const DelayedComponent = () => { + const [show, setShow] = React.useState(false) + + React.useEffect(() => { + setTimeout(() => setShow(true), 100) + }, []) + + return show ?
Delayed Content
:
Loading...
+ } + + render() + + expect(screen.getByText('Loading...')).toBeInTheDocument() + + await testHelpers.async.waitForElementToAppear('Delayed Content') + + expect(screen.getByText('Delayed Content')).toBeInTheDocument() + }) + + it('should wait for loading to finish', async () => { + const LoadingComponent = () => { + const [loading, setLoading] = React.useState(true) + + React.useEffect(() => { + setTimeout(() => setLoading(false), 100) + }, []) + + return loading ?
Loading...
:
Content Loaded
+ } + + render() + + await testHelpers.async.waitForLoadingToFinish() + + expect(screen.getByText('Content Loaded')).toBeInTheDocument() + }) + }) + + describe('Component Helpers', () => { + it('should test component rendering', () => { + render() + + testHelpers.component.expectComponentToRender('test-component') + }) + + it('should test component props', () => { + render() + + const button = screen.getByRole('button', {name: 'Increment'}) + testHelpers.component.expectComponentToHaveProps(button, { + type: 'button' + }) + }) + }) + + describe('Test Data Helpers', () => { + it('should generate random data', () => { + const randomString = testHelpers.data.randomString(5) + const randomEmail = testHelpers.data.randomEmail() + const randomNumber = testHelpers.data.randomNumber(1, 10) + const randomBoolean = testHelpers.data.randomBoolean() + + expect(randomString).toHaveLength(5) + expect(randomEmail).toMatch(/@example\.com$/) + expect(randomNumber).toBeGreaterThanOrEqual(1) + expect(randomNumber).toBeLessThanOrEqual(10) + expect(typeof randomBoolean).toBe('boolean') + }) + }) + + describe('Mock Helpers', () => { + it('should mock console methods', () => { + const consoleMock = testHelpers.mock.mockConsole() + + console.log('test message') + console.warn('test warning') + console.error('test error') + + expect(consoleMock.mockLog).toHaveBeenCalledWith('test message') + expect(consoleMock.mockWarn).toHaveBeenCalledWith('test warning') + expect(consoleMock.mockError).toHaveBeenCalledWith('test error') + + consoleMock.restore() + }) + + it('should mock timers', () => { + const timerMock = testHelpers.mock.mockTimers() + const callback = vi.fn() + + setTimeout(callback, 1000) + + expect(callback).not.toHaveBeenCalled() + + timerMock.advanceTime(1000) + + expect(callback).toHaveBeenCalledOnce() + + timerMock.restore() + }) + }) + + describe('Form Helpers', () => { + it('should fill and submit forms', async () => { + const FormComponent = () => { + const [submitted, setSubmitted] = React.useState(false) + + return ( +
{ + e.preventDefault() + setSubmitted(true) + }}> + + + + {submitted &&
Form Submitted
} +
+ ) + } + + render() + + await testHelpers.form.fillAndSubmitForm({ + Name: 'John Doe', + Email: 'john@example.com' + }, /Submit Form/) + + expect(screen.getByText('Form Submitted')).toBeInTheDocument() + }) + }) +}) diff --git a/__tests__/helpers/test-utils.tsx b/__tests__/helpers/test-utils.tsx new file mode 100644 index 00000000..c59a7d14 --- /dev/null +++ b/__tests__/helpers/test-utils.tsx @@ -0,0 +1,157 @@ +import React from 'react' +import {render, RenderOptions, RenderResult} from '@testing-library/react' +import {QueryClient, QueryClientProvider} from '@tanstack/react-query' +import {TooltipProvider} from '@/components/ui/tooltip' +import {SidebarProvider} from '@/components/providers/SidebarProvider' + +// Create a custom render function that includes common providers +interface CustomRenderOptions extends Omit { + // Options for customizing the test environment + withQueryClient?: boolean + withTooltipProvider?: boolean + withSidebarProvider?: boolean + queryClientOptions?: { + defaultOptions?: any + } + initialSidebarState?: { + isExpanded?: boolean + currentPage?: 'NOTIFICATION' | 'SETTINGS' | null + } +} + +// Create a test query client with sensible defaults for testing +const createTestQueryClient = (options?: any) => { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + staleTime: Infinity, + cacheTime: Infinity + }, + mutations: { + retry: false + }, + ...options + } + }) +} + +// All providers wrapper for comprehensive testing +const AllTheProviders: React.FC<{ + children: React.ReactNode + queryClient?: QueryClient + withTooltipProvider?: boolean + withSidebarProvider?: boolean +}> = ({ + children, + queryClient, + withTooltipProvider = true, + withSidebarProvider = true +}) => { + const testQueryClient = queryClient || createTestQueryClient() + + let wrappedChildren = ( + + {children} + + ) + + if (withTooltipProvider) { + wrappedChildren = ( + + {wrappedChildren} + + ) + } + + if (withSidebarProvider) { + wrappedChildren = ( + + {wrappedChildren} + + ) + } + + return wrappedChildren +} + +// Custom render function +const customRender = ( + ui: React.ReactElement, + options: CustomRenderOptions = {} +): RenderResult => { + const { + withQueryClient = true, + withTooltipProvider = true, + withSidebarProvider = true, + queryClientOptions, + ...renderOptions + } = options + + const queryClient = withQueryClient + ? createTestQueryClient(queryClientOptions?.defaultOptions) + : undefined + + const Wrapper: React.FC<{ children: React.ReactNode }> = ({children}) => ( + + {children} + + ) + + return render(ui, {wrapper: Wrapper, ...renderOptions}) +} + +// Render with only specific providers +const renderWithProviders = ( + ui: React.ReactElement, + options: CustomRenderOptions = {} +): RenderResult => { + return customRender(ui, options) +} + +// Render with minimal setup (no providers) +const renderWithoutProviders = ( + ui: React.ReactElement, + options?: Omit +): RenderResult => { + return render(ui, options) +} + +// Render with only QueryClient +const renderWithQueryClient = ( + ui: React.ReactElement, + queryClientOptions?: any +): RenderResult => { + const queryClient = createTestQueryClient(queryClientOptions) + + const Wrapper: React.FC<{ children: React.ReactNode }> = ({children}) => ( + + {children} + + ) + + return render(ui, {wrapper: Wrapper}) +} + +// Export all render utilities +export { + customRender as render, + renderWithProviders, + renderWithoutProviders, + renderWithQueryClient, + createTestQueryClient +} + +// Re-export everything from React Testing Library +export * from '@testing-library/react' +export {default as userEvent} from '@testing-library/user-event' + +// Re-export all helper utilities +export * from './test-helpers' +export * from './mock-factories' +export * from './api-mocks' diff --git a/__tests__/helpers/utilities-core.test.ts b/__tests__/helpers/utilities-core.test.ts new file mode 100644 index 00000000..2485c00a --- /dev/null +++ b/__tests__/helpers/utilities-core.test.ts @@ -0,0 +1,203 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {apiMocks, createMockApiResponse, createMockFile, createMockFormData, createMockUser, testHelpers} from './index' + +describe('Core Test Utilities (Non-DOM)', () => { + beforeEach(() => { + apiMocks.reset() + }) + + afterEach(() => { + testHelpers.cleanup.cleanupMocks() + }) + + describe('Mock Factories', () => { + it('should create mock user data', () => { + const mockUser = createMockUser({ + name: 'Custom User', + email: 'custom@example.com' + }) + + expect(mockUser.name).toBe('Custom User') + expect(mockUser.email).toBe('custom@example.com') + expect(mockUser.id).toBe('user_123') + expect(mockUser.firstName).toBe('Test') + expect(mockUser.lastName).toBe('User') + }) + + it('should create mock API responses', () => { + const mockResponse = createMockApiResponse({message: 'Success'}) + + expect(mockResponse.data).toEqual({message: 'Success'}) + expect(mockResponse.status).toBe(200) + expect(mockResponse.success).toBe(true) + expect(mockResponse.statusText).toBe('OK') + }) + + it('should create mock files', () => { + const mockFile = createMockFile('test.txt', 'test content', 'text/plain') + + expect(mockFile.name).toBe('test.txt') + expect(mockFile.type).toBe('text/plain') + expect(mockFile.size).toBeGreaterThan(0) + }) + + it('should create mock form data', () => { + const formData = createMockFormData({ + name: 'John Doe', + email: 'john@example.com', + age: 30 + }) + + expect(formData.get('name')).toBe('John Doe') + expect(formData.get('email')).toBe('john@example.com') + expect(formData.get('age')).toBe('30') + }) + }) + + describe('API Mocking Utilities', () => { + it('should mock successful API responses', async () => { + const testData = {message: 'Success'} + apiMocks.mockSuccess('/api/test', testData) + + const response = await fetch('/api/test') + const data = await response.json() + + expect(response.ok).toBe(true) + expect(data).toEqual(testData) + }) + + it('should mock API errors', async () => { + apiMocks.mockError('/api/error', {status: 404, message: 'Not Found'}) + + const response = await fetch('/api/error') + const data = await response.json() + + expect(response.status).toBe(404) + expect(data.message).toBe('Not Found') + }) + + it('should verify fetch calls', async () => { + apiMocks.mockSuccess('/api/verify', {success: true}) + + await fetch('/api/verify') + + apiMocks.verifyFetchCalled('/api/verify', 1) + }) + + it('should mock different HTTP methods', async () => { + const postData = {id: 1, name: 'Created'} + apiMocks.mockPost('/api/create', postData) + + const response = await fetch('/api/create', {method: 'POST'}) + const data = await response.json() + + expect(response.status).toBe(201) + expect(data).toEqual(postData) + }) + + it('should mock paginated responses', async () => { + const items = [ + {id: 1, name: 'Item 1'}, + {id: 2, name: 'Item 2'}, + {id: 3, name: 'Item 3'} + ] + + apiMocks.mockPaginated('/api/items', items, 1, 2) + + const response = await fetch('/api/items') + const data = await response.json() + + expect(data.data).toHaveLength(2) + expect(data.pagination.page).toBe(1) + expect(data.pagination.limit).toBe(2) + expect(data.pagination.total).toBe(3) + }) + }) + + describe('Test Data Helpers', () => { + it('should generate random data', () => { + const randomString = testHelpers.data.randomString(5) + const randomEmail = testHelpers.data.randomEmail() + const randomNumber = testHelpers.data.randomNumber(1, 10) + const randomBoolean = testHelpers.data.randomBoolean() + + expect(randomString).toHaveLength(5) + expect(randomEmail).toMatch(/@example\.com$/) + expect(randomNumber).toBeGreaterThanOrEqual(1) + expect(randomNumber).toBeLessThanOrEqual(10) + expect(typeof randomBoolean).toBe('boolean') + }) + + it('should generate random dates', () => { + const start = new Date('2020-01-01') + const end = new Date('2021-01-01') + const randomDate = testHelpers.data.randomDate(start, end) + + expect(randomDate.getTime()).toBeGreaterThanOrEqual(start.getTime()) + expect(randomDate.getTime()).toBeLessThanOrEqual(end.getTime()) + }) + }) + + describe('Mock Helpers', () => { + it('should mock console methods', () => { + const consoleMock = testHelpers.mock.mockConsole() + + console.log('test message') + console.warn('test warning') + console.error('test error') + + expect(consoleMock.mockLog).toHaveBeenCalledWith('test message') + expect(consoleMock.mockWarn).toHaveBeenCalledWith('test warning') + expect(consoleMock.mockError).toHaveBeenCalledWith('test error') + + consoleMock.restore() + }) + + it('should create mock functions', () => { + const mockFn = vi.fn() + mockFn('test') + + expect(mockFn).toHaveBeenCalledWith('test') + expect(mockFn).toHaveBeenCalledTimes(1) + }) + + it('should mock timers', () => { + const timerMock = testHelpers.mock.mockTimers() + const callback = vi.fn() + + setTimeout(callback, 1000) + + expect(callback).not.toHaveBeenCalled() + + timerMock.advanceTime(1000) + + expect(callback).toHaveBeenCalledOnce() + + timerMock.restore() + }) + }) + + describe('Cleanup Helpers', () => { + it('should cleanup mocks', () => { + const mockFn = vi.fn() + mockFn('test') + + expect(mockFn).toHaveBeenCalledTimes(1) + + testHelpers.cleanup.cleanupMocks() + + // Mock should be cleared but still callable + expect(mockFn).toHaveBeenCalledTimes(0) + }) + + it('should cleanup storage', () => { + localStorage.setItem('test', 'value') + sessionStorage.setItem('test', 'value') + + testHelpers.cleanup.cleanupStorage() + + expect(localStorage.getItem('test')).toBeNull() + expect(sessionStorage.getItem('test')).toBeNull() + }) + }) +}) diff --git a/__tests__/lib-utils.test.ts b/__tests__/lib-utils.test.ts new file mode 100644 index 00000000..135e92da --- /dev/null +++ b/__tests__/lib-utils.test.ts @@ -0,0 +1,453 @@ +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {apiDateToDate, cn, dateToApiFormat, deepCopy, sanitizeHtml, stripNullFields} from '@/lib/utils' + +describe('lib/utils.ts', () => { + describe('cn function', () => { + it('should merge class names correctly', () => { + expect(cn('class1', 'class2')).toBe('class1 class2') + }) + + it('should handle conditional classes', () => { + expect(cn('base', false && 'conditional')).toBe('base') + expect(cn('base', true && 'conditional')).toBe('base conditional') + }) + + it('should handle undefined and null values', () => { + expect(cn('base', undefined, null)).toBe('base') + }) + + it('should handle empty strings', () => { + expect(cn('base', '', 'other')).toBe('base other') + }) + + it('should handle arrays of classes', () => { + expect(cn(['class1', 'class2'], 'class3')).toBe('class1 class2 class3') + }) + + it('should handle objects with boolean values', () => { + expect(cn({ + 'active': true, + 'disabled': false, + 'visible': true + })).toBe('active visible') + }) + + it('should merge Tailwind classes correctly', () => { + // This tests the twMerge functionality + expect(cn('px-2 py-1', 'px-4')).toBe('py-1 px-4') + }) + + it('should handle complex combinations', () => { + expect(cn( + 'base-class', + {'conditional': true, 'hidden': false}, + ['array-class1', 'array-class2'], + undefined, + 'final-class' + )).toBe('base-class conditional array-class1 array-class2 final-class') + }) + }) + + describe('deepCopy function', () => { + it('should create a deep copy of an object', () => { + const original = {a: 1, b: {c: 2, d: [3, 4]}} + const copy = deepCopy(original) + + expect(copy).toEqual(original) + expect(copy).not.toBe(original) + expect(copy.b).not.toBe(original.b) + expect(copy.b.d).not.toBe(original.b.d) + }) + + it('should handle arrays', () => { + const original = [1, 2, {a: 3}, [4, 5]] + const copy = deepCopy(original) + + expect(copy).toEqual(original) + expect(copy).not.toBe(original) + expect(copy[2]).not.toBe(original[2]) + expect(copy[3]).not.toBe(original[3]) + }) + + it('should handle primitive values', () => { + expect(deepCopy(42)).toBe(42) + expect(deepCopy('string')).toBe('string') + expect(deepCopy(true)).toBe(true) + expect(deepCopy(null)).toBe(null) + }) + + it('should handle nested objects', () => { + const original = { + level1: { + level2: { + level3: { + value: 'deep' + } + } + } + } + const copy = deepCopy(original) + + expect(copy.level1.level2.level3.value).toBe('deep') + expect(copy.level1.level2.level3).not.toBe(original.level1.level2.level3) + }) + + it('should handle empty objects and arrays', () => { + expect(deepCopy({})).toEqual({}) + expect(deepCopy([])).toEqual([]) + }) + + it('should handle objects with mixed data types', () => { + const original = { + string: 'test', + number: 42, + boolean: true, + nullValue: null, + array: [1, 'two', {three: 3}], + object: {nested: 'value'} + } + const copy = deepCopy(original) + + expect(copy).toEqual(original) + expect(copy).not.toBe(original) + expect(copy.array).not.toBe(original.array) + expect(copy.object).not.toBe(original.object) + }) + + it('should not handle functions, undefined, or symbols (JSON limitation)', () => { + const original = { + func: () => 'test', + undef: undefined, + symbol: Symbol('test'), + valid: 'value' + } + const copy = deepCopy(original) + + expect(copy.func).toBeUndefined() + expect(copy.undef).toBeUndefined() + expect(copy.symbol).toBeUndefined() + expect(copy.valid).toBe('value') + }) + }) + + describe('stripNullFields function', () => { + it('should remove null fields from an object', () => { + const input = { + name: 'John', + age: null, + email: 'john@example.com', + phone: null, + active: true + } + const result = stripNullFields(input) + + expect(result).toEqual({ + name: 'John', + email: 'john@example.com', + active: true + }) + }) + + it('should preserve undefined values', () => { + const input = { + name: 'John', + age: undefined, + email: null, + phone: 'valid' + } + const result = stripNullFields(input) + + expect(result).toEqual({ + name: 'John', + age: undefined, + phone: 'valid' + }) + }) + + it('should preserve false and 0 values', () => { + const input = { + name: 'John', + active: false, + count: 0, + email: null, + score: null + } + const result = stripNullFields(input) + + expect(result).toEqual({ + name: 'John', + active: false, + count: 0 + }) + }) + + it('should handle empty objects', () => { + expect(stripNullFields({})).toEqual({}) + }) + + it('should handle objects with only null values', () => { + const input = { + a: null, + b: null, + c: null + } + const result = stripNullFields(input) + + expect(result).toEqual({}) + }) + + it('should handle objects with no null values', () => { + const input = { + name: 'John', + age: 30, + active: true + } + const result = stripNullFields(input) + + expect(result).toEqual(input) + }) + + it('should preserve empty strings', () => { + const input = { + name: '', + email: null, + phone: 'valid' + } + const result = stripNullFields(input) + + expect(result).toEqual({ + name: '', + phone: 'valid' + }) + }) + + it('should handle nested objects (shallow operation)', () => { + const input = { + user: { + name: 'John', + email: null + }, + settings: null, + active: true + } + const result = stripNullFields(input) + + expect(result).toEqual({ + user: { + name: 'John', + email: null // nested nulls are preserved + }, + active: true + }) + }) + }) + + describe('dateToApiFormat function', () => { + it('should format valid dates correctly', () => { + const date = new Date('2024-01-15T10:30:00Z') + expect(dateToApiFormat(date)).toBe('2024-01-15') + }) + + it('should handle single digit months and days', () => { + const date = new Date('2024-03-05T10:30:00Z') + expect(dateToApiFormat(date)).toBe('2024-03-05') + }) + + it('should handle December and day 31', () => { + const date = new Date('2024-12-31T10:30:00Z') + expect(dateToApiFormat(date)).toBe('2024-12-31') + }) + + it('should handle January 1st', () => { + const date = new Date('2024-01-01T10:30:00Z') + expect(dateToApiFormat(date)).toBe('2024-01-01') + }) + + it('should return null for null input', () => { + expect(dateToApiFormat(null)).toBeNull() + }) + + it('should return null for undefined input', () => { + expect(dateToApiFormat(undefined)).toBeNull() + }) + + it('should handle leap year dates', () => { + const date = new Date('2024-02-29T10:30:00Z') + expect(dateToApiFormat(date)).toBe('2024-02-29') + }) + + it('should handle different timezones consistently', () => { + // Create date using local timezone + const date = new Date(2024, 0, 15) // Month is 0-indexed + const result = dateToApiFormat(date) + expect(result).toBe('2024-01-15') + }) + + it('should handle edge case dates', () => { + // Test with a very old date + const oldDate = new Date('1900-01-01T00:00:00Z') + expect(dateToApiFormat(oldDate)).toBe('1900-01-01') + + // Test with a future date - using local timezone to match function behavior + const futureDate = new Date('2099-12-31T00:00:00') + expect(dateToApiFormat(futureDate)).toBe('2099-12-31') + }) + }) + + describe('apiDateToDate function', () => { + it('should convert valid date strings to Date objects', () => { + const result = apiDateToDate('2024-01-15') + expect(result).toBeInstanceOf(Date) + expect(result?.getFullYear()).toBe(2024) + expect(result?.getMonth()).toBe(0) // January is 0 + expect(result?.getDate()).toBe(15) + }) + + it('should handle ISO date strings', () => { + const result = apiDateToDate('2024-01-15T10:30:00Z') + expect(result).toBeInstanceOf(Date) + expect(result?.getFullYear()).toBe(2024) + }) + + it('should return null for null input', () => { + expect(apiDateToDate(null)).toBeNull() + }) + + it('should return null for undefined input', () => { + expect(apiDateToDate(undefined)).toBeNull() + }) + + it('should return null for empty string', () => { + expect(apiDateToDate('')).toBeNull() + }) + + it('should handle different date formats', () => { + // MM/DD/YYYY format + const result1 = apiDateToDate('01/15/2024') + expect(result1).toBeInstanceOf(Date) + + // YYYY-MM-DD format + const result2 = apiDateToDate('2024-01-15') + expect(result2).toBeInstanceOf(Date) + }) + + it('should handle invalid date strings gracefully', () => { + const result = apiDateToDate('invalid-date') + expect(result).toBeInstanceOf(Date) + // Check if the date is invalid by checking if getTime() returns NaN + expect(Number.isNaN(result?.getTime())).toBe(true) + }) + + it('should be consistent with dateToApiFormat', () => { + const originalDate = new Date('2024-01-15T10:30:00Z') + const apiFormat = dateToApiFormat(originalDate) + const convertedBack = apiDateToDate(apiFormat) + + expect(convertedBack?.getFullYear()).toBe(originalDate.getFullYear()) + expect(convertedBack?.getMonth()).toBe(originalDate.getMonth()) + expect(convertedBack?.getDate()).toBe(originalDate.getDate()) + }) + }) + + describe('sanitizeHtml function', () => { + // Mock window to be undefined for server-side tests + const originalWindow = global.window + + beforeEach(() => { + // @ts-ignore + global.window = undefined + }) + + afterEach(() => { + global.window = originalWindow + }) + + it('should strip all HTML tags in server environment', () => { + // In test environment (no window), it should strip all tags + const input = '

Hello world!

' + const result = sanitizeHtml(input) + + expect(result).toBe('Hello world!') + }) + + it('should handle complex HTML structures in server environment', () => { + const input = ` +
+

Title

+

Paragraph with link

+
    +
  • Item 1
  • +
  • Item 2
  • +
+
+ ` + const result = sanitizeHtml(input) + + expect(result).toBe(` + + Title + Paragraph with link + + Item 1 + Item 2 + + + `) + }) + + it('should handle self-closing tags', () => { + const input = 'Line 1
Line 2
Line 3' + const result = sanitizeHtml(input) + + expect(result).toBe('Line 1Line 2Line 3') + }) + + it('should handle malformed HTML', () => { + const input = '

Unclosed paragraph

Nested content
' + const result = sanitizeHtml(input) + + expect(result).toBe('Unclosed paragraphNested content') + }) + + it('should handle empty strings', () => { + expect(sanitizeHtml('')).toBe('') + }) + + it('should handle strings without HTML', () => { + const input = 'Plain text content' + const result = sanitizeHtml(input) + + expect(result).toBe('Plain text content') + }) + + it('should handle HTML entities', () => { + const input = '

<script>alert("test")</script>

' + const result = sanitizeHtml(input) + + expect(result).toBe('<script>alert("test")</script>') + }) + + it('should handle script tags and dangerous content', () => { + const input = ` +

Safe content

+ + + Link + ` + const result = sanitizeHtml(input) + + expect(result).not.toContain(' & special chars' + const specialDescription = 'Description with "quotes" & and émojis 🚨' + + render() + + expect(screen.getByText(specialTitle)).toBeInTheDocument() + expect(screen.getByText(specialDescription)).toBeInTheDocument() + }) + }) + + describe('Error Handling', () => { + it('handles button function being called correctly', () => { + const customFunction = vi.fn() + render() + + const button = screen.getByTestId('error-button') + fireEvent.click(button) + + expect(customFunction).toHaveBeenCalledTimes(1) + }) + + it('uses reload function when no custom function provided', () => { + render() + + const button = screen.getByTestId('error-button') + fireEvent.click(button) + + expect(mockReload).toHaveBeenCalledTimes(1) + }) + }) + + describe('Component Integration', () => { + it('integrates properly with motion component', () => { + render() + + const motionDiv = screen.getByTestId('motion-div') + expect(motionDiv).toBeInTheDocument() + + // Should have all motion props + expect(motionDiv).toHaveAttribute('data-initial') + expect(motionDiv).toHaveAttribute('data-animate') + expect(motionDiv).toHaveAttribute('data-transition') + }) + + it('integrates properly with Button component', () => { + render() + + const button = screen.getByTestId('error-button') + expect(button).toHaveAttribute('data-variant', 'secondary') + expect(button).toHaveClass('w-[70%]') + }) + + it('integrates properly with Image component', () => { + render() + + const image = screen.getByTestId('error-banner-image') + expect(image).toHaveAttribute('data-priority', 'true') + expect(image).toHaveAttribute('role', 'presentation') + }) + }) +}) diff --git a/env.ts b/env.ts index 700497df..4fc3f4e9 100644 --- a/env.ts +++ b/env.ts @@ -51,8 +51,7 @@ const envSchema = z.object({ // Required Ghost CMS API key GHOST_API_KEY: z.string({ - required_error: 'Ghost API key is required', - invalid_type_error: 'Ghost API key must be a string' + message: 'Ghost API key must be a string' }), // Required PostHog key, must start with "phc_" @@ -96,7 +95,35 @@ const envSchema = z.object({ NEXT_PUBLIC_KNOCK_FEED_CHANNEL_ID: z.string({ required_error: 'Knock feed channel ID is required', invalid_type_error: 'Knock feed channel ID must be a string' - }) + }), + + // Required backend admin API key for accessing admin endpoints + BACKEND_ADMIN_API_KEY: z.string({ + required_error: 'Backend admin API key is required', + invalid_type_error: 'Backend admin API key must be a string' + }), + + // Required self secret key for API route authentication + SELF_SECRET_KEY: z.string({ + required_error: 'Self secret key is required', + invalid_type_error: 'Self secret key must be a string' + }), + + // Required Gemini API key for Vercel AI SDK + GOOGLE_GENERATIVE_AI_API_KEY: z.string({ + required_error: 'Gemini API key is required', + invalid_type_error: 'Gemini API key must be a string' + }), + + // Optional feature flag for Resume Editor tabs new design + NEXT_PUBLIC_RESUME_EDITOR_TABS_NEW_DESIGN_ENABLED: z.string() + .optional() + .default('true'), + + // Optional Algolia search configuration for client-side search + NEXT_PUBLIC_ALGOLIA_APPLICATION_ID: z.string(), + NEXT_PUBLIC_ALGOLIA_SEARCH_ONLY_API_KEY: z.string(), + NEXT_PUBLIC_ALGOLIA_INDEX_NAME: z.string() }) diff --git a/lib/__tests__/utils.test.ts b/lib/__tests__/utils.test.ts new file mode 100644 index 00000000..8cee53e5 --- /dev/null +++ b/lib/__tests__/utils.test.ts @@ -0,0 +1,240 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {apiDateToDate, dateToApiFormat, sanitizeHtml} from '@/lib/utils' + +describe('dateToApiFormat', () => { + it('should format date to YYYY-MM-DD format', () => { + const date = new Date('2023-12-15T10:30:00Z') + const result = dateToApiFormat(date) + expect(result).toBe('2023-12-15') + }) + + it('should handle single digit months and days', () => { + const date = new Date('2023-01-05T10:30:00Z') + const result = dateToApiFormat(date) + expect(result).toBe('2023-01-05') + }) + + it('should return null for null input', () => { + const result = dateToApiFormat(null) + expect(result).toBeNull() + }) + + it('should return null for undefined input', () => { + const result = dateToApiFormat(undefined) + expect(result).toBeNull() + }) + + it('should handle leap year dates', () => { + const date = new Date('2020-02-29T10:30:00Z') + const result = dateToApiFormat(date) + expect(result).toBe('2020-02-29') + }) + + it('should handle year boundaries', () => { + const newYear = new Date('2024-01-01T00:00:00Z') + const result = dateToApiFormat(newYear) + expect(result).toBe('2024-01-01') + }) + + it('should preserve local timezone when formatting', () => { + // Test with a specific date that could be affected by timezone + const date = new Date(2023, 11, 15) // December 15, 2023 in local time + const result = dateToApiFormat(date) + expect(result).toBe('2023-12-15') + }) +}) + +describe('apiDateToDate', () => { + it('should convert API date string to Date object', () => { + const result = apiDateToDate('2023-12-15') + expect(result).toBeInstanceOf(Date) + expect(result?.getFullYear()).toBe(2023) + expect(result?.getMonth()).toBe(11) // December is month 11 + expect(result?.getDate()).toBe(15) + }) + + it('should handle ISO date strings', () => { + const result = apiDateToDate('2023-12-15T10:30:00Z') + expect(result).toBeInstanceOf(Date) + expect(result?.getFullYear()).toBe(2023) + expect(result?.getMonth()).toBe(11) + expect(result?.getDate()).toBe(15) + }) + + it('should return null for null input', () => { + const result = apiDateToDate(null) + expect(result).toBeNull() + }) + + it('should return null for undefined input', () => { + const result = apiDateToDate(undefined) + expect(result).toBeNull() + }) + + it('should return null for empty string', () => { + const result = apiDateToDate('') + expect(result).toBeNull() + }) + + it('should handle invalid date strings', () => { + const result = apiDateToDate('invalid-date') + expect(result).toBeInstanceOf(Date) + expect(result?.toString()).toBe('Invalid Date') + }) + + it('should handle leap year dates', () => { + const result = apiDateToDate('2020-02-29') + expect(result).toBeInstanceOf(Date) + expect(result?.getFullYear()).toBe(2020) + expect(result?.getMonth()).toBe(1) // February is month 1 + expect(result?.getDate()).toBe(29) + }) +}) + +describe('sanitizeHtml', () => { + // Mock window for client-side tests + const mockWindow = { + location: {href: 'http://localhost'}, + document: {} + } + + beforeEach(() => { + vi.stubGlobal('window', mockWindow) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('should preserve allowed tags', () => { + const html = '

Hello world

' + const result = sanitizeHtml(html) + expect(result).toBe('

Hello world

') + }) + + it('should preserve allowed attributes', () => { + const html = 'Link' + const result = sanitizeHtml(html) + + // Check that all expected attributes are present (order may vary) + expect(result).toContain('href="https://example.com"') + expect(result).toContain('target="_blank"') + expect(result).toContain('rel="noopener"') + expect(result).toContain('>Link') + expect(result).toMatch(/]*href="https:\/\/example\.com"[^>]*>Link<\/a>/) + }) + + it('should remove disallowed tags', () => { + const html = '

Hello

' + const result = sanitizeHtml(html) + expect(result).toBe('

Hello

') + }) + + it('should remove disallowed attributes', () => { + const html = '

Click me

' + const result = sanitizeHtml(html) + expect(result).toBe('

Click me

') + }) + + it('should handle nested allowed tags', () => { + const html = '

Text with emphasis and bold

' + const result = sanitizeHtml(html) + expect(result).toBe('

Text with emphasis and bold

') + }) + + it('should handle lists', () => { + const html = '
  • Item 1
  • Item 2
' + const result = sanitizeHtml(html) + expect(result).toBe('
  • Item 1
  • Item 2
') + }) + + it('should handle line breaks', () => { + const html = 'Line 1
Line 2
Line 3' + const result = sanitizeHtml(html) + expect(result).toBe('Line 1
Line 2
Line 3') + }) + + it('should handle underlined text', () => { + const html = '

This is underlined text

' + const result = sanitizeHtml(html) + expect(result).toBe('

This is underlined text

') + }) + + it('should handle ordered lists', () => { + const html = '
  1. First
  2. Second
' + const result = sanitizeHtml(html) + expect(result).toBe('
  1. First
  2. Second
') + }) + + it('should handle empty input', () => { + const result = sanitizeHtml('') + expect(result).toBe('') + }) + + it('should handle plain text', () => { + const html = 'Just plain text' + const result = sanitizeHtml(html) + expect(result).toBe('Just plain text') + }) + + describe('server-side fallback', () => { + beforeEach(() => { + vi.stubGlobal('window', undefined) + }) + + it('should strip all HTML tags on server-side', () => { + const html = '

Hello world

' + const result = sanitizeHtml(html) + expect(result).toBe('Hello world') + }) + + it('should handle complex HTML on server-side', () => { + const html = '

Paragraph

Bold
' + const result = sanitizeHtml(html) + expect(result).toBe('Paragraphalert("xss")Bold') + }) + + it('should handle empty input on server-side', () => { + const result = sanitizeHtml('') + expect(result).toBe('') + }) + + it('should handle plain text on server-side', () => { + const html = 'Just plain text' + const result = sanitizeHtml(html) + expect(result).toBe('Just plain text') + }) + }) +}) + +describe('date transformation round-trip', () => { + it('should maintain date integrity through round-trip conversion', () => { + const originalDate = new Date('2023-12-15T10:30:00Z') + const apiFormat = dateToApiFormat(originalDate) + const convertedBack = apiDateToDate(apiFormat) + + expect(convertedBack).toBeInstanceOf(Date) + expect(convertedBack?.getFullYear()).toBe(2023) + expect(convertedBack?.getMonth()).toBe(11) + expect(convertedBack?.getDate()).toBe(15) + }) + + it('should handle edge cases in round-trip conversion', () => { + const leapYearDate = new Date(2020, 1, 29) // February 29, 2020 (leap year) + const apiFormat = dateToApiFormat(leapYearDate) + const convertedBack = apiDateToDate(apiFormat) + + expect(convertedBack).toBeInstanceOf(Date) + expect(convertedBack?.getFullYear()).toBe(2020) + expect(convertedBack?.getMonth()).toBe(1) // February is month 1 (0-indexed) + expect(convertedBack?.getDate()).toBe(29) + }) + + it('should handle null values in round-trip', () => { + const apiFormat = dateToApiFormat(null) + const convertedBack = apiDateToDate(apiFormat) + + expect(apiFormat).toBeNull() + expect(convertedBack).toBeNull() + }) +}) diff --git a/lib/certification/actions.ts b/lib/certification/actions.ts new file mode 100644 index 00000000..2d8a85de --- /dev/null +++ b/lib/certification/actions.ts @@ -0,0 +1,100 @@ +'use server' + +import {z} from 'zod' +import {Certification, CertificationMutation, CertificationMutationSchema, CertificationSchema} from '@/lib/certification/types' +import {api} from '@/lib/config/api-client' +import {handleErrors} from '@/lib//misc/error-handler' +import {apiDateToDate, dateToApiFormat} from '@/lib/utils' + +/** + * Adds new certification information to the database. + * @param {CertificationMutation} certificationValues - The certification information to add. + * @returns {Promise} The newly added certification object. + * @throws {Error} If validation, authentication, or API request fails. + */ +export const addCertificationToDB = async ( + certificationValues: CertificationMutation, + resumeId: string = 'base' +): Promise => { + try { + const params = CertificationMutationSchema.parse(certificationValues) + + // Transform date for API compatibility + const apiParams = { + ...params, + issue_date: params.issue_date ? dateToApiFormat(params.issue_date as Date) : undefined + } + + const data = await api.post(`/resume/${resumeId}/certification/`, apiParams) + + return CertificationSchema.parse(data) + } catch (error) { + return handleErrors(error, 'add certification') + } +} + +/** + * Retrieves all certification entries for a specific resume from the database. + * @param {string} [resumeId='base'] - The ID of the resume to retrieve certification entries for. Defaults to 'base'. + * @returns {Promise} An array of certification objects. + * @throws {Error} If authentication or API request fails. + */ +export const getCertificationsFromDB = async ( + resumeId: string = 'base' +): Promise => { + const apiUrl = `/resume/${resumeId}/certification/` + + try { + const data = await api.get(apiUrl) + + const parsedData = z.array(CertificationSchema).parse(data) + return parsedData + } catch (error) { + return handleErrors(error, 'fetch certifications') + } +} + +/** + * Updates an existing certification entry in the database. + * @param {string} certificationId - The ID of the certification entry to update. + * @param {Partial} certificationValues - The updated certification information. + * @param {string} [resumeId='base'] - The ID of the resume the certification entry belongs to. Defaults to 'base'. + * @returns {Promise} The updated certification object. + * @throws {Error} If validation, authentication, or API request fails. + */ +export const updateCertificationInDB = async ( + certificationId: string, + certificationValues: Partial, + resumeId: string = 'base' +): Promise => { + try { + // Transform date for API compatibility if present + const apiParams = certificationValues.issue_date ? { + ...certificationValues, + issue_date: dateToApiFormat(certificationValues.issue_date as Date) + } : certificationValues + + const data = await api.patch(`/resume/${resumeId}/certification/${certificationId}/`, apiParams) + + return CertificationSchema.parse(data) + } catch (error) { + return handleErrors(error, 'update certification') + } +} + +/** + * Deletes a specific certification entry from the database. + * @param {string} certificationId - The ID of the certification entry to delete. + * @param {string} [resumeId='base'] - The ID of the resume the certification entry belongs to. Defaults to 'base'. + * @throws {Error} If authentication or API request fails. + */ +export const deleteCertificationFromDB = async ( + certificationId: string, + resumeId: string = 'base' +): Promise => { + try { + await api.delete(`/resume/${resumeId}/certification/${certificationId}/`) + } catch (error) { + return handleErrors(error, 'delete certification') + } +} diff --git a/lib/certification/keys.ts b/lib/certification/keys.ts new file mode 100644 index 00000000..b1304ce3 --- /dev/null +++ b/lib/certification/keys.ts @@ -0,0 +1 @@ +export const CERTIFICATION_KEYS = ['certification'] diff --git a/lib/certification/mutations.ts b/lib/certification/mutations.ts new file mode 100644 index 00000000..9cffba23 --- /dev/null +++ b/lib/certification/mutations.ts @@ -0,0 +1,18 @@ +import {MutationOptions, useMutation} from '@tanstack/react-query' +import {Certification, CertificationMutation} from '@/lib/certification/types' +import {addCertificationToDB, deleteCertificationFromDB, updateCertificationInDB} from '@/lib/certification/actions' + +export const useAddCertificationMutation = (options?: MutationOptions) => useMutation({ + mutationFn: ({data, resumeId}) => addCertificationToDB(data, resumeId || 'base'), + ...options +}) + +export const useUpdateCertificationMutation = (options?: MutationOptions, resumeId?: string}>) => useMutation({ + mutationFn: ({id, data, resumeId}) => updateCertificationInDB(id, data, resumeId || 'base'), + ...options +}) + +export const useDeleteCertificationMutation = (options?:MutationOptions) => useMutation({ + mutationFn: ({id, resumeId}) => deleteCertificationFromDB(id, resumeId || 'base'), + ...options +}) diff --git a/lib/certification/queries.ts b/lib/certification/queries.ts new file mode 100644 index 00000000..f94ae6a1 --- /dev/null +++ b/lib/certification/queries.ts @@ -0,0 +1,13 @@ +import {queryOptions, useQuery} from '@tanstack/react-query' +import {CERTIFICATION_KEYS} from '@/lib/certification/keys' +import {getCertificationsFromDB} from '@/lib/certification/actions' + +export const certificationQueryOptions = (resumeId: string) => queryOptions({ + queryKey: [...CERTIFICATION_KEYS, resumeId], + queryFn: () => getCertificationsFromDB(resumeId) +}) + + +export const useCurrentCertifications = (resumeId: string) => { + return useQuery(certificationQueryOptions(resumeId)) +} diff --git a/lib/certification/types.ts b/lib/certification/types.ts new file mode 100644 index 00000000..c3a249d0 --- /dev/null +++ b/lib/certification/types.ts @@ -0,0 +1,41 @@ +import {z} from 'zod' + +/* + * Base schema for Certification + * Based on the API documentation provided + */ +export const CertificationSchema = z.object({ + id: z.string().uuid().describe('Unique identifier for the certification').readonly(), + name: z.string().max(255).describe('Name of the certification.'), + issuing_organization: z.string().max(255).nullable().optional().describe('Organization that issued the certification'), + issue_date: z.string().nullable().optional().describe('Date when the certification was issued.'), + credential_url: z.string().max(200).nullable().optional().describe('Link to the certification credential.'), + created_at: z.string().readonly().describe('Timestamp when the certification was first created.'), + updated_at: z.string().readonly().describe('Timestamp when the certification was last updated.'), + user: z.string().describe('The user who owns this certification'), + resume_section: z.string().uuid().describe('The unique identifier for the resume section entry.') +}) + +/** + * Schema for CertificationMutation + * Derived by omitting read-only fields from CertificationSchema + * Required fields: name, issuing_organization, issue_date + * Optional field: credential_url + */ +export const CertificationMutationSchema = CertificationSchema.omit({ + id: true, + created_at: true, + updated_at: true, + user: true, + resume_section: true +}).extend({ + name: z.string().min(1, {message: 'Required'}).max(255).describe('Name of the certification.'), + issuing_organization: z.string().max(255).optional().describe('Organization that issued the certification'), + // Accept ISO date string, Date, or null (parser may return null for unknown) + issue_date: z.union([z.string(), z.date(), z.null()]).optional().describe('Date when the certification was issued.'), + credential_url: z.string().max(200).nullable().optional().describe('Link to the certification credential.') +}) + +// Infer TypeScript types from the schema +export type Certification = z.infer +export type CertificationMutation = z.infer diff --git a/lib/config/__tests__/api-client.test.ts b/lib/config/__tests__/api-client.test.ts new file mode 100644 index 00000000..d2a37ce3 --- /dev/null +++ b/lib/config/__tests__/api-client.test.ts @@ -0,0 +1,242 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {api} from '@/lib/config/api-client' +import {mockApiError, mockApiSuccess} from '@/test-setup' + +// Mock next/headers +vi.mock('next/headers', () => ({ + cookies: vi.fn(() => ({ + toString: vi.fn(() => 'session=abc123; user=john') + })) +})) + +// Set environment variable for API URL +process.env.API_URL = 'http://localhost:3000' + +describe('api client', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('api methods', () => { + it('should make GET request', async () => { + const mockData = {id: 1, name: 'test'} + mockApiSuccess(mockData) + + const result = await api.get('/test') + + expect(global.fetch).toHaveBeenCalledWith('http://localhost:3000/test', { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: undefined, + credentials: 'include', + cache: 'no-store', + next: undefined + }) + expect(result).toEqual(mockData) + }) + + it('should make POST request with body', async () => { + const mockData = {id: 1, name: 'test'} + const requestBody = {name: 'test'} + mockApiSuccess(mockData) + + const result = await api.post('/test', requestBody) + + expect(global.fetch).toHaveBeenCalledWith('http://localhost:3000/test', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestBody), + credentials: 'include', + cache: 'no-store', + next: undefined + }) + expect(result).toEqual(mockData) + }) + + it('should make PUT request with body', async () => { + const mockData = {id: 1, name: 'updated'} + const requestBody = {name: 'updated'} + mockApiSuccess(mockData) + + const result = await api.put('/test/1', requestBody) + + expect(global.fetch).toHaveBeenCalledWith('http://localhost:3000/test/1', { + method: 'PUT', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestBody), + credentials: 'include', + cache: 'no-store', + next: undefined + }) + expect(result).toEqual(mockData) + }) + + it('should make PATCH request with body', async () => { + const mockData = {id: 1, name: 'patched'} + const requestBody = {name: 'patched'} + mockApiSuccess(mockData) + + const result = await api.patch('/test/1', requestBody) + + expect(global.fetch).toHaveBeenCalledWith('http://localhost:3000/test/1', { + method: 'PATCH', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestBody), + credentials: 'include', + cache: 'no-store', + next: undefined + }) + expect(result).toEqual(mockData) + }) + + it('should make DELETE request', async () => { + const mockData = {success: true} + mockApiSuccess(mockData) + + const result = await api.delete('/test/1') + + expect(global.fetch).toHaveBeenCalledWith('http://localhost:3000/test/1', { + method: 'DELETE', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: undefined, + credentials: 'include', + cache: 'no-store', + next: undefined + }) + expect(result).toEqual(mockData) + }) + + it('should handle query parameters', async () => { + const mockData = {results: []} + mockApiSuccess(mockData) + + const result = await api.get('/test', { + params: { + page: 1, + limit: 10, + search: 'test query' + } + }) + + expect(global.fetch).toHaveBeenCalledWith('http://localhost:3000/test?page=1&limit=10&search=test+query', { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: undefined, + credentials: 'include', + cache: 'no-store', + next: undefined + }) + expect(result).toEqual(mockData) + }) + + it('should filter out null and undefined parameters', async () => { + const mockData = {results: []} + mockApiSuccess(mockData) + + const result = await api.get('/test', { + params: { + page: 1, + limit: null, + search: undefined, + active: true + } + }) + + expect(global.fetch).toHaveBeenCalledWith('http://localhost:3000/test?page=1&active=true', { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: undefined, + credentials: 'include', + cache: 'no-store', + next: undefined + }) + expect(result).toEqual(mockData) + }) + + it('should handle 204 No Content response', async () => { + mockApiSuccess(null, 204) + + const result = await api.delete('/test/1') + + expect(result).toEqual({}) + }) + + it('should throw error for non-ok response', async () => { + mockApiError(400, 'Bad Request') + + await expect(api.get('/test')).rejects.toThrow('Bad Request') + }) + + it('should pass options to all methods', async () => { + const mockData = {success: true} + mockApiSuccess(mockData) + + const customOptions = { + headers: { + 'X-Custom-Header': 'custom-value' + } + } + + const result = await api.get('/test', customOptions) + + expect(global.fetch).toHaveBeenCalledWith('http://localhost:3000/test', { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-Custom-Header': 'custom-value' + }, + body: undefined, + credentials: 'include', + cache: 'no-store', + next: undefined + }) + expect(result).toEqual(mockData) + }) + + it('should include custom cookie header', async () => { + const mockData = {success: true} + mockApiSuccess(mockData) + + const result = await api.get('/test') + + expect(global.fetch).toHaveBeenCalledWith('http://localhost:3000/test', { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: undefined, + credentials: 'include', + cache: 'no-store', + next: undefined + }) + expect(result).toEqual(mockData) + }) + }) +}) diff --git a/lib/config/api-client.ts b/lib/config/api-client.ts index a3bc559b..4fa81b6f 100644 --- a/lib/config/api-client.ts +++ b/lib/config/api-client.ts @@ -76,6 +76,7 @@ const fetchApi = async ( const fullUrl = buildUrlWithParams(`${process.env.API_URL}${url}`, params) + const response = await fetch(fullUrl, { method, headers: { @@ -91,6 +92,7 @@ const fetchApi = async ( }) if (!response.ok) { + const errText = await response.clone().text().catch(() => '') const {error} = ((await response.json()) || response.statusText) as { error: ApiError } if (typeof window === 'undefined') { } else { @@ -107,7 +109,8 @@ const fetchApi = async ( return {} as T } - return response.json() + const json = await response.json() + return json } export const api = { diff --git a/lib/education/__tests__/actions.test.ts b/lib/education/__tests__/actions.test.ts new file mode 100644 index 00000000..2b96256b --- /dev/null +++ b/lib/education/__tests__/actions.test.ts @@ -0,0 +1,511 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest' +import { + addEducationToDB, + deleteEducationFromDB, + getEducationsFromDB, + updateEducationInDB +} from '@/lib/education/actions' +import {api} from '@/lib/config/api-client' +import {handleErrors} from '@/lib/misc/error-handler' +import {Education, EducationMutation} from '@/lib/education/types' + +// Mock dependencies +vi.mock('@/lib/config/api-client', () => ({ + api: { + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + get: vi.fn() + } +})) + +vi.mock('@/lib/misc/error-handler', () => ({ + handleErrors: vi.fn().mockImplementation(() => { + throw new Error('Mocked error') + }) +})) + +// Mock the schema validation to avoid issues +vi.mock('@/lib/education/types', () => ({ + EducationSchema: { + parse: vi.fn((data) => data) + }, + EducationMutationSchema: { + parse: vi.fn((data) => data) + } +})) + +// Mock Zod array parsing +vi.mock('zod', () => ({ + z: { + array: vi.fn(() => ({ + parse: vi.fn((data) => data) + })) + } +})) + +describe('education actions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('addEducationToDB', () => { + it('should successfully add education to database', async () => { + const mockEducationMutation: EducationMutation = { + institution_name: 'Harvard University', + field_of_study: 'Computer Science', + degree: 'Bachelor of Science', + country: 'USA', + started_from_month: '9', + started_from_year: '2018', + finished_at_month: '5', + finished_at_year: '2022', + current: false, + description: 'Studied computer science fundamentals' + } + + const mockEducationResponse: Education = { + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + user: 'user-123', + resume_section: 'b2c3d4e5-f6g7-8901-bcde-f12345678901', + institution_name: 'Harvard University', + field_of_study: 'Computer Science', + degree: 'Bachelor of Science', + country: { + code: 'USA', + name: 'United States' + }, + started_from_month: 9, + started_from_year: 2018, + finished_at_month: 5, + finished_at_year: 2022, + current: false, + description: 'Studied computer science fundamentals', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + } + + vi.mocked(api.post).mockResolvedValue(mockEducationResponse) + + const result = await addEducationToDB(mockEducationMutation) + + expect(api.post).toHaveBeenCalledWith('/resume/base/education/', mockEducationMutation) + expect(result).toEqual(mockEducationResponse) + }) + + it('should handle validation errors', async () => { + const invalidEducation: EducationMutation = { + institution_name: 'Harvard University', + field_of_study: 'Computer Science', + country: 'INVALID', // Invalid country code + started_from_month: '15', // Invalid month + started_from_year: '1900', // Invalid year + current: false + } + + vi.mocked(api.post).mockRejectedValue(new Error('Validation failed')) + vi.mocked(handleErrors).mockImplementation(() => { + throw new Error('Handled validation error') + }) + + await expect(addEducationToDB(invalidEducation)).rejects.toThrow('Handled validation error') + }) + + it('should handle API errors through error handler', async () => { + const mockEducationMutation: EducationMutation = { + institution_name: 'Harvard University', + field_of_study: 'Computer Science', + country: 'USA', + started_from_month: '9', + started_from_year: '2018', + current: false + } + + const apiError = new Error('API Error') + vi.mocked(api.post).mockRejectedValue(apiError) + vi.mocked(handleErrors).mockImplementation(() => { + throw new Error('Handled API Error') + }) + + await expect(addEducationToDB(mockEducationMutation)).rejects.toThrow('Handled API Error') + }) + + it('should handle current education (no graduation date)', async () => { + const currentEducation: EducationMutation = { + institution_name: 'MIT', + field_of_study: 'Artificial Intelligence', + degree: 'Master of Science', + country: 'USA', + started_from_month: '9', + started_from_year: '2022', + current: true, + description: 'Currently studying AI' + } + + const mockCurrentEducationResponse: Education = { + id: 'c3d4e5f6-g7h8-9012-cdef-g23456789012', + user: 'user-123', + resume_section: 'b2c3d4e5-f6g7-8901-bcde-f12345678901', + institution_name: 'MIT', + field_of_study: 'Artificial Intelligence', + degree: 'Master of Science', + country: { + code: 'USA', + name: 'United States' + }, + started_from_month: 9, + started_from_year: 2022, + finished_at_month: null, + finished_at_year: null, + current: true, + description: 'Currently studying AI', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + } + + vi.mocked(api.post).mockResolvedValue(mockCurrentEducationResponse) + + const result = await addEducationToDB(currentEducation) + + expect(result.current).toBe(true) + expect(result.finished_at_month).toBeNull() + expect(result.finished_at_year).toBeNull() + }) + + it('should handle education with custom resume ID', async () => { + const educationWithCustomId: EducationMutation = { + institution_name: 'Stanford University', + field_of_study: 'Machine Learning', + country: 'USA', + started_from_month: '9', + started_from_year: '2020', + current: false + } + + const mockResponse: Education = { + id: 'd4e5f6g7-h8i9-0123-def0-h34567890123', + user: 'user-123', + resume_section: 'e5f6g7h8-i9j0-1234-e0f1-i45678901234', + institution_name: 'Stanford University', + field_of_study: 'Machine Learning', + country: { + code: 'USA', + name: 'United States' + }, + started_from_month: 9, + started_from_year: 2020, + finished_at_month: null, + finished_at_year: null, + current: false, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + } + + vi.mocked(api.post).mockResolvedValue(mockResponse) + + const result = await addEducationToDB(educationWithCustomId) + + expect(api.post).toHaveBeenCalledWith('/resume/base/education/', educationWithCustomId) + expect(result).toEqual(mockResponse) + }) + }) + + describe('updateEducationInDB', () => { + it('should successfully update education in database', async () => { + const mockUpdateData: Partial = { + field_of_study: 'Data Science', + degree: 'Master of Science', + description: 'Updated description' + } + + const mockUpdatedEducation: Education = { + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + user: 'user-123', + resume_section: 'b2c3d4e5-f6g7-8901-bcde-f12345678901', + institution_name: 'Harvard University', + field_of_study: 'Data Science', + degree: 'Master of Science', + country: { + code: 'USA', + name: 'United States' + }, + started_from_month: 9, + started_from_year: 2018, + finished_at_month: 5, + finished_at_year: 2022, + current: false, + description: 'Updated description', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + } + + vi.mocked(api.patch).mockResolvedValue(mockUpdatedEducation) + + const result = await updateEducationInDB('a1b2c3d4-e5f6-7890-abcd-ef1234567890', mockUpdateData) + + expect(api.patch).toHaveBeenCalledWith('/resume/base/education/a1b2c3d4-e5f6-7890-abcd-ef1234567890/', mockUpdateData) + expect(result).toEqual(mockUpdatedEducation) + }) + + it('should handle API errors through error handler', async () => { + const mockUpdateData: Partial = { + description: 'Updated description' + } + + const apiError = new Error('Update failed') + vi.mocked(api.patch).mockRejectedValue(apiError) + vi.mocked(handleErrors).mockImplementation(() => { + throw new Error('Handled update error') + }) + + await expect(updateEducationInDB('a1b2c3d4-e5f6-7890-abcd-ef1234567890', mockUpdateData)).rejects.toThrow('Handled update error') + }) + + it('should handle partial updates', async () => { + const partialUpdate: Partial = { + description: 'Updated description only' + } + + const mockPartialResponse: Education = { + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + user: 'user-123', + resume_section: 'b2c3d4e5-f6g7-8901-bcde-f12345678901', + institution_name: 'Harvard University', + field_of_study: 'Computer Science', + degree: 'Bachelor of Science', + country: { + code: 'USA', + name: 'United States' + }, + started_from_month: 9, + started_from_year: 2018, + finished_at_month: 5, + finished_at_year: 2022, + current: false, + description: 'Updated description only', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + } + + vi.mocked(api.patch).mockResolvedValue(mockPartialResponse) + + const result = await updateEducationInDB('a1b2c3d4-e5f6-7890-abcd-ef1234567890', partialUpdate) + + expect(result.description).toBe('Updated description only') + }) + }) + + describe('deleteEducationFromDB', () => { + it('should successfully delete education from database', async () => { + vi.mocked(api.delete).mockResolvedValue(undefined) + + await deleteEducationFromDB('a1b2c3d4-e5f6-7890-abcd-ef1234567890') + + expect(api.delete).toHaveBeenCalledWith('/resume/base/education/a1b2c3d4-e5f6-7890-abcd-ef1234567890/') + }) + + it('should handle API errors through error handler', async () => { + const apiError = new Error('Delete failed') + vi.mocked(api.delete).mockRejectedValue(apiError) + vi.mocked(handleErrors).mockImplementation(() => { + throw new Error('Handled delete error') + }) + + await expect(deleteEducationFromDB('a1b2c3d4-e5f6-7890-abcd-ef1234567890')).rejects.toThrow('Handled delete error') + }) + + it('should handle education deletion with custom resume ID', async () => { + vi.mocked(api.delete).mockResolvedValue(undefined) + + await deleteEducationFromDB('a1b2c3d4-e5f6-7890-abcd-ef1234567890') + + expect(api.delete).toHaveBeenCalledWith('/resume/base/education/a1b2c3d4-e5f6-7890-abcd-ef1234567890/') + }) + }) + + describe('getEducationsFromDB', () => { + it('should successfully retrieve educations from database', async () => { + const mockEducations: Education[] = [ + { + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + user: 'user-123', + resume_section: 'b2c3d4e5-f6g7-8901-bcde-f12345678901', + institution_name: 'Harvard University', + field_of_study: 'Computer Science', + degree: 'Bachelor of Science', + country: { + code: 'USA', + name: 'United States' + }, + started_from_month: 9, + started_from_year: 2018, + finished_at_month: 5, + finished_at_year: 2022, + current: false, + description: 'Studied computer science fundamentals', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + }, + { + id: 'c3d4e5f6-g7h8-9012-cdef-g23456789012', + user: 'user-123', + resume_section: 'b2c3d4e5-f6g7-8901-bcde-f12345678901', + institution_name: 'MIT', + field_of_study: 'Artificial Intelligence', + degree: 'Master of Science', + country: { + code: 'USA', + name: 'United States' + }, + started_from_month: 9, + started_from_year: 2022, + finished_at_month: null, + finished_at_year: null, + current: true, + description: 'Currently studying AI', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + } + ] + + vi.mocked(api.get).mockResolvedValue(mockEducations) + + const result = await getEducationsFromDB() + + expect(api.get).toHaveBeenCalledWith('/resume/base/education/') + expect(result).toEqual(mockEducations) + }) + + it('should handle API errors through error handler', async () => { + const apiError = new Error('Failed to fetch educations') + vi.mocked(api.get).mockRejectedValue(apiError) + vi.mocked(handleErrors).mockImplementation(() => { + throw new Error('Handled fetch error') + }) + + await expect(getEducationsFromDB()).rejects.toThrow('Handled fetch error') + }) + + it('should handle mixed current and completed educations', async () => { + const mixedEducations: Education[] = [ + { + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + user: 'user-123', + resume_section: 'b2c3d4e5-f6g7-8901-bcde-f12345678901', + institution_name: 'Harvard University', + field_of_study: 'Computer Science', + degree: 'Bachelor of Science', + country: { + code: 'USA', + name: 'United States' + }, + started_from_month: 9, + started_from_year: 2018, + finished_at_month: 5, + finished_at_year: 2022, + current: false, + description: 'Completed degree', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + }, + { + id: 'c3d4e5f6-g7h8-9012-cdef-g23456789012', + user: 'user-123', + resume_section: 'b2c3d4e5-f6g7-8901-bcde-f12345678901', + institution_name: 'MIT', + field_of_study: 'Artificial Intelligence', + degree: 'PhD', + country: { + code: 'USA', + name: 'United States' + }, + started_from_month: 9, + started_from_year: 2022, + finished_at_month: null, + finished_at_year: null, + current: true, + description: 'Currently pursuing PhD', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + } + ] + + vi.mocked(api.get).mockResolvedValue(mixedEducations) + + const result = await getEducationsFromDB() + + expect(result).toHaveLength(2) + expect(result[0].current).toBe(false) + expect(result[1].current).toBe(true) + }) + + it('should handle education retrieval with custom resume ID', async () => { + const mockEducations: Education[] = [] + + vi.mocked(api.get).mockResolvedValue(mockEducations) + + const result = await getEducationsFromDB() + + expect(api.get).toHaveBeenCalledWith('/resume/base/education/') + expect(result).toEqual(mockEducations) + }) + }) + + describe('education actions integration', () => { + it('should handle complete CRUD lifecycle', async () => { + // Create + const newEducation: EducationMutation = { + institution_name: 'Stanford University', + field_of_study: 'Machine Learning', + country: 'USA', + started_from_month: '9', + started_from_year: '2020', + current: false, + description: 'Studying ML fundamentals' + } + + const createdEducation: Education = { + id: 'd4e5f6g7-h8i9-0123-def0-h34567890123', + user: 'user-123', + resume_section: 'e5f6g7h8-i9j0-1234-e0f1-i45678901234', + institution_name: 'Stanford University', + field_of_study: 'Machine Learning', + country: { + code: 'USA', + name: 'United States' + }, + started_from_month: 9, + started_from_year: 2020, + finished_at_month: null, + finished_at_year: null, + current: false, + description: 'Studying ML fundamentals', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + } + + vi.mocked(api.post).mockResolvedValue(createdEducation) + const created = await addEducationToDB(newEducation) + expect(created.id).toBe('d4e5f6g7-h8i9-0123-def0-h34567890123') + + // Update + const updateData: Partial = { + description: 'Added project details' + } + + const updatedEducation: Education = { + ...createdEducation, + description: 'Added project details' + } + + vi.mocked(api.patch).mockResolvedValue(updatedEducation) + const updated = await updateEducationInDB('d4e5f6g7-h8i9-0123-def0-h34567890123', updateData) + expect(updated.description).toBe('Added project details') + + // Delete + vi.mocked(api.delete).mockResolvedValue(undefined) + await deleteEducationFromDB('d4e5f6g7-h8i9-0123-def0-h34567890123') + expect(api.delete).toHaveBeenCalledWith('/resume/base/education/d4e5f6g7-h8i9-0123-def0-h34567890123/') + }) + }) +}) diff --git a/lib/education/__tests__/mutations.test.ts b/lib/education/__tests__/mutations.test.ts new file mode 100644 index 00000000..b6e22a3a --- /dev/null +++ b/lib/education/__tests__/mutations.test.ts @@ -0,0 +1,108 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' + +// Mock the mutations module +vi.mock('@/lib/education/mutations', () => ({ + useAddEducationMutation: vi.fn(), + useUpdateEducationMutation: vi.fn(), + useDeleteEducationMutation: vi.fn() +})) + +// Mock the actions +vi.mock('@/lib/education/actions') + +describe('Education Mutations', () => { + beforeEach(async () => { + vi.clearAllMocks() + + // Setup default mock implementations + const mutations = vi.mocked(await import('@/lib/education/mutations')) + + mutations.useAddEducationMutation.mockReturnValue({ + mutateAsync: vi.fn(() => Promise.resolve()), + isPending: false, + isError: false, + error: null + } as any) + + mutations.useUpdateEducationMutation.mockReturnValue({ + mutateAsync: vi.fn(() => Promise.resolve()), + isPending: false, + isError: false, + error: null + } as any) + + mutations.useDeleteEducationMutation.mockReturnValue({ + mutateAsync: vi.fn(() => Promise.resolve()), + isPending: false, + isError: false, + error: null + } as any) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('useAddEducationMutation', () => { + it('should be a function', async () => { + const {useAddEducationMutation} = await import('@/lib/education/mutations') + expect(typeof useAddEducationMutation).toBe('function') + }) + + it('should be mockable', async () => { + const {useAddEducationMutation} = vi.mocked(await import('@/lib/education/mutations')) + + expect(useAddEducationMutation).toHaveBeenCalledTimes(0) + + // Call the mocked function + const result = useAddEducationMutation() + + expect(useAddEducationMutation).toHaveBeenCalledTimes(1) + expect(result).toBeDefined() + expect(result.mutateAsync).toBeDefined() + expect(result.isPending).toBe(false) + }) + }) + + describe('useUpdateEducationMutation', () => { + it('should be a function', async () => { + const {useUpdateEducationMutation} = await import('@/lib/education/mutations') + expect(typeof useUpdateEducationMutation).toBe('function') + }) + + it('should be mockable', async () => { + const {useUpdateEducationMutation} = vi.mocked(await import('@/lib/education/mutations')) + + expect(useUpdateEducationMutation).toHaveBeenCalledTimes(0) + + // Call the mocked function + const result = useUpdateEducationMutation() + + expect(useUpdateEducationMutation).toHaveBeenCalledTimes(1) + expect(result).toBeDefined() + expect(result.mutateAsync).toBeDefined() + expect(result.isPending).toBe(false) + }) + }) + + describe('useDeleteEducationMutation', () => { + it('should be a function', async () => { + const {useDeleteEducationMutation} = await import('@/lib/education/mutations') + expect(typeof useDeleteEducationMutation).toBe('function') + }) + + it('should be mockable', async () => { + const {useDeleteEducationMutation} = vi.mocked(await import('@/lib/education/mutations')) + + expect(useDeleteEducationMutation).toHaveBeenCalledTimes(0) + + // Call the mocked function + const result = useDeleteEducationMutation() + + expect(useDeleteEducationMutation).toHaveBeenCalledTimes(1) + expect(result).toBeDefined() + expect(result.mutateAsync).toBeDefined() + expect(result.isPending).toBe(false) + }) + }) +}) diff --git a/lib/education/__tests__/queries.test.ts b/lib/education/__tests__/queries.test.ts new file mode 100644 index 00000000..26afd0f6 --- /dev/null +++ b/lib/education/__tests__/queries.test.ts @@ -0,0 +1,124 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {educationOptions, useCurrentEducations} from '@/lib/education/queries' +import {getEducationsFromDB} from '@/lib/education/actions' +import {EDUCATION_KEYS} from '@/lib/education/keys' +import {Education} from '@/lib/education/types' + +// Mock the actions +vi.mock('../actions') + +const mockGetEducationsFromDB = vi.mocked(getEducationsFromDB) + +// Mock data +const mockEducations: Education[] = [ + { + id: '123e4567-e89b-12d3-a456-426614174000', + user: 'user-123', + resume_section: '123e4567-e89b-12d3-a456-426614174001', + institution_name: 'Harvard University', + field_of_study: 'Computer Science', + degree: 'Bachelor of Science', + country: { + code: 'USA', + name: 'United States' + }, + started_from_month: 9, + started_from_year: 2018, + finished_at_month: 5, + finished_at_year: 2022, + current: false, + description: '

Studied computer science fundamentals

', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + }, + { + id: '123e4567-e89b-12d3-a456-426614174002', + user: 'user-123', + resume_section: '123e4567-e89b-12d3-a456-426614174003', + institution_name: 'MIT', + field_of_study: 'Artificial Intelligence', + degree: 'Master of Science', + country: { + code: 'USA', + name: 'United States' + }, + started_from_month: 9, + started_from_year: 2022, + finished_at_month: null, + finished_at_year: null, + current: true, + description: '

Currently studying AI

', + created_at: '2023-09-01T00:00:00Z', + updated_at: '2023-09-01T00:00:00Z' + } +] + +describe('Education Queries', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('educationOptions', () => { + it('should have correct query key', () => { + const options = educationOptions + + expect(options.queryKey).toEqual(EDUCATION_KEYS) + }) + + it('should have correct query function', () => { + const options = educationOptions + + expect(options.queryFn).toBeInstanceOf(Function) + }) + + it('should call getEducationsFromDB with "base" when query function is executed', async () => { + mockGetEducationsFromDB.mockResolvedValue(mockEducations) + + const mockContext = { + queryKey: EDUCATION_KEYS, + client: {} as any, + signal: {} as AbortSignal, + meta: undefined + } + + const result = await educationOptions.queryFn!(mockContext) + + expect(mockGetEducationsFromDB).toHaveBeenCalledTimes(1) + expect(mockGetEducationsFromDB).toHaveBeenCalledWith('base') + expect(result).toEqual(mockEducations) + }) + + it('should handle query function errors', async () => { + const error = new Error('API Error') + mockGetEducationsFromDB.mockRejectedValue(error) + + const mockContext = { + queryKey: EDUCATION_KEYS, + client: {} as any, + signal: {} as AbortSignal, + meta: undefined + } + + await expect(educationOptions.queryFn!(mockContext)).rejects.toThrow('API Error') + expect(mockGetEducationsFromDB).toHaveBeenCalledWith('base') + }) + }) + + describe('useCurrentEducations', () => { + it('should use the correct query options', () => { + const hook = useCurrentEducations + + // Test that the hook is a function + expect(typeof hook).toBe('function') + }) + + it('should be available for import', () => { + expect(useCurrentEducations).toBeDefined() + expect(typeof useCurrentEducations).toBe('function') + }) + }) +}) diff --git a/lib/education/actions.ts b/lib/education/actions.ts index 73710639..ff224088 100644 --- a/lib/education/actions.ts +++ b/lib/education/actions.ts @@ -12,11 +12,12 @@ import {handleErrors} from '@/lib//misc/error-handler' * @throws {Error} If validation, authentication, or API request fails. */ export const addEducationToDB = async ( - educationValues: EducationMutation + educationValues: EducationMutation, + resumeId: string = 'base' ): Promise => { try { const params = EducationMutationSchema.parse(educationValues) - const data = await api.post('/resume/base/education/', params) + const data = await api.post(`/resume/${resumeId}/education/`, params) return EducationSchema.parse(data) } catch (error) { return handleErrors(error, 'add education') diff --git a/lib/education/mutations.ts b/lib/education/mutations.ts index d81a9c63..420e3f30 100644 --- a/lib/education/mutations.ts +++ b/lib/education/mutations.ts @@ -1,18 +1,18 @@ import {MutationOptions, useMutation} from '@tanstack/react-query' import {Education, EducationMutation} from '@/lib/education/types' -import {deleteEducationFromDB, addEducationToDB, updateEducationInDB} from '@/lib/education/actions' +import {addEducationToDB, deleteEducationFromDB, updateEducationInDB} from '@/lib/education/actions' -export const useAddEducationMutation = (options?: MutationOptions) => useMutation({ - mutationFn: addEducationToDB, +export const useAddEducationMutation = (options?: MutationOptions) => useMutation({ + mutationFn: ({data, resumeId}) => addEducationToDB(data, resumeId || 'base'), ...options }) -export const useUpdateEducationMutation = (options?: MutationOptions}>) => useMutation({ - mutationFn: ({id, data}) => updateEducationInDB(id, data), +export const useUpdateEducationMutation = (options?: MutationOptions, resumeId?: string}>) => useMutation({ + mutationFn: ({id, data, resumeId}) => updateEducationInDB(id, data, resumeId || 'base'), ...options }) -export const useDeleteEducationMutation = (options?:MutationOptions) => useMutation({ - mutationFn: (id) => deleteEducationFromDB(id, 'base'), +export const useDeleteEducationMutation = (options?:MutationOptions) => useMutation({ + mutationFn: ({id, resumeId}) => deleteEducationFromDB(id, resumeId || 'base'), ...options }) diff --git a/lib/education/queries.ts b/lib/education/queries.ts index b49adf15..a5f3f51a 100644 --- a/lib/education/queries.ts +++ b/lib/education/queries.ts @@ -2,10 +2,12 @@ import {queryOptions, useQuery} from '@tanstack/react-query' import {EDUCATION_KEYS} from '@/lib/education/keys' import {getEducationsFromDB} from '@/lib/education/actions' -export const educationOptions = queryOptions({ - queryKey: EDUCATION_KEYS, - queryFn: () => getEducationsFromDB('base') +export const educationOptions = (resumeId: string = 'base') => queryOptions({ + queryKey: [...EDUCATION_KEYS, resumeId], + queryFn: () => getEducationsFromDB(resumeId) }) -export const useCurrentEducations = () => useQuery(educationOptions) +export const useCurrentEducations = (resumeId: string = 'base') => { + return useQuery(educationOptions(resumeId)) +} diff --git a/lib/experience/__tests__/actions.test.ts b/lib/experience/__tests__/actions.test.ts new file mode 100644 index 00000000..e856f2a1 --- /dev/null +++ b/lib/experience/__tests__/actions.test.ts @@ -0,0 +1,513 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import { + addExperienceToDB, + deleteExperienceFromDB, + getExperiencesFromDB, + updateExperienceInDB +} from '@/lib/experience/actions' +import {api} from '@/lib/config/api-client' +import {handleErrors} from '@/lib/misc/error-handler' +import {Experience, ExperienceMutation} from '@/lib/experience/types' + +// Mock dependencies +vi.mock('@/lib/config/api-client') +vi.mock('@/lib/misc/error-handler') + +const mockApi = vi.mocked(api) +const mockHandleErrors = vi.mocked(handleErrors) + +describe('Experience Actions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + describe('addExperienceToDB', () => { + const mockExperienceData: ExperienceMutation = { + company_name: 'Tech Corp', + job_title: 'Senior Software Engineer', + country: 'USA', + city: 'San Francisco', + employment_type: 'flt', + started_from_month: '1', + started_from_year: '2022', + finished_at_month: '12', + finished_at_year: '2023', + current: false, + description: '

Led development of web applications using React and Node.js.

' + } + + const mockCreatedExperience: Experience = { + id: '123e4567-e89b-12d3-a456-426614174000', + user: 'user_123', + resume_section: '123e4567-e89b-12d3-a456-426614174001', + company_name: 'Tech Corp', + job_title: 'Senior Software Engineer', + country: { + code: 'USA', + name: 'United States' + }, + city: 'San Francisco', + employment_type: 'flt', + started_from_month: 1, + started_from_year: 2022, + finished_at_month: 12, + finished_at_year: 2023, + current: false, + description: '

Led development of web applications using React and Node.js.

', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z' + } + + it('should add experience to database successfully', async () => { + mockApi.post.mockResolvedValue(mockCreatedExperience) + + const result = await addExperienceToDB(mockExperienceData) + + expect(mockApi.post).toHaveBeenCalledWith('/resume/base/experience/', mockExperienceData) + expect(result).toEqual(mockCreatedExperience) + }) + + it('should validate input data with schema', async () => { + const invalidData: ExperienceMutation = { + company_name: '', // Invalid: empty string + job_title: 'Engineer', + country: 'US', // Invalid: should be 3-letter code + current: false, + employment_type: 'flt' + } + + mockApi.post.mockResolvedValue(mockCreatedExperience) + vi.mocked(handleErrors).mockImplementation(() => { + throw new Error('Handled validation error') + }) + + // The schema validation should catch this before API call + await expect(addExperienceToDB(invalidData)).rejects.toThrow('Handled validation error') + }) + + it('should handle API errors', async () => { + const error = new Error('API Error') + mockApi.post.mockRejectedValue(error) + mockHandleErrors.mockImplementation(() => { + throw new Error('Failed to add experience: API Error') + }) + + await expect(addExperienceToDB(mockExperienceData)).rejects.toThrow('Failed to add experience: API Error') + expect(mockHandleErrors).toHaveBeenCalledWith(error, 'add experience') + }) + + it('should handle current job (no end date)', async () => { + const currentJobData: ExperienceMutation = { + ...mockExperienceData, + current: true, + finished_at_month: null, + finished_at_year: null + } + + const currentJobResponse: Experience = { + ...mockCreatedExperience, + current: true, + finished_at_month: null, + finished_at_year: null + } + + mockApi.post.mockResolvedValue(currentJobResponse) + + const result = await addExperienceToDB(currentJobData) + + expect(result.current).toBe(true) + expect(result.finished_at_month).toBeNull() + expect(result.finished_at_year).toBeNull() + }) + + it('should handle different employment types', async () => { + const contractData: ExperienceMutation = { + ...mockExperienceData, + employment_type: 'con' + } + + const contractResponse: Experience = { + ...mockCreatedExperience, + employment_type: 'con' + } + + mockApi.post.mockResolvedValue(contractResponse) + + const result = await addExperienceToDB(contractData) + + expect(result.employment_type).toBe('con') + }) + + it('should handle optional fields', async () => { + const minimalData: ExperienceMutation = { + company_name: 'Startup Inc', + job_title: 'Developer', + country: 'CAN', + employment_type: 'flt', + current: true, + city: null, + description: null, + started_from_month: null, + started_from_year: null, + finished_at_month: null, + finished_at_year: null + } + + const minimalResponse: Experience = { + ...mockCreatedExperience, + company_name: 'Startup Inc', + job_title: 'Developer', + country: { + code: 'CAN', + name: 'Canada' + }, + city: null, + description: null, + current: true, + started_from_month: null, + started_from_year: null, + finished_at_month: null, + finished_at_year: null + } + + mockApi.post.mockResolvedValue(minimalResponse) + + const result = await addExperienceToDB(minimalData) + + expect(result.city).toBeNull() + expect(result.description).toBeNull() + }) + }) + + describe('getExperiencesFromDB', () => { + const mockExperiences: Experience[] = [ + { + id: '123e4567-e89b-12d3-a456-426614174000', + user: 'user_123', + resume_section: '123e4567-e89b-12d3-a456-426614174001', + company_name: 'Tech Corp', + job_title: 'Senior Software Engineer', + country: { + code: 'USA', + name: 'United States' + }, + city: 'San Francisco', + employment_type: 'flt', + started_from_month: 1, + started_from_year: 2022, + finished_at_month: 12, + finished_at_year: 2023, + current: false, + description: '

Led development of web applications.

', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z' + }, + { + id: '123e4567-e89b-12d3-a456-426614174002', + user: 'user_123', + resume_section: '123e4567-e89b-12d3-a456-426614174001', + company_name: 'Startup Inc', + job_title: 'Full Stack Developer', + country: { + code: 'CAN', + name: 'Canada' + }, + city: 'Toronto', + employment_type: 'con', + started_from_month: 6, + started_from_year: 2021, + finished_at_month: 12, + finished_at_year: 2021, + current: false, + description: '

Built mobile applications.

', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z' + } + ] + + it('should fetch experiences with default resumeId', async () => { + mockApi.get.mockResolvedValue(mockExperiences) + + const result = await getExperiencesFromDB() + + expect(mockApi.get).toHaveBeenCalledWith('/resume/base/experience/') + expect(result).toEqual(mockExperiences) + }) + + it('should fetch experiences with custom resumeId', async () => { + mockApi.get.mockResolvedValue(mockExperiences) + + const result = await getExperiencesFromDB('custom-resume-id') + + expect(mockApi.get).toHaveBeenCalledWith('/resume/custom-resume-id/experience/') + expect(result).toEqual(mockExperiences) + }) + + it('should return empty array when no experiences found', async () => { + mockApi.get.mockResolvedValue([]) + + const result = await getExperiencesFromDB() + + expect(result).toEqual([]) + expect(Array.isArray(result)).toBe(true) + }) + + it('should validate response data with schema', async () => { + mockApi.get.mockResolvedValue(mockExperiences) + + const result = await getExperiencesFromDB() + + expect(result).toHaveLength(2) + expect(result[0]).toMatchObject({ + id: expect.any(String), + company_name: expect.any(String), + job_title: expect.any(String), + country: expect.objectContaining({ + code: expect.any(String), + name: expect.any(String) + }), + employment_type: expect.any(String), + current: expect.any(Boolean) + }) + }) + + it('should handle API errors', async () => { + const error = new Error('API Error') + mockApi.get.mockRejectedValue(error) + mockHandleErrors.mockImplementation(() => { + throw new Error('Failed to fetch experiences: API Error') + }) + + await expect(getExperiencesFromDB()).rejects.toThrow('Failed to fetch experiences: API Error') + expect(mockHandleErrors).toHaveBeenCalledWith(error, 'fetch experiences') + }) + }) + + describe('updateExperienceInDB', () => { + const mockUpdatedExperience: Experience = { + id: '123e4567-e89b-12d3-a456-426614174000', + user: 'user_123', + resume_section: '123e4567-e89b-12d3-a456-426614174001', + company_name: 'Updated Tech Corp', + job_title: 'Lead Software Engineer', + country: { + code: 'USA', + name: 'United States' + }, + city: 'Seattle', + employment_type: 'flt', + started_from_month: 1, + started_from_year: 2022, + finished_at_month: null, + finished_at_year: null, + current: true, + description: '

Updated description with new responsibilities.

', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z' + } + + it('should update experience with partial data', async () => { + const updateData: Partial = { + job_title: 'Lead Software Engineer', + city: 'Seattle', + current: true + } + + mockApi.patch.mockResolvedValue(mockUpdatedExperience) + + const result = await updateExperienceInDB('experience-id', updateData) + + expect(mockApi.patch).toHaveBeenCalledWith('/resume/base/experience/experience-id/', updateData) + expect(result).toEqual(mockUpdatedExperience) + }) + + it('should update experience with custom resumeId', async () => { + const updateData: Partial = { + company_name: 'Updated Tech Corp' + } + + mockApi.patch.mockResolvedValue(mockUpdatedExperience) + + const result = await updateExperienceInDB('experience-id', updateData, 'custom-resume-id') + + expect(mockApi.patch).toHaveBeenCalledWith('/resume/custom-resume-id/experience/experience-id/', updateData) + expect(result).toEqual(mockUpdatedExperience) + }) + + it('should update employment type', async () => { + const updateData: Partial = { + employment_type: 'prt' + } + + const partTimeResponse: Experience = { + ...mockUpdatedExperience, + employment_type: 'prt' + } + + mockApi.patch.mockResolvedValue(partTimeResponse) + + const result = await updateExperienceInDB('experience-id', updateData) + + expect(result.employment_type).toBe('prt') + }) + + it('should update date fields', async () => { + const updateData: Partial = { + started_from_month: '6', + started_from_year: '2023', + finished_at_month: '12', + finished_at_year: '2023', + current: false + } + + const dateUpdatedResponse: Experience = { + ...mockUpdatedExperience, + started_from_month: 6, + started_from_year: 2023, + finished_at_month: 12, + finished_at_year: 2023, + current: false + } + + mockApi.patch.mockResolvedValue(dateUpdatedResponse) + + const result = await updateExperienceInDB('experience-id', updateData) + + expect(result.started_from_month).toBe(6) + expect(result.started_from_year).toBe(2023) + expect(result.current).toBe(false) + }) + + it('should clear end dates when setting current to true', async () => { + const updateData: Partial = { + current: true, + finished_at_month: null, + finished_at_year: null + } + + const currentJobResponse: Experience = { + ...mockUpdatedExperience, + current: true, + finished_at_month: null, + finished_at_year: null + } + + mockApi.patch.mockResolvedValue(currentJobResponse) + + const result = await updateExperienceInDB('experience-id', updateData) + + expect(result.current).toBe(true) + expect(result.finished_at_month).toBeNull() + expect(result.finished_at_year).toBeNull() + }) + + it('should update description with rich text', async () => { + const updateData: Partial = { + description: '

New description with bold text and

  • bullet points

' + } + + const richTextResponse: Experience = { + ...mockUpdatedExperience, + description: '

New description with bold text and

  • bullet points

' + } + + mockApi.patch.mockResolvedValue(richTextResponse) + + const result = await updateExperienceInDB('experience-id', updateData) + + expect(result.description).toContain('bold') + expect(result.description).toContain('
  • bullet points
') + }) + + it('should handle API errors', async () => { + const updateData: Partial = { + job_title: 'Updated Title' + } + + const error = new Error('API Error') + mockApi.patch.mockRejectedValue(error) + mockHandleErrors.mockImplementation(() => { + throw new Error('Failed to update experience: API Error') + }) + + await expect(updateExperienceInDB('experience-id', updateData)).rejects.toThrow('Failed to update experience: API Error') + expect(mockHandleErrors).toHaveBeenCalledWith(error, 'update experience') + }) + + it('should validate response data with schema', async () => { + const updateData: Partial = { + job_title: 'Lead Engineer' + } + + mockApi.patch.mockResolvedValue(mockUpdatedExperience) + + const result = await updateExperienceInDB('experience-id', updateData) + + expect(result).toMatchObject({ + id: expect.any(String), + company_name: expect.any(String), + job_title: expect.any(String), + country: expect.objectContaining({ + code: expect.any(String), + name: expect.any(String) + }), + employment_type: expect.any(String), + current: expect.any(Boolean), + created_at: expect.any(String), + updated_at: expect.any(String) + }) + }) + }) + + describe('deleteExperienceFromDB', () => { + it('should delete experience with default resumeId', async () => { + mockApi.delete.mockResolvedValue(undefined) + + await deleteExperienceFromDB('experience-id') + + expect(mockApi.delete).toHaveBeenCalledWith('/resume/base/experience/experience-id/') + }) + + it('should delete experience with custom resumeId', async () => { + mockApi.delete.mockResolvedValue(undefined) + + await deleteExperienceFromDB('experience-id', 'custom-resume-id') + + expect(mockApi.delete).toHaveBeenCalledWith('/resume/custom-resume-id/experience/experience-id/') + }) + + it('should handle API errors', async () => { + const error = new Error('API Error') + mockApi.delete.mockRejectedValue(error) + mockHandleErrors.mockImplementation(() => { + throw new Error('Failed to delete experience: API Error') + }) + + await expect(deleteExperienceFromDB('experience-id')).rejects.toThrow('Failed to delete experience: API Error') + expect(mockHandleErrors).toHaveBeenCalledWith(error, 'delete experience') + }) + + it('should handle 404 errors gracefully', async () => { + const notFoundError = new Error('Experience not found') + mockApi.delete.mockRejectedValue(notFoundError) + mockHandleErrors.mockImplementation(() => { + throw new Error('Failed to delete experience: Experience not found') + }) + + await expect(deleteExperienceFromDB('non-existent-id')).rejects.toThrow('Failed to delete experience: Experience not found') + expect(mockHandleErrors).toHaveBeenCalledWith(notFoundError, 'delete experience') + }) + + it('should not return any value on successful deletion', async () => { + mockApi.delete.mockResolvedValue(undefined) + + const result = await deleteExperienceFromDB('experience-id') + + expect(result).toBeUndefined() + }) + }) +}) diff --git a/lib/experience/__tests__/mutations.test.ts b/lib/experience/__tests__/mutations.test.ts new file mode 100644 index 00000000..cda6886d --- /dev/null +++ b/lib/experience/__tests__/mutations.test.ts @@ -0,0 +1,108 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' + +// Mock the mutations module +vi.mock('@/lib/experience/mutations', () => ({ + useAddUserExperienceMutation: vi.fn(), + useUpdateExperienceMutation: vi.fn(), + useDeleteExperienceMutation: vi.fn() +})) + +// Mock the actions +vi.mock('@/lib/experience/actions') + +describe('Experience Mutations', () => { + beforeEach(async () => { + vi.clearAllMocks() + + // Setup default mock implementations + const mutations = vi.mocked(await import('@/lib/experience/mutations')) + + mutations.useAddUserExperienceMutation.mockReturnValue({ + mutateAsync: vi.fn(() => Promise.resolve()), + isPending: false, + isError: false, + error: null + } as any) + + mutations.useUpdateExperienceMutation.mockReturnValue({ + mutateAsync: vi.fn(() => Promise.resolve()), + isPending: false, + isError: false, + error: null + } as any) + + mutations.useDeleteExperienceMutation.mockReturnValue({ + mutateAsync: vi.fn(() => Promise.resolve()), + isPending: false, + isError: false, + error: null + } as any) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('useAddUserExperienceMutation', () => { + it('should be a function', async () => { + const {useAddUserExperienceMutation} = await import('@/lib/experience/mutations') + expect(typeof useAddUserExperienceMutation).toBe('function') + }) + + it('should be mockable', async () => { + const {useAddUserExperienceMutation} = vi.mocked(await import('@/lib/experience/mutations')) + + expect(useAddUserExperienceMutation).toHaveBeenCalledTimes(0) + + // Call the mocked function + const result = useAddUserExperienceMutation() + + expect(useAddUserExperienceMutation).toHaveBeenCalledTimes(1) + expect(result).toBeDefined() + expect(result.mutateAsync).toBeDefined() + expect(result.isPending).toBe(false) + }) + }) + + describe('useUpdateExperienceMutation', () => { + it('should be a function', async () => { + const {useUpdateExperienceMutation} = await import('@/lib/experience/mutations') + expect(typeof useUpdateExperienceMutation).toBe('function') + }) + + it('should be mockable', async () => { + const {useUpdateExperienceMutation} = vi.mocked(await import('@/lib/experience/mutations')) + + expect(useUpdateExperienceMutation).toHaveBeenCalledTimes(0) + + // Call the mocked function + const result = useUpdateExperienceMutation() + + expect(useUpdateExperienceMutation).toHaveBeenCalledTimes(1) + expect(result).toBeDefined() + expect(result.mutateAsync).toBeDefined() + expect(result.isPending).toBe(false) + }) + }) + + describe('useDeleteExperienceMutation', () => { + it('should be a function', async () => { + const {useDeleteExperienceMutation} = await import('@/lib/experience/mutations') + expect(typeof useDeleteExperienceMutation).toBe('function') + }) + + it('should be mockable', async () => { + const {useDeleteExperienceMutation} = vi.mocked(await import('@/lib/experience/mutations')) + + expect(useDeleteExperienceMutation).toHaveBeenCalledTimes(0) + + // Call the mocked function + const result = useDeleteExperienceMutation() + + expect(useDeleteExperienceMutation).toHaveBeenCalledTimes(1) + expect(result).toBeDefined() + expect(result.mutateAsync).toBeDefined() + expect(result.isPending).toBe(false) + }) + }) +}) diff --git a/lib/experience/__tests__/queries.test.ts b/lib/experience/__tests__/queries.test.ts new file mode 100644 index 00000000..7c421220 --- /dev/null +++ b/lib/experience/__tests__/queries.test.ts @@ -0,0 +1,126 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {experienceQueryOptions, useCurrentExperiences} from '@/lib/experience/queries' +import {getExperiencesFromDB} from '@/lib/experience/actions' +import {EXPERIENCE_KEYS} from '@/lib/experience/keys' +import {Experience} from '@/lib/experience/types' + +// Mock the actions +vi.mock('../actions') + +const mockGetExperiencesFromDB = vi.mocked(getExperiencesFromDB) + +// Mock data +const mockExperiences: Experience[] = [ + { + id: '123e4567-e89b-12d3-a456-426614174000', + user: 'user-123', + resume_section: '123e4567-e89b-12d3-a456-426614174001', + company_name: 'Tech Corp', + job_title: 'Software Engineer', + country: { + code: 'USA', + name: 'United States' + }, + city: 'San Francisco', + employment_type: 'Full-time', + started_from_month: 6, + started_from_year: 2020, + finished_at_month: 8, + finished_at_year: 2023, + current: false, + description: '

Developed web applications using React and Node.js

', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + }, + { + id: '123e4567-e89b-12d3-a456-426614174002', + user: 'user-123', + resume_section: '123e4567-e89b-12d3-a456-426614174003', + company_name: 'Startup Inc', + job_title: 'Senior Developer', + country: { + code: 'CAN', + name: 'Canada' + }, + city: 'Toronto', + employment_type: 'Full-time', + started_from_month: 9, + started_from_year: 2023, + finished_at_month: null, + finished_at_year: null, + current: true, + description: '

Leading development of mobile applications

', + created_at: '2023-09-01T00:00:00Z', + updated_at: '2023-09-01T00:00:00Z' + } +] + +describe('Experience Queries', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('experienceQueryOptions', () => { + it('should have correct query key', () => { + const options = experienceQueryOptions + + expect(options.queryKey).toEqual(EXPERIENCE_KEYS) + }) + + it('should have correct query function', () => { + const options = experienceQueryOptions + + expect(options.queryFn).toBeInstanceOf(Function) + }) + + it('should call getExperiencesFromDB with "base" when query function is executed', async () => { + mockGetExperiencesFromDB.mockResolvedValue(mockExperiences) + + const mockContext = { + queryKey: EXPERIENCE_KEYS, + client: {} as any, + signal: {} as AbortSignal, + meta: undefined + } + + const result = await experienceQueryOptions.queryFn!(mockContext) + + expect(mockGetExperiencesFromDB).toHaveBeenCalledTimes(1) + expect(mockGetExperiencesFromDB).toHaveBeenCalledWith('base') + expect(result).toEqual(mockExperiences) + }) + + it('should handle query function errors', async () => { + const error = new Error('API Error') + mockGetExperiencesFromDB.mockRejectedValue(error) + + const mockContext = { + queryKey: EXPERIENCE_KEYS, + client: {} as any, + signal: {} as AbortSignal, + meta: undefined + } + + await expect(experienceQueryOptions.queryFn!(mockContext)).rejects.toThrow('API Error') + expect(mockGetExperiencesFromDB).toHaveBeenCalledWith('base') + }) + }) + + describe('useCurrentExperiences', () => { + it('should use the correct query options', () => { + const hook = useCurrentExperiences + + // Test that the hook is a function + expect(typeof hook).toBe('function') + }) + + it('should be available for import', () => { + expect(useCurrentExperiences).toBeDefined() + expect(typeof useCurrentExperiences).toBe('function') + }) + }) +}) diff --git a/lib/experience/actions.ts b/lib/experience/actions.ts index 9ba73d63..b1d42f92 100644 --- a/lib/experience/actions.ts +++ b/lib/experience/actions.ts @@ -11,10 +11,13 @@ import {handleErrors} from '@/lib/misc/error-handler' * @returns {Promise} - The updated experience object * @throws {Error} If validation, authentication, or API request fails. */ -export const addExperienceToDB = async (experienceValues: ExperienceMutation): Promise => { +export const addExperienceToDB = async ( + experienceValues: ExperienceMutation, + resumeId: string = 'base' +): Promise => { try { const params = ExperienceMutationSchema.parse(experienceValues) - const data = await api.post('/resume/base/experience/', params) + const data = await api.post(`/resume/${resumeId}/experience/`, params) return ExperienceSchema.parse(data) } catch (error) { return handleErrors(error, 'add experience') diff --git a/lib/experience/mutations.ts b/lib/experience/mutations.ts index bf0ad7d5..f9fd0dea 100644 --- a/lib/experience/mutations.ts +++ b/lib/experience/mutations.ts @@ -1,18 +1,24 @@ import {Experience, ExperienceMutation} from '@/lib/experience/types' import {MutationOptions, useMutation} from '@tanstack/react-query' -import {deleteExperienceFromDB, addExperienceToDB, updateExperienceInDB} from '@/lib/experience/actions' +import {addExperienceToDB, deleteExperienceFromDB, updateExperienceInDB} from '@/lib/experience/actions' -export const useAddUserExperienceMutation = (options?: MutationOptions) => useMutation({ - mutationFn: addExperienceToDB, +export const useAddUserExperienceMutation = (options?: MutationOptions) => useMutation({ + mutationFn: ({data, resumeId}) => { + return addExperienceToDB(data, resumeId || 'base') + }, ...options }) -export const useUpdateExperienceMutation = (options?: MutationOptions}>) => useMutation({ - mutationFn: ({id, data}) => updateExperienceInDB(id, data), +export const useUpdateExperienceMutation = (options?: MutationOptions, resumeId?: string}>) => useMutation({ + mutationFn: ({id, data, resumeId}) => { + return updateExperienceInDB(id, data, resumeId || 'base') + }, ...options }) -export const useDeleteExperienceMutation = (options?:MutationOptions) => useMutation({ - mutationFn: (experienceId) => deleteExperienceFromDB(experienceId, 'base'), +export const useDeleteExperienceMutation = (options?:MutationOptions) => useMutation({ + mutationFn: ({id, resumeId}) => { + return deleteExperienceFromDB(id, resumeId || 'base') + }, ...options }) diff --git a/lib/experience/queries.ts b/lib/experience/queries.ts index bf29c5ba..af71ce68 100644 --- a/lib/experience/queries.ts +++ b/lib/experience/queries.ts @@ -1,11 +1,13 @@ import {queryOptions, useQuery} from '@tanstack/react-query' -import {EXPERIENCE_KEYS} from './keys' -import {getExperiencesFromDB} from './actions' +import {EXPERIENCE_KEYS} from '@/lib/experience/keys' +import {getExperiencesFromDB} from '@/lib/experience/actions' -export const experienceQueryOptions = queryOptions({ - queryKey: EXPERIENCE_KEYS, - queryFn: () => getExperiencesFromDB('base') +export const experienceQueryOptions = (resumeId: string = 'base') => queryOptions({ + queryKey: [...EXPERIENCE_KEYS, resumeId], + queryFn: () => getExperiencesFromDB(resumeId) }) -export const useCurrentExperiences = () => useQuery(experienceQueryOptions) +export const useCurrentExperiences = (resumeId?: string) => { + return useQuery(experienceQueryOptions(resumeId)) +} diff --git a/lib/job/types.ts b/lib/job/types.ts index aeef529c..1daaf00f 100644 --- a/lib/job/types.ts +++ b/lib/job/types.ts @@ -4,18 +4,18 @@ import {z} from 'zod' * Base schema for Job * Check https://outline.letraz.app/api-reference/job-object/get-job-by-job-id for more information */ +const stringOrStringArray = z.union([z.string(), z.array(z.string())]).transform((val) => Array.isArray(val) ? val.join('\n') : val) + export const JobSchema = z.object({ job_url: z.string().describe('The URL of the job posting.'), title: z.string().describe('The title of the job position.'), company_name: z.string().describe('The name of the company offering the job.'), - location: z.string().describe('The location where the job is based.'), - currency: z.string().describe('The currency in which the salary is paid.'), - salary_max: z.number().nullable().describe('The maximum salary for the job position. Nullable if not specified.'), - salary_min: z.number().nullable().describe('The minimum salary for the job position. Nullable if not specified.'), - requirements: z.array(z.string().nullable().describe('The requirements for the job position. Nullable if not specified.')), - description: z.string().describe('The description of the job position.'), - responsibilities: z.array(z.string().nullable().describe('The responsibilities associated with the job position. Nullable if not specified.')), - benefits: z.array(z.string().nullable().describe('The benefits provided by the company for the job position. Nullable if not specified.')) + location: z.string().nullable().describe('The location where the job is based.'), + requirements: stringOrStringArray.nullable().describe('The requirements for the job position. Nullable if not specified.'), + description: stringOrStringArray.nullable().describe('The description of the job position.'), + responsibilities: stringOrStringArray.nullable().describe('The responsibilities associated with the job position. Nullable if not specified.'), + benefits: stringOrStringArray.nullable().describe('The benefits provided by the company for the job position. Nullable if not specified.'), + status: z.string().describe('Indicates if the job is currently being processed.').nullable().optional() }) // Infer TypeScript types from the schema diff --git a/lib/misc/__tests__/error-handler.test.ts b/lib/misc/__tests__/error-handler.test.ts new file mode 100644 index 00000000..c5394921 --- /dev/null +++ b/lib/misc/__tests__/error-handler.test.ts @@ -0,0 +1,94 @@ +import {describe, expect, it} from 'vitest' +import {ZodError} from 'zod' +import {handleErrors} from '@/lib/misc/error-handler' + +describe('handleErrors', () => { + it('should handle ZodError with formatted message', () => { + const zodError = new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + path: ['name'], + message: 'Expected string, received number' + }, + { + code: 'too_small', + minimum: 2, + type: 'string', + inclusive: true, + exact: false, + path: ['email'], + message: 'String must contain at least 2 character(s)' + } + ]) + + expect(() => handleErrors(zodError, 'test validation')).toThrow( + 'Validation failed for test validation: Expected string, received number, String must contain at least 2 character(s)' + ) + }) + + it('should handle single ZodError', () => { + const zodError = new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + path: ['name'], + message: 'Expected string, received number' + } + ]) + + expect(() => handleErrors(zodError, 'single validation')).toThrow( + 'Validation failed for single validation: Expected string, received number' + ) + }) + + it('should handle generic Error', () => { + const genericError = new Error('Something went wrong') + + expect(() => handleErrors(genericError, 'generic operation')).toThrow( + 'Failed to generic operation: Something went wrong' + ) + }) + + it('should handle unknown error types', () => { + const unknownError = 'string error' + + expect(() => handleErrors(unknownError, 'unknown operation')).toThrow( + 'An unknown error occurred while trying to unknown operation' + ) + }) + + it('should handle null error', () => { + expect(() => handleErrors(null, 'null operation')).toThrow( + 'An unknown error occurred while trying to null operation' + ) + }) + + it('should handle undefined error', () => { + expect(() => handleErrors(undefined, 'undefined operation')).toThrow( + 'An unknown error occurred while trying to undefined operation' + ) + }) + + it('should handle object error', () => { + const objectError = {message: 'Custom error object'} + + expect(() => handleErrors(objectError, 'object operation')).toThrow( + 'An unknown error occurred while trying to object operation' + ) + }) + + it('should always throw and never return', () => { + let errorThrown = false + + try { + handleErrors(new Error('test'), 'test context') + } catch (error) { + errorThrown = true + } + + expect(errorThrown).toBe(true) + }) +}) diff --git a/lib/onboarding/__tests__/actions.test.ts b/lib/onboarding/__tests__/actions.test.ts new file mode 100644 index 00000000..6d8e681a --- /dev/null +++ b/lib/onboarding/__tests__/actions.test.ts @@ -0,0 +1,586 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import { + completeOnboarding, + getOnboardingState, + progressToNextStep, + resetOnboarding, + updateOnboardingStep +} from '@/lib/onboarding/actions' +import {auth, clerkClient} from '@clerk/nextjs/server' +import {ONBOARDING_STEPS, OnboardingMetadata, OnboardingStep} from '@/lib/onboarding/types' + +// Mock dependencies +vi.mock('@clerk/nextjs/server') +vi.mock('../types', async () => { + const actual = await vi.importActual('../types') + return { + ...actual, + getNextStep: vi.fn() + } +}) + +const mockAuth = vi.mocked(auth) +const mockClerkClient = vi.mocked(clerkClient) +const mockGetNextStep = vi.mocked((await import('../types')).getNextStep) + +// Mock Clerk client methods +const mockClient = { + users: { + getUser: vi.fn(), + updateUser: vi.fn() + } +} + +// Helper function to create proper Auth objects for mocking +const createMockAuthObject = (userId: string | null) => { + if (userId) { + return { + userId, + sessionClaims: {}, + sessionId: 'session_123', + sessionStatus: 'active' as const, + actor: null, + orgId: null, + orgRole: null, + orgSlug: null, + orgPermissions: null, + has: vi.fn(), + redirectToSignIn: vi.fn(), + protect: vi.fn(), + getToken: vi.fn(), + factorVerificationAge: null, + debug: vi.fn() + } as any + } else { + return { + userId: null, + sessionClaims: null, + sessionId: null, + sessionStatus: 'unauthenticated' as const, + actor: null, + orgId: null, + orgRole: null, + orgSlug: null, + orgPermissions: null, + has: vi.fn(), + redirectToSignIn: vi.fn(), + protect: vi.fn(), + getToken: vi.fn(), + factorVerificationAge: null, + debug: vi.fn() + } as any + } +} + +describe('Onboarding Actions', () => { + beforeEach(() => { + vi.clearAllMocks() + mockClerkClient.mockResolvedValue(mockClient as any) + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + describe('updateOnboardingStep', () => { + const mockUser = { + id: 'user_123', + publicMetadata: { + onboardingComplete: false, + currentOnboardingStep: 'welcome' as OnboardingStep, + completedSteps: ['welcome'] as OnboardingStep[] + } + } + + it('should update onboarding step successfully', async () => { + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.getUser.mockResolvedValue(mockUser) + mockClient.users.updateUser.mockResolvedValue({}) + + const result = await updateOnboardingStep('about') + + expect(mockAuth).toHaveBeenCalled() + expect(mockClient.users.getUser).toHaveBeenCalledWith('user_123') + expect(mockClient.users.updateUser).toHaveBeenCalledWith('user_123', { + publicMetadata: { + onboardingComplete: false, + currentOnboardingStep: 'about', + completedSteps: ['welcome', 'about'] + } + }) + expect(result).toEqual({ + onboardingComplete: false, + currentOnboardingStep: 'about', + completedSteps: ['welcome', 'about'] + }) + }) + + it('should not duplicate steps in completedSteps', async () => { + const userWithDuplicateStep = { + ...mockUser, + publicMetadata: { + ...mockUser.publicMetadata, + completedSteps: ['welcome', 'about'] as OnboardingStep[] + } + } + + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.getUser.mockResolvedValue(userWithDuplicateStep) + mockClient.users.updateUser.mockResolvedValue({}) + + const result = await updateOnboardingStep('about') + + expect(mockClient.users.updateUser).toHaveBeenCalledWith('user_123', { + publicMetadata: { + onboardingComplete: false, + currentOnboardingStep: 'about', + completedSteps: ['welcome', 'about'] + } + }) + expect(result.completedSteps).toEqual(['welcome', 'about']) + }) + + it('should handle empty publicMetadata', async () => { + const userWithEmptyMetadata = { + id: 'user_123', + publicMetadata: {} + } + + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.getUser.mockResolvedValue(userWithEmptyMetadata) + mockClient.users.updateUser.mockResolvedValue({}) + + const result = await updateOnboardingStep('welcome') + + expect(mockClient.users.updateUser).toHaveBeenCalledWith('user_123', { + publicMetadata: { + currentOnboardingStep: 'welcome', + completedSteps: ['welcome'] + } + }) + expect(result.completedSteps).toEqual(['welcome']) + }) + + it('should handle null publicMetadata', async () => { + const userWithNullMetadata = { + id: 'user_123', + publicMetadata: null + } + + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.getUser.mockResolvedValue(userWithNullMetadata) + mockClient.users.updateUser.mockResolvedValue({}) + + const result = await updateOnboardingStep('welcome') + + expect(result.completedSteps).toEqual(['welcome']) + }) + + it('should throw error when user is not authenticated', async () => { + mockAuth.mockResolvedValue(createMockAuthObject(null)) + + await expect(updateOnboardingStep('about')).rejects.toThrow('User not authenticated') + expect(mockClient.users.getUser).not.toHaveBeenCalled() + }) + + it('should handle all onboarding steps', async () => { + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.getUser.mockResolvedValue(mockUser) + mockClient.users.updateUser.mockResolvedValue({}) + + const steps: OnboardingStep[] = ['welcome', 'about', 'personal-details', 'education', 'experience', 'resume'] + + for (const step of steps) { + const result = await updateOnboardingStep(step) + expect(result.currentOnboardingStep).toBe(step) + expect(result.completedSteps).toContain(step) + } + }) + + it('should preserve existing metadata properties', async () => { + const userWithExtraMetadata = { + id: 'user_123', + publicMetadata: { + onboardingComplete: false, + currentOnboardingStep: 'welcome' as OnboardingStep, + completedSteps: ['welcome'] as OnboardingStep[] + } + } + + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.getUser.mockResolvedValue(userWithExtraMetadata) + mockClient.users.updateUser.mockResolvedValue({}) + + const result = await updateOnboardingStep('about') + + expect(mockClient.users.updateUser).toHaveBeenCalledWith('user_123', { + publicMetadata: { + onboardingComplete: false, + currentOnboardingStep: 'about', + completedSteps: ['welcome', 'about'] + } + }) + }) + }) + + describe('progressToNextStep', () => { + const mockUser = { + id: 'user_123', + publicMetadata: { + onboardingComplete: false, + currentOnboardingStep: 'welcome' as OnboardingStep, + completedSteps: ['welcome'] as OnboardingStep[] + } + } + + it('should progress to next step successfully', async () => { + mockGetNextStep.mockReturnValue('about' as OnboardingStep) + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.getUser.mockResolvedValue(mockUser) + mockClient.users.updateUser.mockResolvedValue({}) + + const result = await progressToNextStep('welcome') + + expect(mockGetNextStep).toHaveBeenCalledWith('welcome') + expect(result.currentOnboardingStep).toBe('about') + }) + + it('should complete onboarding when no next step exists', async () => { + mockGetNextStep.mockReturnValue(null) + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.getUser.mockResolvedValue(mockUser) + mockClient.users.updateUser.mockResolvedValue({}) + + const result = await progressToNextStep('resume') + + expect(mockGetNextStep).toHaveBeenCalledWith('resume') + expect(result.onboardingComplete).toBe(true) + expect(result.currentOnboardingStep).toBe('resume') + expect(result.completedSteps).toEqual(ONBOARDING_STEPS) + }) + + it('should handle progression through all steps', async () => { + const stepProgression = [ + {current: 'welcome', next: 'about'}, + {current: 'about', next: 'personal-details'}, + {current: 'personal-details', next: 'education'}, + {current: 'education', next: 'experience'}, + {current: 'experience', next: 'resume'}, + {current: 'resume', next: null} + ] + + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.getUser.mockResolvedValue(mockUser) + mockClient.users.updateUser.mockResolvedValue({}) + + for (const {current, next} of stepProgression) { + mockGetNextStep.mockReturnValue(next as OnboardingStep) + + const result = await progressToNextStep(current as OnboardingStep) + + if (next) { + expect(result.currentOnboardingStep).toBe(next) + expect(result.onboardingComplete).toBeFalsy() + } else { + expect(result.onboardingComplete).toBe(true) + } + } + }) + }) + + describe('completeOnboarding', () => { + const mockUser = { + id: 'user_123', + publicMetadata: { + onboardingComplete: false, + currentOnboardingStep: 'experience' as OnboardingStep, + completedSteps: ['welcome', 'about', 'personal-details', 'education', 'experience'] as OnboardingStep[] + } + } + + it('should complete onboarding successfully', async () => { + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.getUser.mockResolvedValue(mockUser) + mockClient.users.updateUser.mockResolvedValue({}) + + const result = await completeOnboarding() + + expect(mockAuth).toHaveBeenCalled() + expect(mockClient.users.getUser).toHaveBeenCalledWith('user_123') + expect(mockClient.users.updateUser).toHaveBeenCalledWith('user_123', { + publicMetadata: { + onboardingComplete: true, + currentOnboardingStep: 'resume', + completedSteps: ONBOARDING_STEPS + } + }) + expect(result).toEqual({ + onboardingComplete: true, + currentOnboardingStep: 'resume', + completedSteps: ONBOARDING_STEPS + }) + }) + + it('should handle empty publicMetadata', async () => { + const userWithEmptyMetadata = { + id: 'user_123', + publicMetadata: {} + } + + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.getUser.mockResolvedValue(userWithEmptyMetadata) + mockClient.users.updateUser.mockResolvedValue({}) + + const result = await completeOnboarding() + + expect(result.onboardingComplete).toBe(true) + expect(result.currentOnboardingStep).toBe('resume') + expect(result.completedSteps).toEqual(ONBOARDING_STEPS) + }) + + it('should preserve existing metadata properties', async () => { + const userWithExtraMetadata = { + id: 'user_123', + publicMetadata: { + ...mockUser.publicMetadata, + customProperty: 'custom_value' + } + } + + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.getUser.mockResolvedValue(userWithExtraMetadata) + mockClient.users.updateUser.mockResolvedValue({}) + + await completeOnboarding() + + expect(mockClient.users.updateUser).toHaveBeenCalledWith('user_123', { + publicMetadata: { + customProperty: 'custom_value', + onboardingComplete: true, + currentOnboardingStep: 'resume', + completedSteps: ONBOARDING_STEPS + } + }) + }) + + it('should throw error when user is not authenticated', async () => { + mockAuth.mockResolvedValue(createMockAuthObject(null)) + + await expect(completeOnboarding()).rejects.toThrow('User not authenticated') + expect(mockClient.users.getUser).not.toHaveBeenCalled() + }) + + it('should handle already completed onboarding', async () => { + const completedUser = { + id: 'user_123', + publicMetadata: { + onboardingComplete: true, + currentOnboardingStep: 'resume' as OnboardingStep, + completedSteps: ONBOARDING_STEPS + } + } + + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.getUser.mockResolvedValue(completedUser) + mockClient.users.updateUser.mockResolvedValue({}) + + const result = await completeOnboarding() + + expect(result.onboardingComplete).toBe(true) + expect(result.completedSteps).toEqual(ONBOARDING_STEPS) + }) + }) + + describe('getOnboardingState', () => { + const mockOnboardingMetadata: OnboardingMetadata = { + onboardingComplete: false, + currentOnboardingStep: 'about', + completedSteps: ['welcome', 'about'] + } + + const mockUser = { + id: 'user_123', + publicMetadata: mockOnboardingMetadata + } + + it('should get onboarding state successfully', async () => { + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.getUser.mockResolvedValue(mockUser) + + const result = await getOnboardingState() + + expect(mockAuth).toHaveBeenCalled() + expect(mockClient.users.getUser).toHaveBeenCalledWith('user_123') + expect(result).toEqual(mockOnboardingMetadata) + }) + + it('should handle empty publicMetadata', async () => { + const userWithEmptyMetadata = { + id: 'user_123', + publicMetadata: {} + } + + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.getUser.mockResolvedValue(userWithEmptyMetadata) + + const result = await getOnboardingState() + + expect(result).toEqual({}) + }) + + it('should handle null publicMetadata', async () => { + const userWithNullMetadata = { + id: 'user_123', + publicMetadata: null + } + + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.getUser.mockResolvedValue(userWithNullMetadata) + + const result = await getOnboardingState() + + expect(result).toBeNull() + }) + + it('should throw error when user is not authenticated', async () => { + mockAuth.mockResolvedValue(createMockAuthObject(null)) + + await expect(getOnboardingState()).rejects.toThrow('User not authenticated') + expect(mockClient.users.getUser).not.toHaveBeenCalled() + }) + + it('should handle completed onboarding state', async () => { + const completedMetadata: OnboardingMetadata = { + onboardingComplete: true, + currentOnboardingStep: 'resume', + completedSteps: ONBOARDING_STEPS + } + + const completedUser = { + id: 'user_123', + publicMetadata: completedMetadata + } + + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.getUser.mockResolvedValue(completedUser) + + const result = await getOnboardingState() + + expect(result).toEqual(completedMetadata) + expect(result.onboardingComplete).toBe(true) + expect(result.completedSteps).toHaveLength(6) + }) + + it('should handle partial onboarding state', async () => { + const partialMetadata: OnboardingMetadata = { + currentOnboardingStep: 'education', + completedSteps: ['welcome', 'about', 'personal-details'] + } + + const partialUser = { + id: 'user_123', + publicMetadata: partialMetadata + } + + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.getUser.mockResolvedValue(partialUser) + + const result = await getOnboardingState() + + expect(result).toEqual(partialMetadata) + expect(result.onboardingComplete).toBeUndefined() + expect(result.completedSteps).toHaveLength(3) + }) + }) + + describe('resetOnboarding', () => { + it('should reset onboarding successfully', async () => { + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.updateUser.mockResolvedValue({}) + + await resetOnboarding() + + expect(mockAuth).toHaveBeenCalled() + expect(mockClient.users.updateUser).toHaveBeenCalledWith('user_123', { + publicMetadata: { + onboardingComplete: false, + currentOnboardingStep: 'welcome', + completedSteps: [] + } + }) + }) + + it('should throw error when user is not authenticated', async () => { + mockAuth.mockResolvedValue(createMockAuthObject(null)) + + await expect(resetOnboarding()).rejects.toThrow('User not authenticated') + expect(mockClient.users.updateUser).not.toHaveBeenCalled() + }) + + it('should reset from any onboarding state', async () => { + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.updateUser.mockResolvedValue({}) + + // Test multiple calls to ensure consistent reset + await resetOnboarding() + await resetOnboarding() + + expect(mockClient.users.updateUser).toHaveBeenCalledTimes(2) + mockClient.users.updateUser.mock.calls.forEach(call => { + expect(call[1]).toEqual({ + publicMetadata: { + onboardingComplete: false, + currentOnboardingStep: 'welcome', + completedSteps: [] + } + }) + }) + }) + + it('should handle Clerk client errors', async () => { + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.updateUser.mockRejectedValue(new Error('Clerk API Error')) + + await expect(resetOnboarding()).rejects.toThrow('Clerk API Error') + }) + }) + + describe('Error Handling', () => { + it('should handle Clerk authentication errors', async () => { + mockAuth.mockRejectedValue(new Error('Auth service unavailable')) + + await expect(updateOnboardingStep('about')).rejects.toThrow('Auth service unavailable') + }) + + it('should handle Clerk client initialization errors', async () => { + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClerkClient.mockRejectedValue(new Error('Clerk client initialization failed')) + + await expect(updateOnboardingStep('about')).rejects.toThrow('Clerk client initialization failed') + }) + + it('should handle user fetch errors', async () => { + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.getUser.mockRejectedValue(new Error('User not found')) + + await expect(updateOnboardingStep('about')).rejects.toThrow('User not found') + }) + + it('should handle user update errors', async () => { + const mockUser = { + id: 'user_123', + publicMetadata: { + onboardingComplete: false, + currentOnboardingStep: 'welcome' as OnboardingStep, + completedSteps: ['welcome'] as OnboardingStep[] + } + } + + mockAuth.mockResolvedValue(createMockAuthObject('user_123')) + mockClient.users.getUser.mockResolvedValue(mockUser) + mockClient.users.updateUser.mockRejectedValue(new Error('Update failed')) + + await expect(updateOnboardingStep('about')).rejects.toThrow('Update failed') + }) + }) +}) diff --git a/lib/onboarding/actions.ts b/lib/onboarding/actions.ts new file mode 100644 index 00000000..fc67aac2 --- /dev/null +++ b/lib/onboarding/actions.ts @@ -0,0 +1,124 @@ +'use server' + +import {auth, clerkClient} from '@clerk/nextjs/server' +import { + getNextStep, + ONBOARDING_STEPS, + OnboardingMetadata, + OnboardingMetadataSchema, + OnboardingStep +} from '@/lib/onboarding/types' + +/** + * Updates the user's current onboarding step + */ +export const updateOnboardingStep = async (step: OnboardingStep): Promise => { + const {userId} = await auth() + + if (!userId) { + throw new Error('User not authenticated') + } + + const client = await clerkClient() + const user = await client.users.getUser(userId) + + const currentMetadata = OnboardingMetadataSchema.parse(user.publicMetadata || {}) + const completedSteps = currentMetadata.completedSteps || [] + + // Add current step to completed steps if not already there + const updatedCompletedSteps = completedSteps.includes(step) + ? completedSteps + : [...completedSteps, step] + + const updatedMetadata: OnboardingMetadata = { + ...currentMetadata, + currentOnboardingStep: step, + completedSteps: updatedCompletedSteps + } + + await client.users.updateUser(userId, { + publicMetadata: updatedMetadata + }) + + return updatedMetadata +} + +/** + * Progresses to the next onboarding step + */ +export const progressToNextStep = async (currentStep: OnboardingStep): Promise => { + const nextStep = getNextStep(currentStep) + + if (!nextStep) { + // If no next step, complete onboarding + return completeOnboarding() + } + + return updateOnboardingStep(nextStep) +} + +/** + * Marks onboarding as complete + */ +export const completeOnboarding = async (): Promise => { + const {userId} = await auth() + + if (!userId) { + throw new Error('User not authenticated') + } + + const client = await clerkClient() + const user = await client.users.getUser(userId) + + const currentMetadata = user.publicMetadata as OnboardingMetadata + + const completedMetadata: OnboardingMetadata = { + ...currentMetadata, + onboardingComplete: true, + currentOnboardingStep: 'resume', // Final step + completedSteps: ONBOARDING_STEPS // Mark all steps as completed + } + + await client.users.updateUser(userId, { + publicMetadata: completedMetadata + }) + + return completedMetadata +} + +/** + * Gets the current onboarding state for the user + */ +export const getOnboardingState = async (): Promise => { + const {userId} = await auth() + + if (!userId) { + throw new Error('User not authenticated') + } + + const client = await clerkClient() + const user = await client.users.getUser(userId) + + return user.publicMetadata as OnboardingMetadata +} + +/** + * Resets onboarding state (useful for testing) + */ +export const resetOnboarding = async (): Promise => { + const {userId} = await auth() + + if (!userId) { + throw new Error('User not authenticated') + } + + const client = await clerkClient() + + await client.users.updateUser(userId, { + publicMetadata: { + onboardingComplete: false, + currentOnboardingStep: 'welcome', + completedSteps: [] + } + }) +} diff --git a/lib/onboarding/globals.d.ts b/lib/onboarding/globals.d.ts new file mode 100644 index 00000000..15e10fef --- /dev/null +++ b/lib/onboarding/globals.d.ts @@ -0,0 +1,9 @@ +import {OnboardingMetadata} from '@/lib/onboarding/types' + +declare global { + interface CustomJwtSessionClaims { + metadata: OnboardingMetadata + } + + interface UserPublicMetadata extends OnboardingMetadata {} +} diff --git a/lib/onboarding/hooks.ts b/lib/onboarding/hooks.ts new file mode 100644 index 00000000..ffcbd95e --- /dev/null +++ b/lib/onboarding/hooks.ts @@ -0,0 +1,56 @@ +'use client' + +import {useUser} from '@clerk/nextjs' +import {OnboardingMetadata, OnboardingStep} from '@/lib/onboarding/types' +import {ONBOARDING_STEPS} from '@/app/app/onboarding/types' + +/** + * Hook to get the current onboarding state from the user's metadata + */ +export const useOnboardingState = () => { + const {user} = useUser() + + const metadata = user?.publicMetadata as OnboardingMetadata + + return { + onboardingComplete: metadata?.onboardingComplete ?? false, + currentStep: metadata?.currentOnboardingStep ?? 'welcome', + completedSteps: metadata?.completedSteps ?? [], + isLoading: !user + } +} + +/** + * Hook to check if onboarding is complete + */ +export const useOnboardingComplete = () => { + const {onboardingComplete} = useOnboardingState() + return onboardingComplete +} + +/** + * Hook to get the current onboarding step + */ +export const useCurrentOnboardingStep = () => { + const {currentStep} = useOnboardingState() + return currentStep +} + +/** + * Hook to check if a specific step is completed + */ +export const useIsStepCompleted = (step: OnboardingStep) => { + const {completedSteps} = useOnboardingState() + return completedSteps.includes(step) +} + +/** + * Hook to get onboarding progress percentage + */ +export const useOnboardingProgress = () => { + const {completedSteps} = useOnboardingState() + const totalSteps = ONBOARDING_STEPS.length // welcome, about, personal-details, education, experience, resume + const completedCount = completedSteps.length + + return Math.round((completedCount / totalSteps) * 100) +} diff --git a/lib/onboarding/types.ts b/lib/onboarding/types.ts new file mode 100644 index 00000000..97fbc782 --- /dev/null +++ b/lib/onboarding/types.ts @@ -0,0 +1,52 @@ +import {z} from 'zod' + +// Define the available onboarding steps +export const OnboardingStepSchema = z.enum([ + 'welcome', + 'about', + 'personal-details', + 'education', + 'experience', + 'resume' +]) + +export type OnboardingStep = z.infer + +// Schema for onboarding metadata +export const OnboardingMetadataSchema = z.object({ + onboardingComplete: z.boolean().optional(), + currentOnboardingStep: OnboardingStepSchema.optional(), + completedSteps: z.array(OnboardingStepSchema).optional() +}) + +export type OnboardingMetadata = z.infer + +// Step progression mapping +export const ONBOARDING_STEPS: OnboardingStep[] = [ + 'welcome', + 'about', + 'personal-details', + 'education', + 'experience', + 'resume' +] + +// Helper to get next step +export const getNextStep = (currentStep: OnboardingStep): OnboardingStep | null => { + const currentIndex = ONBOARDING_STEPS.indexOf(currentStep) + if (currentIndex === -1) { + throw new Error(`Invalid onboarding step: ${currentStep}`) + } + const nextIndex = currentIndex + 1 + return nextIndex < ONBOARDING_STEPS.length ? ONBOARDING_STEPS[nextIndex] : null +} + +// Helper to get previous step +export const getPreviousStep = (currentStep: OnboardingStep): OnboardingStep | null => { + const currentIndex = ONBOARDING_STEPS.indexOf(currentStep) + if (currentIndex === -1) { + throw new Error(`Invalid onboarding step: ${currentStep}`) + } + const previousIndex = currentIndex - 1 + return previousIndex >= 0 ? ONBOARDING_STEPS[previousIndex] : null +} diff --git a/lib/project/actions.ts b/lib/project/actions.ts new file mode 100644 index 00000000..3cd8553f --- /dev/null +++ b/lib/project/actions.ts @@ -0,0 +1,103 @@ +'use server' + +import {z} from 'zod' +import { + GlobalSkill, + GlobalSkillSchema, + Project, + ProjectMutation, + ProjectMutationSchema, + ProjectSchema +} from '@/lib/project/types' +import {api} from '@/lib/config/api-client' +import {handleErrors} from '@/lib/misc/error-handler' + +/** + * Gets all projects from the database for a given resume + * @param {string} resumeId - The resume id, default is 'base' + * @returns {Promise} - Array of projects + * @throws {Error} If authentication or API request fails. + */ +export const getProjectsFromDB = async (resumeId: string = 'base'): Promise => { + try { + const data = await api.get(`/resume/${resumeId}/project/`) + + return z.array(ProjectSchema).parse(data) + } catch (error) { + return handleErrors(error, 'fetch projects') + } +} + +/** + * Fetches all global skills available in the database + * @returns {Promise} - Array of skills + * @throws {Error} If API request fails. + */ +export const getGlobalSkills = async (): Promise => { + try { + const data = await api.get('/skills/') + return z.array(GlobalSkillSchema).parse(data) + } catch (error) { + return handleErrors(error, 'fetch global skills') + } +} + +/** + * Adds a new project in the database + * @param {ProjectMutation} projectValues - The project information + * @param {string} resumeId - The resume id, default is 'base' + * @returns {Promise} - The new project object + * @throws {Error} If validation, authentication, or API request fails. + */ +export const addProjectToDB = async (projectValues: ProjectMutation, resumeId: string = 'base'): Promise => { + try { + const params = ProjectMutationSchema.parse(projectValues) + const data = await api.post(`/resume/${resumeId}/project/`, params) + return ProjectSchema.parse(data) + } catch (error) { + return handleErrors(error, 'add project') + } +} + +/** + * Updates an existing project in the database + * @param {string} projectId - The project id + * @param {Partial} projectValues - The updated project information + * @param {string} resumeId - The resume id, default is 'base' + * @returns {Promise} - The updated project object + * @throws {Error} If validation, authentication, or API request fails. + */ +export const updateProjectInDB = async ( + projectId: string, + projectValues: Partial, + resumeId: string = 'base' +): Promise => { + try { + const params = ProjectMutationSchema.partial().parse(projectValues) + const data = await api.patch( + `/resume/${resumeId}/project/${projectId}/`, + params + ) + return ProjectSchema.parse(data) + } catch (error) { + return handleErrors(error, 'update project') + } +} + +/** + * Deletes a project from the database + * @param {string} projectId - The project id + * @param {string} resumeId - The resume id, default is 'base' + * @returns {Promise} + * @throws {Error} If authentication or API request fails. + */ +export const deleteProjectFromDB = async ( + projectId: string, + resumeId: string = 'base' +): Promise => { + try { + await api.delete(`/resume/${resumeId}/project/${projectId}/`) + } catch (error) { + return handleErrors(error, 'delete project') + } +} diff --git a/lib/project/keys.ts b/lib/project/keys.ts new file mode 100644 index 00000000..506b39d8 --- /dev/null +++ b/lib/project/keys.ts @@ -0,0 +1,4 @@ +/** + * React Query keys for project-related queries + */ +export const PROJECT_KEYS = ['projects'] diff --git a/lib/project/mutations.ts b/lib/project/mutations.ts new file mode 100644 index 00000000..6250e109 --- /dev/null +++ b/lib/project/mutations.ts @@ -0,0 +1,18 @@ +import {Project, ProjectMutation} from '@/lib/project/types' +import {MutationOptions, useMutation} from '@tanstack/react-query' +import {addProjectToDB, deleteProjectFromDB, updateProjectInDB} from '@/lib/project/actions' + +export const useAddProjectMutation = (options?: MutationOptions) => useMutation({ + mutationFn: ({data, resumeId}) => addProjectToDB(data, resumeId || 'base'), + ...options +}) + +export const useUpdateProjectMutation = (options?: MutationOptions, resumeId?: string}>) => useMutation({ + mutationFn: ({id, data, resumeId}) => updateProjectInDB(id, data, resumeId || 'base'), + ...options +}) + +export const useDeleteProjectMutation = (options?: MutationOptions) => useMutation({ + mutationFn: ({id, resumeId}) => deleteProjectFromDB(id, resumeId || 'base'), + ...options +}) diff --git a/lib/project/queries.ts b/lib/project/queries.ts new file mode 100644 index 00000000..5f027086 --- /dev/null +++ b/lib/project/queries.ts @@ -0,0 +1,19 @@ +import {queryOptions, useQuery} from '@tanstack/react-query' +import {getGlobalSkills, getProjectsFromDB} from './actions' +import {PROJECT_KEYS} from './keys' + +export const projectQueryOptions = (resumeId: string) => queryOptions({ + queryKey: [...PROJECT_KEYS, resumeId], + queryFn: () => getProjectsFromDB(resumeId) +}) + +export const globalSkillsQueryOptions = queryOptions({ + queryKey: [...PROJECT_KEYS, 'globalSkills'], + queryFn: getGlobalSkills +}) + +export const useCurrentProjects = (resumeId: string) => { + return useQuery(projectQueryOptions(resumeId)) +} + +export const useGlobalSkills = () => useQuery(globalSkillsQueryOptions) diff --git a/lib/project/types.ts b/lib/project/types.ts new file mode 100644 index 00000000..aa0c4759 --- /dev/null +++ b/lib/project/types.ts @@ -0,0 +1,92 @@ +import {z} from 'zod' + +/** + * Project Schema for API interactions + */ +export const ProjectSchema = z.object({ + id: z.string().uuid().describe('Unique identifier for the project').readonly(), + name: z.string().max(255).describe('Name of the project.'), + category: z.string().max(255).nullable().describe('Category or type of the project (e.g., Web Development, Mobile App).'), + description: z.string().nullable().optional().describe('Detailed description of the project and its objectives.'), + role: z.string().max(255).nullable().describe('Your role or position in this project (e.g., Lead Developer, UI Designer).'), + github_url: z.string().max(200).nullable().describe('Link to the project\'s GitHub repository.'), + live_url: z.string().max(200).nullable().describe('Link to the live/deployed version of the project.'), + started_from_month: z.number().int().min(1).max(12).nullable().describe('Month when the project started (1-12).'), + started_from_year: z.number().int().min(1900).max(2100).nullable().describe('Year when the project started (YYYY).'), + finished_at_month: z.number().int().min(1).max(12).nullable().describe('Month when the project was completed (1-12).'), + finished_at_year: z.number().int().min(1900).max(2100).nullable().describe('Year when the project was completed (YYYY).'), + current: z.boolean().nullable().describe('Indicates if this is a current/ongoing project.'), + user: z.string().describe('The user who owns this project').readonly(), + created_at: z.string().describe('Timestamp when the project was first created.').readonly(), + updated_at: z.string().describe('Timestamp when the project was last updated.').readonly(), + skills_used: z.array(z.object({ + id: z.string().uuid().describe('The unique identifier for the skill entry.').readonly(), + category: z.string().max(50).nullable().describe('The category of the skill. (optional)'), + name: z.string().max(250).describe('The name of the skill.'), + preferred: z.boolean().optional().describe('Whether this is a preferred skill'), + alias: z.array(z.object({ + id: z.string().describe('The unique identifier for the alias entry.').readonly(), + category: z.string().max(50).nullable().describe('The category of the alias. (optional)'), + name: z.string().max(250).describe('The name of the alias.'), + preferred: z.boolean().optional().describe('Whether this is a preferred alias'), + created_at: z.string().describe('The date and time the alias entry was created.').readonly(), + updated_at: z.string().describe('The date and time the alias entry was last updated.').readonly() + })).describe('Alternative names for the skill'), + updated_at: z.string().describe('The date and time the skill entry was last updated.').readonly(), + created_at: z.string().describe('The date and time the skill entry was created.').readonly() + })).describe('Skills used in this project'), + resume_section: z.string().uuid().describe('The unique identifier for the resume section entry.').readonly() +}) + +/** + * Schema for project creation/modification + * Derived by omitting read-only fields from ProjectSchema + */ +export const ProjectMutationSchema = ProjectSchema.omit({ + id: true, + user: true, + created_at: true, + updated_at: true, + resume_section: true, + skills_used: true, + started_from_month: true, + started_from_year: true, + finished_at_month: true, + finished_at_year: true +}).extend({ + name: z.string().min(1, {message: 'Required'}).max(255).describe('Name of the project.'), + started_from_month: z.string().nullish(), + started_from_year: z.string().nullish(), + finished_at_month: z.string().nullish(), + finished_at_year: z.string().nullish(), + skills_used: z.array(z.object({ + name: z.string().describe('The name of the skill.'), + category: z.string().nullable().describe('The category of the skill. (optional)') + })).describe('Skills used in this project') +}) + +// Infer TypeScript types from the schema +export type Project = z.infer +export type ProjectMutation = z.infer + +/** + * Schema for global skills + */ +export const GlobalSkillSchema = z.object({ + id: z.string().describe('The unique identifier for the skill entry.').readonly(), + name: z.string().max(250).describe('The name of the skill.'), + category: z.string().max(50).nullable().describe('The category of the skill. (optional)'), + preferred: z.boolean().optional().describe('Whether this is a preferred skill'), + alias: z.array(z.object({ + id: z.string().describe('The unique identifier for the alias entry.').readonly(), + category: z.string().max(50).nullable().describe('The category of the alias. (optional)'), + name: z.string().max(250).describe('The name of the alias.'), + preferred: z.boolean().optional().describe('Whether this is a preferred alias'), + created_at: z.string().describe('The date and time the alias entry was created.').readonly(), + updated_at: z.string().describe('The date and time the alias entry was last updated.').readonly() + })).describe('Alternative names for the skill'), + created_at: z.string().describe('The date and time the skill entry was created.').readonly(), + updated_at: z.string().describe('The date and time the skill entry was last updated.').readonly() +}) + +export type GlobalSkill = z.infer diff --git a/lib/resume/__tests__/actions.test.ts b/lib/resume/__tests__/actions.test.ts new file mode 100644 index 00000000..110b26aa --- /dev/null +++ b/lib/resume/__tests__/actions.test.ts @@ -0,0 +1,436 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {getResumeFromDB, rearrangeResumeSections} from '@/lib/resume/actions' +import {api} from '@/lib/config/api-client' +import {handleErrors} from '@/lib/misc/error-handler' +import {Resume} from '@/lib/resume/types' + +// Mock dependencies +vi.mock('@/lib/config/api-client') +vi.mock('@/lib/misc/error-handler') + +const mockApi = vi.mocked(api) +const mockHandleErrors = vi.mocked(handleErrors) + +describe('Resume Actions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + describe('getResumeFromDB', () => { + const mockResume: Resume = { + id: 'resume_123', + base: true, + status: 'Success', + user: { + id: 'user_123', + title: null, + first_name: 'John', + last_name: 'Doe', + email: 'john.doe@example.com', + phone: '+1-555-0123', + dob: null, + nationality: null, + address: null, + city: null, + postal: null, + country: null, + website: 'https://johndoe.dev', + profile_text: null, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z' + }, + job: { + job_url: 'https://example.com/job/1', + title: 'Senior Software Engineer', + company_name: 'Tech Corp', + location: 'San Francisco, CA', + requirements: 'React, TypeScript, Node.js', + description: 'We are looking for a senior software engineer.', + responsibilities: 'Lead development projects, Mentor junior developers', + benefits: 'Health insurance, Remote work, 401k matching', + status: 'Success' + }, + sections: [ + { + id: '123e4567-e89b-12d3-a456-426614174001', + resume: 'resume_123', + index: 0, + type: 'Experience', + data: { + id: '123e4567-e89b-12d3-a456-426614174010', + user: 'user_123', + resume_section: '123e4567-e89b-12d3-a456-426614174001', + company_name: 'Tech Corp', + job_title: 'Senior Software Engineer', + country: { + code: 'USA', + name: 'United States' + }, + city: 'San Francisco', + employment_type: 'flt', + started_from_month: 1, + started_from_year: 2022, + finished_at_month: 12, + finished_at_year: 2023, + current: false, + description: 'Led development of web applications.', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z' + } + }, + { + id: '123e4567-e89b-12d3-a456-426614174002', + resume: 'resume_123', + index: 1, + type: 'Education', + data: { + id: '123e4567-e89b-12d3-a456-426614174020', + user: 'user_123', + resume_section: '123e4567-e89b-12d3-a456-426614174002', + institution_name: 'University of Technology', + degree: 'Bachelor of Science', + field_of_study: 'Computer Science', + country: { + code: 'USA', + name: 'United States' + }, + started_from_month: 9, + started_from_year: 2018, + finished_at_month: 5, + finished_at_year: 2022, + current: false, + description: 'Magna Cum Laude, Dean\'s List', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z' + } + } + ] + } + + it('should fetch resume with default resumeId', async () => { + mockApi.get.mockResolvedValue(mockResume) + + const result = await getResumeFromDB() + + expect(mockApi.get).toHaveBeenCalledWith('/resume/base/') + expect(result).toEqual(mockResume) + }) + + it('should fetch resume with custom resumeId', async () => { + mockApi.get.mockResolvedValue(mockResume) + + const result = await getResumeFromDB('custom-resume-123') + + expect(mockApi.get).toHaveBeenCalledWith('/resume/custom-resume-123/') + expect(result).toEqual(mockResume) + }) + + it('should handle base resumeId explicitly', async () => { + mockApi.get.mockResolvedValue(mockResume) + + const result = await getResumeFromDB('base') + + expect(mockApi.get).toHaveBeenCalledWith('/resume/base/') + expect(result).toEqual(mockResume) + }) + + it('should validate response data with schema', async () => { + mockApi.get.mockResolvedValue(mockResume) + + const result = await getResumeFromDB() + + expect(result).toMatchObject({ + id: expect.any(String), + base: expect.any(Boolean), + user: expect.objectContaining({ + id: expect.any(String), + first_name: expect.any(String), + last_name: expect.any(String), + email: expect.any(String) + }), + job: expect.objectContaining({ + job_url: expect.any(String), + title: expect.any(String), + company_name: expect.any(String) + }), + sections: expect.any(Array) + }) + }) + + it('should handle API errors', async () => { + const error = new Error('API Error') + mockApi.get.mockRejectedValue(error) + vi.mocked(handleErrors).mockImplementation(() => { + throw new Error('Failed to fetch resume: API Error') + }) + + await expect(getResumeFromDB()).rejects.toThrow('Failed to fetch resume: API Error') + expect(mockHandleErrors).toHaveBeenCalledWith(error, 'fetch resume') + }) + + it('should return resume with all sections', async () => { + mockApi.get.mockResolvedValue(mockResume) + + const result = await getResumeFromDB() + + expect(result.sections).toHaveLength(2) + expect(result.sections[0].type).toBe('Experience') + expect(result.sections[1].type).toBe('Education') + }) + + it('should handle resume with no sections', async () => { + const resumeWithNoSections: Resume = { + ...mockResume, + sections: [] + } + + mockApi.get.mockResolvedValue(resumeWithNoSections) + + const result = await getResumeFromDB() + + expect(result.sections).toEqual([]) + }) + + it('should handle resume with null job values', async () => { + const resumeWithNullJobValues: Resume = { + ...mockResume, + job: { + ...mockResume.job, + requirements: null, + responsibilities: null, + benefits: null + } + } + + mockApi.get.mockResolvedValue(resumeWithNullJobValues) + + const result = await getResumeFromDB() + + expect(result.job.requirements).toBeNull() + expect(result.job.responsibilities).toBeNull() + expect(result.job.benefits).toBeNull() + }) + }) + + describe('rearrangeResumeSections', () => { + const mockUpdatedResume: Resume = { + id: 'resume_123', + base: true, + status: 'Success', + user: { + id: 'user_123', + title: null, + first_name: 'John', + last_name: 'Doe', + email: 'john.doe@example.com', + phone: '+1-555-0123', + dob: null, + nationality: null, + address: null, + city: null, + postal: null, + country: null, + website: 'https://johndoe.dev', + profile_text: null, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z' + }, + job: { + job_url: 'https://example.com/job/1', + title: 'Senior Software Engineer', + company_name: 'Tech Corp', + location: 'San Francisco, CA', + requirements: 'React, TypeScript, Node.js', + description: 'We are looking for a senior software engineer.', + responsibilities: 'Lead development projects, Mentor junior developers', + benefits: 'Health insurance, Remote work, 401k matching', + status: 'Success' + }, + sections: [ + { + id: '123e4567-e89b-12d3-a456-426614174002', + resume: 'resume_123', + index: 0, + type: 'Education', + data: { + id: '123e4567-e89b-12d3-a456-426614174020', + user: 'user_123', + resume_section: '123e4567-e89b-12d3-a456-426614174002', + institution_name: 'University of Technology', + degree: 'Bachelor of Science', + field_of_study: 'Computer Science', + country: { + code: 'USA', + name: 'United States' + }, + started_from_month: 9, + started_from_year: 2018, + finished_at_month: 5, + finished_at_year: 2022, + current: false, + description: 'Magna Cum Laude, Dean\'s List', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z' + } + }, + { + id: '123e4567-e89b-12d3-a456-426614174001', + resume: 'resume_123', + index: 1, + type: 'Experience', + data: { + id: '123e4567-e89b-12d3-a456-426614174010', + user: 'user_123', + resume_section: '123e4567-e89b-12d3-a456-426614174001', + company_name: 'Tech Corp', + job_title: 'Senior Software Engineer', + country: { + code: 'USA', + name: 'United States' + }, + city: 'San Francisco', + employment_type: 'flt', + started_from_month: 1, + started_from_year: 2022, + finished_at_month: 12, + finished_at_year: 2023, + current: false, + description: 'Led development of web applications.', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z' + } + } + ] + } + + it('should rearrange resume sections successfully', async () => { + mockApi.put.mockResolvedValue(mockUpdatedResume) + + const sectionIds = ['123e4567-e89b-12d3-a456-426614174002', '123e4567-e89b-12d3-a456-426614174001'] + const result = await rearrangeResumeSections('resume_123', sectionIds) + + expect(mockApi.put).toHaveBeenCalledWith('/resume/resume_123/sections/rearrange/', { + section_ids: sectionIds + }) + expect(result).toEqual(mockUpdatedResume) + }) + + it('should handle empty section IDs array', async () => { + mockApi.put.mockResolvedValue(mockUpdatedResume) + + const sectionIds: string[] = [] + const result = await rearrangeResumeSections('resume_123', sectionIds) + + expect(mockApi.put).toHaveBeenCalledWith('/resume/resume_123/sections/rearrange/', { + section_ids: [] + }) + expect(result).toEqual(mockUpdatedResume) + }) + + it('should handle single section ID', async () => { + mockApi.put.mockResolvedValue(mockUpdatedResume) + + const sectionIds = ['123e4567-e89b-12d3-a456-426614174001'] + await rearrangeResumeSections('resume_123', sectionIds) + + expect(mockApi.put).toHaveBeenCalledWith('/resume/resume_123/sections/rearrange/', { + section_ids: ['123e4567-e89b-12d3-a456-426614174001'] + }) + }) + + it('should handle multiple section IDs', async () => { + mockApi.put.mockResolvedValue(mockUpdatedResume) + + const sectionIds = ['123e4567-e89b-12d3-a456-426614174003', '123e4567-e89b-12d3-a456-426614174001', '123e4567-e89b-12d3-a456-426614174002', '123e4567-e89b-12d3-a456-426614174004'] + await rearrangeResumeSections('resume_123', sectionIds) + + expect(mockApi.put).toHaveBeenCalledWith('/resume/resume_123/sections/rearrange/', { + section_ids: sectionIds + }) + }) + + it('should validate response data with schema', async () => { + mockApi.put.mockResolvedValue(mockUpdatedResume) + + const result = await rearrangeResumeSections('resume_123', ['123e4567-e89b-12d3-a456-426614174002', '123e4567-e89b-12d3-a456-426614174001']) + + expect(result).toMatchObject({ + id: expect.any(String), + base: expect.any(Boolean), + user: expect.objectContaining({ + id: expect.any(String), + first_name: expect.any(String), + last_name: expect.any(String) + }), + job: expect.objectContaining({ + job_url: expect.any(String), + title: expect.any(String), + company_name: expect.any(String) + }), + sections: expect.any(Array) + }) + }) + + it('should handle API errors', async () => { + const error = new Error('API Error') + mockApi.put.mockRejectedValue(error) + vi.mocked(handleErrors).mockImplementation(() => { + throw new Error('Failed to rearrange resume sections: API Error') + }) + + await expect(rearrangeResumeSections('resume_123', ['123e4567-e89b-12d3-a456-426614174001'])) + .rejects.toThrow('Failed to rearrange resume sections: API Error') + expect(mockHandleErrors).toHaveBeenCalledWith(error, 'rearrange resume sections') + }) + + it('should preserve section order in response', async () => { + mockApi.put.mockResolvedValue(mockUpdatedResume) + + const result = await rearrangeResumeSections('resume_123', ['123e4567-e89b-12d3-a456-426614174002', '123e4567-e89b-12d3-a456-426614174001']) + + // Verify that the returned resume has sections in the new order + expect(result.sections[0].id).toBe('123e4567-e89b-12d3-a456-426614174002') + expect(result.sections[0].index).toBe(0) + expect(result.sections[1].id).toBe('123e4567-e89b-12d3-a456-426614174001') + expect(result.sections[1].index).toBe(1) + }) + + it('should handle different resume IDs', async () => { + mockApi.put.mockResolvedValue(mockUpdatedResume) + + await rearrangeResumeSections('different-resume-456', ['123e4567-e89b-12d3-a456-426614174001']) + + expect(mockApi.put).toHaveBeenCalledWith('/resume/different-resume-456/sections/rearrange/', { + section_ids: ['123e4567-e89b-12d3-a456-426614174001'] + }) + }) + + it('should handle 404 errors gracefully', async () => { + const notFoundError = new Error('Resume not found') + mockApi.put.mockRejectedValue(notFoundError) + vi.mocked(handleErrors).mockImplementation(() => { + throw new Error('Failed to rearrange resume sections: Resume not found') + }) + + await expect(rearrangeResumeSections('non-existent-resume', ['123e4567-e89b-12d3-a456-426614174001'])) + .rejects.toThrow('Failed to rearrange resume sections: Resume not found') + expect(mockHandleErrors).toHaveBeenCalledWith(notFoundError, 'rearrange resume sections') + }) + + it('should handle validation errors', async () => { + const validationError = new Error('Invalid section IDs') + mockApi.put.mockRejectedValue(validationError) + vi.mocked(handleErrors).mockImplementation(() => { + throw new Error('Failed to rearrange resume sections: Invalid section IDs') + }) + + await expect(rearrangeResumeSections('resume_123', ['invalid-section'])) + .rejects.toThrow('Failed to rearrange resume sections: Invalid section IDs') + expect(mockHandleErrors).toHaveBeenCalledWith(validationError, 'rearrange resume sections') + }) + }) +}) diff --git a/lib/resume/__tests__/mutations.test.ts b/lib/resume/__tests__/mutations.test.ts new file mode 100644 index 00000000..c7fbb03c --- /dev/null +++ b/lib/resume/__tests__/mutations.test.ts @@ -0,0 +1,71 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' + +// Mock the mutations module +vi.mock('@/lib/resume/mutations', () => ({ + useRearrangeResumeSectionsMutation: vi.fn() +})) + +// Mock the actions +vi.mock('@/lib/resume/actions') + +// Mock sonner toast +vi.mock('sonner', () => ({ + toast: { + error: vi.fn() + } +})) + +describe('Resume Mutations', () => { + beforeEach(async () => { + vi.clearAllMocks() + + // Setup default mock implementations + const mutations = vi.mocked(await import('@/lib/resume/mutations')) + + mutations.useRearrangeResumeSectionsMutation.mockReturnValue({ + mutateAsync: vi.fn(() => Promise.resolve()), + isPending: false, + isError: false, + error: null + } as any) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('useRearrangeResumeSectionsMutation', () => { + it('should be a function', async () => { + const {useRearrangeResumeSectionsMutation} = await import('@/lib/resume/mutations') + expect(typeof useRearrangeResumeSectionsMutation).toBe('function') + }) + + it('should be mockable', async () => { + const {useRearrangeResumeSectionsMutation} = vi.mocked(await import('@/lib/resume/mutations')) + + expect(useRearrangeResumeSectionsMutation).toHaveBeenCalledTimes(0) + + // Call the mocked function + const result = useRearrangeResumeSectionsMutation() + + expect(useRearrangeResumeSectionsMutation).toHaveBeenCalledTimes(1) + expect(result).toBeDefined() + expect(result.mutateAsync).toBeDefined() + expect(result.isPending).toBe(false) + }) + + it('should return mutation with proper structure', async () => { + const {useRearrangeResumeSectionsMutation} = vi.mocked(await import('@/lib/resume/mutations')) + + const result = useRearrangeResumeSectionsMutation() + + expect(result).toHaveProperty('mutateAsync') + expect(result).toHaveProperty('isPending') + expect(result).toHaveProperty('isError') + expect(result).toHaveProperty('error') + expect(typeof result.mutateAsync).toBe('function') + expect(typeof result.isPending).toBe('boolean') + expect(typeof result.isError).toBe('boolean') + }) + }) +}) diff --git a/lib/resume/__tests__/queries.test.ts b/lib/resume/__tests__/queries.test.ts new file mode 100644 index 00000000..853c5d40 --- /dev/null +++ b/lib/resume/__tests__/queries.test.ts @@ -0,0 +1,120 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {baseResumeQueryOptions, useBaseResume} from '@/lib/resume/queries' +import {getResumeFromDB} from '@/lib/resume/actions' +import {BASE_RESUME_KEYS} from '@/lib/resume/key' +import {Resume} from '@/lib/resume/types' + +// Mock the actions +vi.mock('../actions') + +const mockGetResumeFromDB = vi.mocked(getResumeFromDB) + +// Mock data +const mockResume: Resume = { + id: 'resume-123', + base: true, + status: 'Success', + user: { + id: 'user-123', + title: 'Mr.', + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + phone: '+1234567890', + dob: null, + address: '123 Main St', + city: 'New York', + postal: '10001', + country: { + code: 'US', + name: 'United States' + }, + nationality: 'American', + website: 'https://johndoe.com', + profile_text: 'Software engineer', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + }, + job: { + job_url: 'https://example.com/job', + title: 'Software Engineer', + company_name: 'Tech Corp', + location: 'San Francisco, CA', + requirements: 'JavaScript, React, TypeScript', + description: 'We are looking for a talented software engineer', + responsibilities: 'Develop web applications, Write clean code', + benefits: 'Health insurance, 401k, Remote work', + status: 'Success' + }, + sections: [] +} + +describe('Resume Queries', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('baseResumeQueryOptions', () => { + it('should have correct query key', () => { + const options = baseResumeQueryOptions + + expect(options.queryKey).toEqual(BASE_RESUME_KEYS) + }) + + it('should have correct query function', () => { + const options = baseResumeQueryOptions + + expect(options.queryFn).toBeInstanceOf(Function) + }) + + it('should call getResumeFromDB with "base" when query function is executed', async () => { + mockGetResumeFromDB.mockResolvedValue(mockResume) + + const mockContext = { + queryKey: BASE_RESUME_KEYS, + client: {} as any, + signal: {} as AbortSignal, + meta: undefined + } + + const result = await baseResumeQueryOptions.queryFn!(mockContext) + + expect(mockGetResumeFromDB).toHaveBeenCalledTimes(1) + expect(mockGetResumeFromDB).toHaveBeenCalledWith('base') + expect(result).toEqual(mockResume) + }) + + it('should handle query function errors', async () => { + const error = new Error('API Error') + mockGetResumeFromDB.mockRejectedValue(error) + + const mockContext = { + queryKey: BASE_RESUME_KEYS, + client: {} as any, + signal: {} as AbortSignal, + meta: undefined + } + + await expect(baseResumeQueryOptions.queryFn!(mockContext)).rejects.toThrow('API Error') + expect(mockGetResumeFromDB).toHaveBeenCalledWith('base') + }) + }) + + describe('useBaseResume', () => { + it('should use the correct query options', () => { + const hook = useBaseResume + + // Test that the hook is a function + expect(typeof hook).toBe('function') + }) + + it('should be available for import', () => { + expect(useBaseResume).toBeDefined() + expect(typeof useBaseResume).toBe('function') + }) + }) +}) diff --git a/lib/resume/accept.ts b/lib/resume/accept.ts new file mode 100644 index 00000000..34b34805 --- /dev/null +++ b/lib/resume/accept.ts @@ -0,0 +1,25 @@ +export const ACCEPTED_FILE_EXTENSIONS = ['.pdf', '.txt', '.doc', '.docx', '.rtf', '.odt'] as const + +export const ACCEPTED_MIME_TYPES = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'text/plain', + 'application/rtf', + 'application/vnd.oasis.opendocument.text' +] as const + +export const ACCEPT_ATTRIBUTE = [...ACCEPTED_FILE_EXTENSIONS, ...ACCEPTED_MIME_TYPES].join(',') + +export const ACCEPTED_LABEL = ACCEPTED_FILE_EXTENSIONS.join(', ') + +export const isAcceptedByName = (fileName: string): boolean => { + const lower = fileName.toLowerCase() + return ACCEPTED_FILE_EXTENSIONS.some(ext => lower.endsWith(ext)) +} + +export const isAcceptedFile = (file: File): boolean => { + return isAcceptedByName(file.name) || ACCEPTED_MIME_TYPES.includes(file.type as typeof ACCEPTED_MIME_TYPES[number]) +} + + diff --git a/lib/resume/actions.ts b/lib/resume/actions.ts index 7cd3cd46..621b5b87 100644 --- a/lib/resume/actions.ts +++ b/lib/resume/actions.ts @@ -1,24 +1,46 @@ 'use server' -import {auth} from '@clerk/nextjs/server' -import {Resume} from '@/lib/resume/types' +import { + Resume, + ResumeListItem, + ResumeListItemSchema, + ResumeMutation, + ResumeMutationSchema, + ResumeSchema, + TailorResumeResponse, + TailorResumeResponseSchema +} from '@/lib/resume/types' +import {parseResume} from '@/lib/resume/parser' +import {api} from '@/lib/config/api-client' +import {handleErrors} from '@/lib/misc/error-handler' +import {ACCEPTED_MIME_TYPES, isAcceptedByName} from '@/lib/resume/accept' /** - * Retrieves a single experience object from the database by its ID - * @param {string} [resumeId] - The ID of the resume to retrieve experience entries from. Defaults to 'base'. - * @returns {Promise} - The retrieved experience objects + * Retrieves a single resume object from the database by its ID + * @param {string} [resumeId] - The ID of the resume to retrieve. Defaults to 'base'. + * @returns {Promise} - The retrieved resume object + * @throws {Error} If authentication or API request fails. */ export const getResumeFromDB = async (resumeId?: string | 'base'): Promise => { - const session = await auth() - const token = await session.getToken() - - const response = await fetch(`${process.env.API_URL}/resume/${resumeId ?? 'base'}/`, { - headers: { - Authorization: `Bearer ${token}` - } - }) + try { + const data = await api.get(`/resume/${resumeId ?? 'base'}/`) + return ResumeSchema.parse(data) + } catch (error) { + return handleErrors(error, 'fetch resume') + } +} - return await response.json() +/** + * Retrieves all resumes for the current user + * GET /resume/ + */ +export const listResumesForUser = async (): Promise => { + try { + const data = await api.get('/resume/') + return (data || []).map(item => ResumeListItemSchema.parse(item)) + } catch (error) { + return handleErrors(error, 'list resumes') + } } /** @@ -26,25 +48,78 @@ export const getResumeFromDB = async (resumeId?: string | 'base'): Promise} - The updated resume object + * @throws {Error} If validation, authentication, or API request fails. */ export const rearrangeResumeSections = async (resumeId: string, sectionIds: string[]): Promise => { - const session = await auth() - const token = await session.getToken() - - const response = await fetch(`${process.env.API_URL}/resume/${resumeId}/sections/rearrange/`, { - method: 'PUT', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ + try { + const data = await api.put(`/resume/${resumeId}/sections/rearrange/`, { section_ids: sectionIds }) - }) + return ResumeSchema.parse(data) + } catch (error) { + return handleErrors(error, 'rearrange resume sections') + } +} + +/** + * Initiates tailoring of a new resume for a job + * Accepts either a job URL or a raw job description + */ +export const tailorResumeInDB = async ( + payload: { target: string } +): Promise => { + try { + const data = await api.post('/resume/tailor/', payload) + return TailorResumeResponseSchema.parse(data) + } catch (error) { + return handleErrors(error, 'tailor resume') + } +} + +/** + * Parses an uploaded resume file using the self-hosted parser + * Runs on the server and accepts a FormData containing the file + */ +export const parseUploadedResume = async ( + formData: FormData, + format: 'proprietary' | 'generic' = 'proprietary' +): Promise => { + const file = formData.get('file') + if (!file || !(file instanceof File)) { + throw new Error('No file provided in form data under key "file"') + } - if (!response.ok) { - throw new Error('Failed to rearrange resume sections') + // Basic validation: size and type + const maxBytes = 10 * 1024 * 1024 // 10MB + if (file.size === 0) { + throw new Error('Uploaded file is empty') + } + if (file.size > maxBytes) { + throw new Error('File too large. Maximum allowed size is 10MB') } + const mimeOk = (file.type ? (ACCEPTED_MIME_TYPES as readonly string[]).includes(file.type) : false) + const nameOk = isAcceptedByName((file.name ?? '').toLowerCase()) + if (!mimeOk && !nameOk) { + throw new Error('Unsupported file type. Please upload a PDF, DOC, DOCX, RTF, ODT, or TXT file') + } + + const result = await parseResume(file, format) + return result +} - return await response.json() +/** + * Replaces a resume with provided sections payload + * PUT /resume/{id}/ (or /resume/base/) + */ +export const replaceResume = async ( + payload: ResumeMutation, + resumeId: string = 'base' +): Promise => { + try { + const parsed = ResumeMutationSchema.parse(payload) + const data = await api.put(`/resume/${resumeId ?? 'base'}/`, parsed) + return ResumeSchema.parse(data) + } catch (error) { + return handleErrors(error, 'replace resume') + } } diff --git a/lib/resume/key.ts b/lib/resume/key.ts index e6a7b8cb..3a3e8fc6 100644 --- a/lib/resume/key.ts +++ b/lib/resume/key.ts @@ -1 +1,2 @@ export const BASE_RESUME_KEYS = ['base-resume'] +export const RESUMES_KEYS = ['resumes'] diff --git a/lib/resume/mutations.ts b/lib/resume/mutations.ts index f7ce0e44..b589843d 100644 --- a/lib/resume/mutations.ts +++ b/lib/resume/mutations.ts @@ -1,7 +1,9 @@ -import {useMutation, useQueryClient} from '@tanstack/react-query' -import {rearrangeResumeSections} from '@/lib/resume/actions' +import {MutationOptions, useMutation, useQueryClient} from '@tanstack/react-query' +import {parseUploadedResume, rearrangeResumeSections, replaceResume, tailorResumeInDB} from '@/lib/resume/actions' import {BASE_RESUME_KEYS} from '@/lib/resume/key' import {toast} from 'sonner' +import {Resume, ResumeMutation} from '@/lib/resume/types' +import type {TailorResumeResponse} from '@/lib/resume/types' export const useRearrangeResumeSectionsMutation = () => { const queryClient = useQueryClient() @@ -16,3 +18,39 @@ export const useRearrangeResumeSectionsMutation = () => { } }) } + +export const useTailorResumeMutation = ( + options?: MutationOptions +) => useMutation({ + mutationFn: (payload) => tailorResumeInDB(payload), + ...options +}) + +type ParseResumeParams = { + formData: FormData + format?: 'proprietary' | 'generic' +} + +export const useParseResumeMutation = () => { + return useMutation({ + mutationFn: async ({formData, format = 'proprietary'}) => parseUploadedResume(formData, format) + }) +} + +type ReplaceResumeParams = { + payload: ResumeMutation + resumeId?: string | 'base' +} + +export const useReplaceResumeMutation = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({payload, resumeId = 'base'}) => replaceResume(payload, resumeId), + onSuccess: () => { + queryClient.invalidateQueries({queryKey: BASE_RESUME_KEYS}) + }, + onError: () => { + toast.error('Failed to replace resume') + } + }) +} diff --git a/lib/resume/parser.ts b/lib/resume/parser.ts new file mode 100644 index 00000000..cbfc90ca --- /dev/null +++ b/lib/resume/parser.ts @@ -0,0 +1,272 @@ +'use server' + +import {generateObject} from 'ai' +import {google} from '@ai-sdk/google' +import {z} from 'zod' +import {ResumeMutation, ResumeMutationSchema} from '@/lib/resume/types' + +/** + * Ensure that HTML produced for Tiptap includes our expected classes. + * - paragraph -> text-node + * - heading (h1..h6) -> heading-node + * - blockquote -> block-node + * - ul/ol -> list-node + * - code (inline) -> inline + * If the input is plain text, wrap it in a paragraph with class text-node. + */ +const ALLOWED_TAGS = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'ul', 'ol', 'code'] as const +type AllowedTag = typeof ALLOWED_TAGS[number] + +const ensureClassOnTag = (html: string, tag: AllowedTag, className: string): string => { + if (!ALLOWED_TAGS.includes(tag)) { + throw new Error(`Tag "${tag}" is not allowed`) + } + const regex = new RegExp(`<${tag}\\b([^>]*)>`, 'gi') + return html.replace(regex, (match, attrs: string) => { + if (/class\s*=/.test(attrs)) { + return match.replace(/class\s*=\s*(["'])(.*?)\1/i, (_m, quote: string, classes: string) => { + const classList = classes.trim().split(/\s+/) + if (classList.includes(className)) return `class=${quote}${classes}${quote}` + const updated = classes ? `${classes} ${className}` : className + return `class=${quote}${updated}${quote}` + }) + } + const space = attrs?.length ? attrs : '' + return `<${tag}${space} class="${className}">` + }) +} + +// Normalize certification issue_date to YYYY-MM-DD when possible +const normalizeCertificationDate = (date: unknown): string | null | undefined => { + if (date === null || date === undefined) return date as null | undefined + + if (date instanceof Date) { + return date.toISOString().slice(0, 10) + } + + if (typeof date === 'string') { + if (/^\d{4}-\d{2}-\d{2}$/.test(date)) return date + if (/^\d{4}-\d{2}-\d{2}T/.test(date)) return date.slice(0, 10) + + const parsed = new Date(date) + if (!Number.isNaN(parsed.getTime())) { + return parsed.toISOString().slice(0, 10) + } + } + + return undefined +} + +const isLikelyHtml = (input: string): boolean => /<\w+[\s\S]*>/.test(input) + +const normalizeDescriptionToTiptapHTML = (input: string | null | undefined): string | null | undefined => { + if (input == null || input === '') return input + + let html = String(input).trim() + + // If not HTML, convert to a simple paragraph + if (!isLikelyHtml(html)) { + // Preserve basic newlines by splitting into paragraphs + const paragraphs = html + .split(/\n{2,}/) + .map(p => p.trim()) + .filter(Boolean) + .map(p => `

${p.replace(/\n/g, '
')}

`) // single newlines become
+ html = paragraphs.length ? paragraphs.join('') : '

' + } + + // Enforce classes on common nodes + html = ensureClassOnTag(html, 'p', 'text-node') + html = ensureClassOnTag(html, 'h1', 'heading-node') + html = ensureClassOnTag(html, 'h2', 'heading-node') + html = ensureClassOnTag(html, 'h3', 'heading-node') + html = ensureClassOnTag(html, 'h4', 'heading-node') + html = ensureClassOnTag(html, 'h5', 'heading-node') + html = ensureClassOnTag(html, 'h6', 'heading-node') + html = ensureClassOnTag(html, 'blockquote', 'block-node') + html = ensureClassOnTag(html, 'ul', 'list-node') + html = ensureClassOnTag(html, 'ol', 'list-node') + html = ensureClassOnTag(html, 'code', 'inline') + + return html +} + +// Generic schema for non-proprietary format +const GenericResumeSchema = z.object({ + personalInfo: z.object({ + name: z.string(), + email: z.string().optional(), + phone: z.string().optional(), + location: z.string().optional(), + summary: z.string().optional() + }), + education: z.array(z.object({ + institution: z.string(), + degree: z.string().optional(), + field: z.string().optional(), + startDate: z.string().describe('Use blank string if not found'), + endDate: z.string().describe('Use blank string if not found'), + gpa: z.string().optional() + })), + experience: z.array(z.object({ + company: z.string(), + position: z.string(), + startDate: z.string().describe('Use blank string if not found'), + endDate: z.string().describe('Use blank string if not found'), + description: z.string().describe('In a short paragraph, what did the user do in their experience and their impact'), + location: z.string().optional() + })), + skills: z.array(z.string()), + certifications: z.array(z.object({ + name: z.string(), + issuer: z.string().optional(), + date: z.string().optional() + })), + projects: z.array(z.object({ + name: z.string(), + description: z.string(), + technologies: z.array(z.string()).optional() + })) +}) + +export const parseResume = async ( + file: File, + format: 'proprietary' | 'generic' = 'proprietary' +): Promise => { + // Validate input + if (!file || !(file instanceof File)) { + throw new Error('Uploaded resume is not a file') + } + + if (file.size === 0) { + throw new Error('Uploaded resume is an empty file') + } + + // For proprietary format, output exactly our internal ResumeMutation schema + const schema = format === 'proprietary' ? ResumeMutationSchema : GenericResumeSchema + + const currentYear = new Date().getFullYear() + const prompt = format === 'proprietary' + ? `You are a strict JSON generator. Return ONLY JSON matching this schema, no prose: +{ + "sections": [ + { "type": "Education", "data": { "institution_name": string, "field_of_study": string, "degree": string|null, "country": string(ISO3), "started_from_month": string(1-12)|null|undefined, "started_from_year": string(YYYY)|null|undefined, "finished_at_month": string(1-12)|null|undefined, "finished_at_year": string(YYYY)|null|undefined, "current": boolean, "description": string|null } }, + { "type": "Experience", "data": { "company_name": string, "job_title": string, "country": string(ISO3), "city": string|null, "employment_type": "flt"|"prt"|"con"|"int"|"fre"|"sel"|"vol"|"tra", "started_from_month": string(1-12)|null|undefined, "started_from_year": string(YYYY)|null|undefined, "finished_at_month": string(1-12)|null|undefined, "finished_at_year": string(YYYY)|null|undefined, "current": boolean, "description": string|null } }, + { "type": "Skill", "data": { "skills": [ { "name": string, "level": "BEG"|"INT"|"ADV"|"EXP"|null, "category": string|null|undefined } ] } }, + { "type": "Project", "data": { "name": string, "category": string|null, "description": string|null, "role": string|null, "github_url": string|null, "live_url": string|null, "started_from_month": string(1-12)|null|undefined, "started_from_year": string(YYYY)|null|undefined, "finished_at_month": string(1-12)|null|undefined, "finished_at_year": string(YYYY)|null|undefined, "current": boolean|null, "skills_used": [ { "name": string, "category": string|null } ] } }, + { "type": "Certification", "data": { "name": string, "issuing_organization": string|undefined, "issue_date": string(YYYY-MM-DD) | null | undefined, "credential_url": string|null } } + ] +} + +Rules: +- Use null for unknown optional values where allowed; otherwise use empty string for required strings when unknown. +- Months MUST be numeric strings from "1" to "12" (do not use names like "Jul"). +- Years MUST be 4-digit numeric strings like "2021". +- Certification issue_date MUST be a date-only string in the exact format YYYY-MM-DD (e.g., "2024-03-01"). +- Use ISO3 country codes (e.g., USA, IND) when inferring countries. +- employment_type must be one of: flt, prt, con, int, fre, sel, vol, tra. +- level must be one of: BEG, INT, ADV, EXP, or null. +- The current calendar year is ${currentYear}. Do NOT output any future years. If you encounter a year greater than ${currentYear} in the source resume: + - For that date field, set the associated month and year fields to null, and + - Set the "current" flag to true for that section when available (Education, Experience, Project). +- For every "description" field (Education, Experience, Project), return a Tiptap-compatible HTML string and PREFER BULLETED LISTS: + - Default to unordered lists for multi-point content:
+ - Use a single paragraph only when the content is one succinct sentence:

+ - Allowed classes: paragraphs -> "text-node"; headings h1–h6 -> "heading-node"; blockquotes -> "block-node"; ul/ol -> "list-node"; inline code -> "inline". + - Do NOT return Markdown; return HTML only with the classes above. Keep list items concise, one idea per
  • . +- Return ONLY JSON. No backticks, no extra text.` + : 'Parse resume into: personalInfo, education, experience, skills, certifications, projects. Only return the JSON response. Do not include any additional texts, backticks or artifacts.' + + // Convert file to data URL format as required by AI SDK + const arrayBuffer = await file.arrayBuffer() + + try { + const result = await generateObject({ + model: google('gemini-2.5-flash-lite'), + schema, + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: prompt + }, + { + type: 'file', + data: arrayBuffer, + mediaType: file.type + } + ] + } + ], + // Add performance optimizations + temperature: 0 // More deterministic, faster + }) + + // Normalize any date-time strings to date-only for certification issue_date + if (format === 'proprietary') { + const payload = result.object as ResumeMutation + for (const section of payload.sections) { + if (section.type === 'Certification') { + section.data.issue_date = normalizeCertificationDate(section.data.issue_date) + } + + // Normalize description fields to Tiptap-compatible HTML + if (section.type === 'Education') { + section.data.description = normalizeDescriptionToTiptapHTML(section.data.description) + // Clamp future dates and set current flag when needed + const sYear = typeof section.data.started_from_year === 'string' ? parseInt(section.data.started_from_year, 10) : NaN + const fYear = typeof section.data.finished_at_year === 'string' ? parseInt(section.data.finished_at_year, 10) : NaN + if (!Number.isNaN(sYear) && sYear > currentYear) { + section.data.started_from_year = null + section.data.started_from_month = null + section.data.current = true + } + if (!Number.isNaN(fYear) && fYear > currentYear) { + section.data.finished_at_year = null + section.data.finished_at_month = null + section.data.current = true + } + } + if (section.type === 'Experience') { + section.data.description = normalizeDescriptionToTiptapHTML(section.data.description) + // Clamp future dates and set current flag when needed + const sYear = typeof section.data.started_from_year === 'string' ? parseInt(section.data.started_from_year, 10) : NaN + const fYear = typeof section.data.finished_at_year === 'string' ? parseInt(section.data.finished_at_year, 10) : NaN + if (!Number.isNaN(sYear) && sYear > currentYear) { + section.data.started_from_year = null + section.data.started_from_month = null + section.data.current = true + } + if (!Number.isNaN(fYear) && fYear > currentYear) { + section.data.finished_at_year = null + section.data.finished_at_month = null + section.data.current = true + } + } + if (section.type === 'Project') { + section.data.description = normalizeDescriptionToTiptapHTML(section.data.description) + // Clamp future dates and set current flag when needed + const sYear = typeof section.data.started_from_year === 'string' ? parseInt(section.data.started_from_year, 10) : NaN + const fYear = typeof section.data.finished_at_year === 'string' ? parseInt(section.data.finished_at_year, 10) : NaN + if (!Number.isNaN(sYear) && sYear > currentYear) { + section.data.started_from_year = null + section.data.started_from_month = null + section.data.current = true + } + if (!Number.isNaN(fYear) && fYear > currentYear) { + section.data.finished_at_year = null + section.data.finished_at_month = null + section.data.current = true + } + } + } + return payload + } + + return result.object + } catch (error) { + throw new Error(`Failed to parse resume: ${(error as Error).message}`) + } +} diff --git a/lib/resume/queries.ts b/lib/resume/queries.ts index 1a0d683d..47c4c8c2 100644 --- a/lib/resume/queries.ts +++ b/lib/resume/queries.ts @@ -1,6 +1,8 @@ import {queryOptions, useQuery} from '@tanstack/react-query' -import {BASE_RESUME_KEYS} from '@/lib/resume/key' -import {getResumeFromDB} from '@/lib/resume/actions' +import {BASE_RESUME_KEYS, RESUMES_KEYS} from '@/lib/resume/key' +import {getResumeFromDB, listResumesForUser} from '@/lib/resume/actions' +import {ResumeListItem} from '@/lib/resume/types' +import type {Resume} from '@/lib/resume/types' export const baseResumeQueryOptions = queryOptions({ queryKey: BASE_RESUME_KEYS, @@ -9,3 +11,28 @@ export const baseResumeQueryOptions = queryOptions({ export const useBaseResume = () => useQuery(baseResumeQueryOptions) + +export const resumesListQueryOptions = queryOptions({ + queryKey: RESUMES_KEYS, + queryFn: () => listResumesForUser() +}) + +export const useResumes = () => useQuery(resumesListQueryOptions) + +export const resumeByIdQueryOptions = (resumeId: string) => queryOptions({ + queryKey: ['resume', resumeId], + queryFn: () => getResumeFromDB(resumeId), + // Avoid refetch pause on error; keep polling to reduce flicker and recover automatically + retry: 3, + refetchInterval: (query) => { + const data = query.state.data as Resume | undefined + if (!data) return 2000 + const normalizedStatus = (data.status || '').toString().toLowerCase() + const doneStatuses = new Set(['success', 'failed', 'completed']) + const hasSections = Array.isArray(data.sections) && data.sections.length > 0 + if (hasSections || doneStatuses.has(normalizedStatus)) return false + return 2000 + } +}) + +export const useResumeById = (resumeId: string) => useQuery(resumeByIdQueryOptions(resumeId)) diff --git a/lib/resume/types.ts b/lib/resume/types.ts index b609e67d..f89f4206 100644 --- a/lib/resume/types.ts +++ b/lib/resume/types.ts @@ -1,30 +1,162 @@ -import {EducationSchema} from '@/lib/education/types' -import {ExperienceSchema} from '@/lib/experience/types' +import {EducationMutationSchema, EducationSchema} from '@/lib/education/types' +import {ExperienceMutationSchema, ExperienceSchema} from '@/lib/experience/types' import {UserInfoSchema} from '@/lib/user-info/types' +import {ResumeSkillSectionSchema, SkillLevelEnum} from '@/lib/skill/types' import {z} from 'zod' import {JobSchema} from '@/lib/job/types' +import {CertificationMutationSchema, CertificationSchema} from '@/lib/certification/types' +import {ProjectMutationSchema, ProjectSchema} from '@/lib/project/types' + +// Utility function to normalize thumbnail URLs by adding HTTPS protocol if missing +export const normalizeThumbnailUrl = (url: string | null | undefined): string | null => { + if (!url) return null + // If the URL doesn't start with http:// or https://, add https:// + if (!url.startsWith('http://') && !url.startsWith('https://')) { + return `https://${url}` + } + return url +} + +// Custom schema for thumbnail URLs that automatically adds HTTPS protocol if missing +const ThumbnailUrlSchema = z.string().transform((value) => { + return normalizeThumbnailUrl(value) || value +}).pipe(z.string().url()) /* * Base schema for Resume and its sections * Check https://outline.letraz.app/api-reference/resume-object/get-resume-by-id for more information */ -export const ResumeSectionSchema = z.object({ - id: z.string().describe('The unique identifier for the resume section.'), - resume: z.string().describe('The identifier of the resume this section belongs to.'), - index: z.number().describe('The position of this section within the resume.'), - type: z.enum(['Education', 'Experience']).describe('The type of the resume section, either Education or Experience.'), - data: z.union([EducationSchema, ExperienceSchema]).describe('The data associated with this section, either education or experience details.') -}) +export const ResumeSectionSchema = z.discriminatedUnion('type', [ + z.object({ + id: z.string().describe('The unique identifier for the resume section.'), + resume: z.string().describe('The identifier of the resume this section belongs to.'), + index: z.number().describe('The position of this section within the resume.'), + type: z.literal('Education').describe('The type of the resume section.'), + data: EducationSchema.describe('The education data associated with this section.') + }), + z.object({ + id: z.string().describe('The unique identifier for the resume section.'), + resume: z.string().describe('The identifier of the resume this section belongs to.'), + index: z.number().describe('The position of this section within the resume.'), + type: z.literal('Experience').describe('The type of the resume section.'), + data: ExperienceSchema.describe('The experience data associated with this section.') + }), + z.object({ + id: z.string().describe('The unique identifier for the resume section.'), + resume: z.string().describe('The identifier of the resume this section belongs to.'), + index: z.number().describe('The position of this section within the resume.'), + type: z.literal('Skill').describe('The type of the resume section.'), + data: ResumeSkillSectionSchema.describe('The skills data associated with this section.') + }), + z.object({ + id: z.string().describe('The unique identifier for the resume section.'), + resume: z.string().describe('The identifier of the resume this section belongs to.'), + index: z.number().describe('The position of this section within the resume.'), + type: z.literal('Project').describe('The type of the resume section.'), + data: ProjectSchema.describe('The project data associated with this section.') + }), + z.object({ + id: z.string().describe('The unique identifier for the resume section.'), + resume: z.string().describe('The identifier of the resume this section belongs to.'), + index: z.number().describe('The position of this section within the resume.'), + type: z.literal('Certification').describe('The type of the resume section.'), + data: CertificationSchema.describe('The certification data associated with this section.') + }) +]) export const ResumeSchema = z.object({ id: z.string().describe('The unique identifier for the resume.'), base: z.boolean().describe('Indicates if this is the base resume.'), user: UserInfoSchema.describe('The user information associated with the resume.'), job: JobSchema.describe('The job information associated with the resume.'), + status: z.string().nullable().optional().describe('Processing status at the root of resume.'), + thumbnail: ThumbnailUrlSchema.nullable().optional().describe('Thumbnail image URL for the resume preview.'), sections: z.array(ResumeSectionSchema).describe('The sections included in the resume, such as education and experience.') }) // Infer TypeScript types from the schema export type ResumeSection = z.infer export type Resume = z.infer + +// Tailor API response +export const TailorResumeResponseSchema = z.object({ + id: z.string(), + processing: z.boolean().optional(), + status: z.string().nullable().optional() +}) + +export type TailorResumeResponse = z.infer + +/** + * Mutation schemas for replacing a resume + * Consists of section mutation schemas only; excludes id, base, status, user, and job + */ +export const ResumeSkillSectionMutationSchema = z.object({ + skills: z.array(z.object({ + name: z.string().min(1), + level: SkillLevelEnum.nullable(), + category: z.string().nullish() + })) +}) + +export const ResumeSectionMutationSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('Education'), + data: EducationMutationSchema + }), + z.object({ + type: z.literal('Experience'), + data: ExperienceMutationSchema + }), + z.object({ + type: z.literal('Skill'), + data: ResumeSkillSectionMutationSchema + }), + z.object({ + type: z.literal('Project'), + data: ProjectMutationSchema + }), + z.object({ + type: z.literal('Certification'), + data: CertificationMutationSchema + }) +]) + +export const ResumeMutationSchema = z.object({ + sections: z.array(ResumeSectionMutationSchema) +}) + +export type ResumeMutation = z.infer + +/** + * Lightweight schema for listing resumes for a user + */ +const ResumeListItemCommonFields = z.object({ + id: z.string(), + user: z.string().optional(), + status: z.string().nullable().optional(), + thumbnail: ThumbnailUrlSchema.nullish().optional() +}) + +export const ResumeListItemSchema = z.discriminatedUnion('base', [ + ResumeListItemCommonFields.extend({ + base: z.literal(true), + job: JobSchema.partial({ + requirements: true, + responsibilities: true, + benefits: true, + status: true + }) + }), + ResumeListItemCommonFields.extend({ + base: z.literal(false), + job: JobSchema.extend({ + requirements: JobSchema.shape.requirements.optional(), + responsibilities: JobSchema.shape.responsibilities.optional(), + benefits: JobSchema.shape.benefits.optional() + }) + }) +]) + +export type ResumeListItem = z.infer diff --git a/lib/skill/__tests__/actions.test.ts b/lib/skill/__tests__/actions.test.ts new file mode 100644 index 00000000..3931e5fb --- /dev/null +++ b/lib/skill/__tests__/actions.test.ts @@ -0,0 +1,475 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import { + addSkillToResume, + createGlobalSkill, + fetchGlobalSkills, + fetchResumeSkills, + fetchSkillCategories, + removeSkillFromResume, + updateResumeSkill +} from '@/lib/skill/actions' +import {api} from '@/lib/config/api-client' +import {handleErrors} from '@/lib/misc/error-handler' +import {GlobalSkill, NewSkill, ResumeSkill, SkillMutation} from '@/lib/skill/types' + +// Mock dependencies +vi.mock('@/lib/config/api-client') +vi.mock('@/lib/misc/error-handler') + +const mockApi = vi.mocked(api) +const mockHandleErrors = vi.mocked(handleErrors) + +describe('Skill Actions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + describe('fetchGlobalSkills', () => { + const mockGlobalSkills: GlobalSkill[] = [ + { + id: '123e4567-e89b-12d3-a456-426614174000', + name: 'JavaScript', + category: 'Programming', + preferred: true, + alias: [], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z' + }, + { + id: '123e4567-e89b-12d3-a456-426614174001', + name: 'React', + category: 'Frontend', + preferred: true, + alias: [], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z' + } + ] + + it('should fetch global skills successfully', async () => { + mockApi.get.mockResolvedValue(mockGlobalSkills) + + const result = await fetchGlobalSkills() + + expect(mockApi.get).toHaveBeenCalledWith('/skill/') + expect(result).toEqual(mockGlobalSkills) + }) + + it('should handle API errors', async () => { + const error = new Error('API Error') + mockApi.get.mockRejectedValue(error) + mockHandleErrors.mockImplementation(() => { + throw new Error('Failed to fetch global skills: API Error') + }) + + await expect(fetchGlobalSkills()).rejects.toThrow('Failed to fetch global skills: API Error') + expect(mockHandleErrors).toHaveBeenCalledWith(error, 'fetch global skills') + }) + + it('should parse each skill with schema validation', async () => { + mockApi.get.mockResolvedValue(mockGlobalSkills) + + const result = await fetchGlobalSkills() + + expect(result).toHaveLength(2) + expect(result[0]).toMatchObject({ + id: expect.any(String), + name: expect.any(String), + category: expect.any(String), + preferred: expect.any(Boolean) + }) + }) + }) + + describe('fetchResumeSkills', () => { + const mockResumeSkills: ResumeSkill[] = [ + { + id: '123e4567-e89b-12d3-a456-426614174002', + skill: { + id: '123e4567-e89b-12d3-a456-426614174000', + name: 'JavaScript', + category: 'Programming', + preferred: true, + alias: [], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z' + }, + resume_section: '123e4567-e89b-12d3-a456-426614174003', + level: 'ADV' + } + ] + + it('should fetch resume skills with default resumeId', async () => { + mockApi.get.mockResolvedValue(mockResumeSkills) + + const result = await fetchResumeSkills() + + expect(mockApi.get).toHaveBeenCalledWith('/resume/base/skill/') + expect(result).toEqual(mockResumeSkills) + }) + + it('should fetch resume skills with custom resumeId', async () => { + mockApi.get.mockResolvedValue(mockResumeSkills) + + const result = await fetchResumeSkills('custom-resume-id') + + expect(mockApi.get).toHaveBeenCalledWith('/resume/custom-resume-id/skill/') + expect(result).toEqual(mockResumeSkills) + }) + + it('should handle API errors', async () => { + const error = new Error('API Error') + mockApi.get.mockRejectedValue(error) + mockHandleErrors.mockImplementation(() => { + throw new Error('Failed to fetch resume skills: API Error') + }) + + await expect(fetchResumeSkills()).rejects.toThrow('Failed to fetch resume skills: API Error') + expect(mockHandleErrors).toHaveBeenCalledWith(error, 'fetch resume skills') + }) + }) + + describe('addSkillToResume', () => { + const mockResumeSkill: ResumeSkill = { + id: '123e4567-e89b-12d3-a456-426614174002', + skill: { + id: '123e4567-e89b-12d3-a456-426614174000', + name: 'JavaScript', + category: 'Programming', + preferred: true, + alias: [], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z' + }, + resume_section: '123e4567-e89b-12d3-a456-426614174003', + level: 'ADV' + } + + it('should add existing skill to resume', async () => { + const skillData: SkillMutation = { + skill_id: '123e4567-e89b-12d3-a456-426614174000', + level: 'ADV', + category: 'Programming' + } + + const mockGlobalSkill: GlobalSkill = { + id: '123e4567-e89b-12d3-a456-426614174000', + name: 'JavaScript', + category: 'Programming', + preferred: true, + alias: [], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z' + } + + mockApi.get.mockResolvedValue(mockGlobalSkill) + mockApi.post.mockResolvedValue(mockResumeSkill) + + const result = await addSkillToResume(skillData) + + expect(mockApi.get).toHaveBeenCalledWith('/skill/123e4567-e89b-12d3-a456-426614174000/') + expect(mockApi.post).toHaveBeenCalledWith('/resume/base/skill/', { + level: 'ADV', + name: 'JavaScript', + category: 'Programming' + }) + expect(result).toEqual(mockResumeSkill) + }) + + it('should add custom skill to resume', async () => { + const skillData: SkillMutation = { + skill_id: 'custom:TypeScript', + level: 'INT', + category: 'Programming' + } + + mockApi.post.mockResolvedValue(mockResumeSkill) + + const result = await addSkillToResume(skillData) + + expect(mockApi.post).toHaveBeenCalledWith('/resume/base/skill/', { + level: 'INT', + name: 'TypeScript', + category: 'Programming' + }) + expect(result).toEqual(mockResumeSkill) + }) + + it('should handle fallback when individual skill fetch fails', async () => { + const skillData: SkillMutation = { + skill_id: '123e4567-e89b-12d3-a456-426614174000', + level: 'ADV' + } + + const allSkills: GlobalSkill[] = [ + { + id: '123e4567-e89b-12d3-a456-426614174000', + name: 'JavaScript', + category: 'Programming', + preferred: true, + alias: [], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z' + } + ] + + mockApi.get + .mockRejectedValueOnce(new Error('Individual skill fetch failed')) + .mockResolvedValueOnce(allSkills) + mockApi.post.mockResolvedValue(mockResumeSkill) + + const result = await addSkillToResume(skillData) + + expect(mockApi.get).toHaveBeenCalledTimes(2) + expect(mockApi.get).toHaveBeenNthCalledWith(1, '/skill/123e4567-e89b-12d3-a456-426614174000/') + expect(mockApi.get).toHaveBeenNthCalledWith(2, '/skill/') + expect(result).toEqual(mockResumeSkill) + }) + + it('should not include empty category in payload', async () => { + const skillData: SkillMutation = { + skill_id: 'custom:TypeScript', + level: 'INT', + category: ' ' // Empty/whitespace category + } + + mockApi.post.mockResolvedValue(mockResumeSkill) + + await addSkillToResume(skillData) + + expect(mockApi.post).toHaveBeenCalledWith('/resume/base/skill/', { + level: 'INT', + name: 'TypeScript' + // category should not be included + }) + }) + + it('should handle API errors', async () => { + const skillData: SkillMutation = { + skill_id: 'custom:TypeScript', + level: 'INT' + } + + const error = new Error('API Error') + mockApi.post.mockRejectedValue(error) + mockHandleErrors.mockImplementation(() => { + throw new Error('Failed to add skill to resume: API Error') + }) + + await expect(addSkillToResume(skillData)).rejects.toThrow('Failed to add skill to resume: API Error') + expect(mockHandleErrors).toHaveBeenCalledWith(error, 'add skill to resume') + }) + }) + + describe('updateResumeSkill', () => { + const mockUpdatedSkill: ResumeSkill = { + id: '123e4567-e89b-12d3-a456-426614174002', + skill: { + id: '123e4567-e89b-12d3-a456-426614174000', + name: 'JavaScript', + category: 'Programming', + preferred: true, + alias: [], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z' + }, + resume_section: '123e4567-e89b-12d3-a456-426614174003', + level: 'EXP' + } + + it('should update resume skill level', async () => { + const skillData: Partial = { + level: 'EXP' + } + + mockApi.patch.mockResolvedValue(mockUpdatedSkill) + + const result = await updateResumeSkill('skill-id', skillData) + + expect(mockApi.patch).toHaveBeenCalledWith('/resume/base/skill/skill-id/', { + level: 'EXP' + }) + expect(result).toEqual(mockUpdatedSkill) + }) + + it('should update custom skill name', async () => { + const skillData: Partial = { + skill_id: 'custom:TypeScript', + category: 'Programming' + } + + mockApi.patch.mockResolvedValue(mockUpdatedSkill) + + const result = await updateResumeSkill('skill-id', skillData) + + expect(mockApi.patch).toHaveBeenCalledWith('/resume/base/skill/skill-id/', { + name: 'TypeScript', + category: 'Programming' + }) + expect(result).toEqual(mockUpdatedSkill) + }) + + it('should update existing skill with fallback', async () => { + const skillData: Partial = { + skill_id: '123e4567-e89b-12d3-a456-426614174000' + } + + const allSkills: GlobalSkill[] = [ + { + id: '123e4567-e89b-12d3-a456-426614174000', + name: 'JavaScript', + category: 'Programming', + preferred: true, + alias: [], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z' + } + ] + + mockApi.get + .mockRejectedValueOnce(new Error('Individual skill fetch failed')) + .mockResolvedValueOnce(allSkills) + mockApi.patch.mockResolvedValue(mockUpdatedSkill) + + const result = await updateResumeSkill('skill-id', skillData) + + expect(mockApi.patch).toHaveBeenCalledWith('/resume/base/skill/skill-id/', { + name: 'JavaScript' + }) + expect(result).toEqual(mockUpdatedSkill) + }) + + it('should handle empty category by setting to null', async () => { + const skillData: Partial = { + category: ' ' // Empty/whitespace category + } + + mockApi.patch.mockResolvedValue(mockUpdatedSkill) + + await updateResumeSkill('skill-id', skillData) + + expect(mockApi.patch).toHaveBeenCalledWith('/resume/base/skill/skill-id/', { + category: null + }) + }) + + it('should handle API errors', async () => { + const skillData: Partial = { + level: 'EXP' + } + + const error = new Error('API Error') + mockApi.patch.mockRejectedValue(error) + mockHandleErrors.mockImplementation(() => { + throw new Error('Failed to update resume skill: API Error') + }) + + await expect(updateResumeSkill('skill-id', skillData)).rejects.toThrow('Failed to update resume skill: API Error') + expect(mockHandleErrors).toHaveBeenCalledWith(error, 'update resume skill') + }) + }) + + describe('removeSkillFromResume', () => { + it('should remove skill from resume', async () => { + mockApi.delete.mockResolvedValue(undefined) + + await removeSkillFromResume('skill-id') + + expect(mockApi.delete).toHaveBeenCalledWith('/resume/base/skill/skill-id/') + }) + + it('should remove skill from custom resume', async () => { + mockApi.delete.mockResolvedValue(undefined) + + await removeSkillFromResume('skill-id', 'custom-resume-id') + + expect(mockApi.delete).toHaveBeenCalledWith('/resume/custom-resume-id/skill/skill-id/') + }) + + it('should handle API errors', async () => { + const error = new Error('API Error') + mockApi.delete.mockRejectedValue(error) + mockHandleErrors.mockImplementation(() => { + throw new Error('Failed to remove skill from resume: API Error') + }) + + await expect(removeSkillFromResume('skill-id')).rejects.toThrow('Failed to remove skill from resume: API Error') + expect(mockHandleErrors).toHaveBeenCalledWith(error, 'remove skill from resume') + }) + }) + + describe('createGlobalSkill', () => { + const mockNewSkill: NewSkill = { + name: 'Vue.js', + category: 'Frontend', + preferred: false + } + + const mockCreatedSkill: GlobalSkill = { + id: '123e4567-e89b-12d3-a456-426614174004', + name: 'Vue.js', + category: 'Frontend', + preferred: false, + alias: [], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z' + } + + it('should create global skill successfully', async () => { + mockApi.post.mockResolvedValue(mockCreatedSkill) + + const result = await createGlobalSkill(mockNewSkill) + + expect(mockApi.post).toHaveBeenCalledWith('/skill/', mockNewSkill) + expect(result).toEqual(mockCreatedSkill) + }) + + it('should handle API errors', async () => { + const error = new Error('API Error') + mockApi.post.mockRejectedValue(error) + mockHandleErrors.mockImplementation(() => { + throw new Error('Failed to create global skill: API Error') + }) + + await expect(createGlobalSkill(mockNewSkill)).rejects.toThrow('Failed to create global skill: API Error') + expect(mockHandleErrors).toHaveBeenCalledWith(error, 'create global skill') + }) + }) + + describe('fetchSkillCategories', () => { + const mockCategories = ['Programming', 'Frontend', 'Backend', 'Database'] + + it('should fetch skill categories with default resumeId', async () => { + mockApi.get.mockResolvedValue(mockCategories) + + const result = await fetchSkillCategories() + + expect(mockApi.get).toHaveBeenCalledWith('/resume/base/skill/categories/') + expect(result).toEqual(mockCategories) + }) + + it('should fetch skill categories with custom resumeId', async () => { + mockApi.get.mockResolvedValue(mockCategories) + + const result = await fetchSkillCategories('custom-resume-id') + + expect(mockApi.get).toHaveBeenCalledWith('/resume/custom-resume-id/skill/categories/') + expect(result).toEqual(mockCategories) + }) + + it('should handle API errors', async () => { + const error = new Error('API Error') + mockApi.get.mockRejectedValue(error) + mockHandleErrors.mockImplementation(() => { + throw new Error('Failed to fetch skill categories: API Error') + }) + + await expect(fetchSkillCategories()).rejects.toThrow('Failed to fetch skill categories: API Error') + expect(mockHandleErrors).toHaveBeenCalledWith(error, 'fetch skill categories') + }) + }) +}) diff --git a/lib/skill/__tests__/mutations.test.ts b/lib/skill/__tests__/mutations.test.ts new file mode 100644 index 00000000..22db3abe --- /dev/null +++ b/lib/skill/__tests__/mutations.test.ts @@ -0,0 +1,137 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' + +// Mock the mutations module +vi.mock('../mutations', () => ({ + useAddSkillMutation: vi.fn(), + useUpdateSkillMutation: vi.fn(), + useRemoveSkillMutation: vi.fn(), + useCreateGlobalSkillMutation: vi.fn() +})) + +// Mock the actions +vi.mock('../actions') + +describe('Skill Mutations', () => { + beforeEach(async () => { + vi.clearAllMocks() + + // Setup default mock implementations + const mutations = vi.mocked(await import('../mutations')) + + mutations.useAddSkillMutation.mockReturnValue({ + mutateAsync: vi.fn(() => Promise.resolve()), + isPending: false, + isError: false, + error: null + } as any) + + mutations.useUpdateSkillMutation.mockReturnValue({ + mutateAsync: vi.fn(() => Promise.resolve()), + isPending: false, + isError: false, + error: null + } as any) + + mutations.useRemoveSkillMutation.mockReturnValue({ + mutateAsync: vi.fn(() => Promise.resolve()), + isPending: false, + isError: false, + error: null + } as any) + + mutations.useCreateGlobalSkillMutation.mockReturnValue({ + mutateAsync: vi.fn(() => Promise.resolve()), + isPending: false, + isError: false, + error: null + } as any) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('useAddSkillMutation', () => { + it('should be a function', async () => { + const {useAddSkillMutation} = await import('../mutations') + expect(typeof useAddSkillMutation).toBe('function') + }) + + it('should be mockable', async () => { + const {useAddSkillMutation} = vi.mocked(await import('../mutations')) + + expect(useAddSkillMutation).toHaveBeenCalledTimes(0) + + // Call the mocked function + const result = useAddSkillMutation() + + expect(useAddSkillMutation).toHaveBeenCalledTimes(1) + expect(result).toBeDefined() + expect(result.mutateAsync).toBeDefined() + expect(result.isPending).toBe(false) + }) + }) + + describe('useUpdateSkillMutation', () => { + it('should be a function', async () => { + const {useUpdateSkillMutation} = await import('../mutations') + expect(typeof useUpdateSkillMutation).toBe('function') + }) + + it('should be mockable', async () => { + const {useUpdateSkillMutation} = vi.mocked(await import('../mutations')) + + expect(useUpdateSkillMutation).toHaveBeenCalledTimes(0) + + // Call the mocked function + const result = useUpdateSkillMutation() + + expect(useUpdateSkillMutation).toHaveBeenCalledTimes(1) + expect(result).toBeDefined() + expect(result.mutateAsync).toBeDefined() + expect(result.isPending).toBe(false) + }) + }) + + describe('useRemoveSkillMutation', () => { + it('should be a function', async () => { + const {useRemoveSkillMutation} = await import('../mutations') + expect(typeof useRemoveSkillMutation).toBe('function') + }) + + it('should be mockable', async () => { + const {useRemoveSkillMutation} = vi.mocked(await import('../mutations')) + + expect(useRemoveSkillMutation).toHaveBeenCalledTimes(0) + + // Call the mocked function + const result = useRemoveSkillMutation() + + expect(useRemoveSkillMutation).toHaveBeenCalledTimes(1) + expect(result).toBeDefined() + expect(result.mutateAsync).toBeDefined() + expect(result.isPending).toBe(false) + }) + }) + + describe('useCreateGlobalSkillMutation', () => { + it('should be a function', async () => { + const {useCreateGlobalSkillMutation} = await import('../mutations') + expect(typeof useCreateGlobalSkillMutation).toBe('function') + }) + + it('should be mockable', async () => { + const {useCreateGlobalSkillMutation} = vi.mocked(await import('../mutations')) + + expect(useCreateGlobalSkillMutation).toHaveBeenCalledTimes(0) + + // Call the mocked function + const result = useCreateGlobalSkillMutation() + + expect(useCreateGlobalSkillMutation).toHaveBeenCalledTimes(1) + expect(result).toBeDefined() + expect(result.mutateAsync).toBeDefined() + expect(result.isPending).toBe(false) + }) + }) +}) diff --git a/lib/skill/__tests__/queries.test.ts b/lib/skill/__tests__/queries.test.ts new file mode 100644 index 00000000..45e6462a --- /dev/null +++ b/lib/skill/__tests__/queries.test.ts @@ -0,0 +1,251 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import { + globalSkillsQueryOptions, + resumeSkillsQueryOptions, + skillCategoriesQueryOptions, + useCurrentResumeSkills, + useGlobalSkills, + useSkillCategories +} from '@/lib/skill/queries' +import {fetchGlobalSkills, fetchResumeSkills, fetchSkillCategories} from '@/lib/skill/actions' +import {GlobalSkill, ResumeSkill} from '@/lib/skill/types' + +// Mock the actions +vi.mock('../actions') + +const mockFetchGlobalSkills = vi.mocked(fetchGlobalSkills) +const mockFetchResumeSkills = vi.mocked(fetchResumeSkills) +const mockFetchSkillCategories = vi.mocked(fetchSkillCategories) + +// Mock data +const mockGlobalSkills: GlobalSkill[] = [ + { + id: '123e4567-e89b-12d3-a456-426614174000', + name: 'JavaScript', + category: 'Programming Languages', + preferred: true, + alias: [ + { + id: '123e4567-e89b-12d3-a456-426614174001', + name: 'JS', + category: 'Programming Languages', + preferred: false, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + } + ], + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + }, + { + id: '123e4567-e89b-12d3-a456-426614174002', + name: 'React', + category: 'Frontend Frameworks', + preferred: true, + alias: [ + { + id: '123e4567-e89b-12d3-a456-426614174003', + name: 'React.js', + category: 'Frontend Frameworks', + preferred: false, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + } + ], + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + } +] + +const mockResumeSkills: ResumeSkill[] = [ + { + id: '123e4567-e89b-12d3-a456-426614174004', + skill: mockGlobalSkills[0], + resume_section: 'section-1', + level: 'INT' + }, + { + id: '123e4567-e89b-12d3-a456-426614174005', + skill: mockGlobalSkills[1], + resume_section: 'section-1', + level: 'ADV' + } +] + +const mockSkillCategories: string[] = [ + 'Programming Languages', + 'Frontend Frameworks', + 'Backend Technologies', + 'Databases' +] + +describe('Skill Queries', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('globalSkillsQueryOptions', () => { + it('should have correct query key', () => { + const options = globalSkillsQueryOptions + + expect(options.queryKey).toEqual(['skills', 'global']) + }) + + it('should have correct query function', () => { + const options = globalSkillsQueryOptions + + expect(options.queryFn).toBeInstanceOf(Function) + }) + + it('should call fetchGlobalSkills when query function is executed', async () => { + mockFetchGlobalSkills.mockResolvedValue(mockGlobalSkills) + + const mockContext = { + queryKey: ['skills', 'global'], + client: {} as any, + signal: {} as AbortSignal, + meta: undefined + } + + const result = await globalSkillsQueryOptions.queryFn!(mockContext) + + expect(mockFetchGlobalSkills).toHaveBeenCalledTimes(1) + expect(result).toEqual(mockGlobalSkills) + }) + }) + + describe('useGlobalSkills', () => { + it('should use the correct query options', () => { + const hook = useGlobalSkills + + // Test that the hook is a function + expect(typeof hook).toBe('function') + }) + }) + + describe('resumeSkillsQueryOptions', () => { + it('should have correct query key with default resumeId', () => { + const options = resumeSkillsQueryOptions() + + expect(options.queryKey).toEqual(['skills', 'resume', 'base']) + }) + + it('should have correct query key with custom resumeId', () => { + const options = resumeSkillsQueryOptions('custom-resume-123') + + expect(options.queryKey).toEqual(['skills', 'resume', 'custom-resume-123']) + }) + + it('should have correct query function with default resumeId', () => { + const options = resumeSkillsQueryOptions() + + expect(options.queryFn).toBeInstanceOf(Function) + }) + + it('should call fetchResumeSkills with correct resumeId', async () => { + mockFetchResumeSkills.mockResolvedValue(mockResumeSkills) + + const options = resumeSkillsQueryOptions('custom-resume-id') + const mockContext = { + queryKey: ['skills', 'resume', 'custom-resume-id'], + client: {} as any, + signal: {} as AbortSignal, + meta: undefined + } + + await options.queryFn!(mockContext) + + expect(mockFetchResumeSkills).toHaveBeenCalledWith('custom-resume-id') + }) + + it('should call fetchResumeSkills with default resumeId when none provided', async () => { + mockFetchResumeSkills.mockResolvedValue(mockResumeSkills) + + const options = resumeSkillsQueryOptions() + const mockContext = { + queryKey: ['skills', 'resume', 'base'], + client: {} as any, + signal: {} as AbortSignal, + meta: undefined + } + + await options.queryFn!(mockContext) + + expect(mockFetchResumeSkills).toHaveBeenCalledWith('base') + }) + }) + + describe('useCurrentResumeSkills', () => { + it('should use resumeSkillsQueryOptions with default resumeId', () => { + const hook = useCurrentResumeSkills + + // Test that the hook is a function + expect(typeof hook).toBe('function') + }) + }) + + describe('skillCategoriesQueryOptions', () => { + it('should have correct query key with default resumeId', () => { + const options = skillCategoriesQueryOptions() + + expect(options.queryKey).toEqual(['skills', 'categories', 'base']) + }) + + it('should have correct query key with custom resumeId', () => { + const options = skillCategoriesQueryOptions('custom-resume-789') + + expect(options.queryKey).toEqual(['skills', 'categories', 'custom-resume-789']) + }) + + it('should have correct query function', () => { + const options = skillCategoriesQueryOptions() + + expect(options.queryFn).toBeInstanceOf(Function) + }) + + it('should call fetchSkillCategories with correct resumeId', async () => { + mockFetchSkillCategories.mockResolvedValue(['Programming', 'Design']) + + const options = skillCategoriesQueryOptions('custom-resume-id') + const mockContext = { + queryKey: ['skills', 'categories', 'custom-resume-id'], + client: {} as any, + signal: {} as AbortSignal, + meta: undefined + } + + await options.queryFn!(mockContext) + + expect(mockFetchSkillCategories).toHaveBeenCalledWith('custom-resume-id') + }) + + it('should call fetchSkillCategories with default resumeId when none provided', async () => { + mockFetchSkillCategories.mockResolvedValue(['Programming', 'Design']) + + const options = skillCategoriesQueryOptions() + const mockContext = { + queryKey: ['skills', 'categories', 'base'], + client: {} as any, + signal: {} as AbortSignal, + meta: undefined + } + + await options.queryFn!(mockContext) + + expect(mockFetchSkillCategories).toHaveBeenCalledWith('base') + }) + }) + + describe('useSkillCategories', () => { + it('should use skillCategoriesQueryOptions with default resumeId', () => { + const hook = useSkillCategories + + // Test that the hook is a function + expect(typeof hook).toBe('function') + }) + }) +}) diff --git a/lib/skill/actions.ts b/lib/skill/actions.ts index fc364091..28ff50ab 100644 --- a/lib/skill/actions.ts +++ b/lib/skill/actions.ts @@ -1,6 +1,6 @@ 'use server' -import {GlobalSkill, GlobalSkillSchema, ResumeSkill, ResumeSkillSchema, SkillMutation} from '@/lib/skill/types' +import {GlobalSkill, GlobalSkillSchema, ResumeSkill, ResumeSkillSchema, SkillMutation, NewSkill} from '@/lib/skill/types' import {api} from '@/lib/config/api-client' import {handleErrors} from '@/lib/misc/error-handler' @@ -13,7 +13,7 @@ export const fetchGlobalSkills = async (): Promise => { try { const data = await api.get('/skill/') - // Map the data and log each skill parsing result + // Parse each skill const parsedSkills = data.map(skill => GlobalSkillSchema.parse(skill)) return parsedSkills @@ -55,13 +55,33 @@ export const addSkillToResume = async ( // Handle custom skill vs existing skill if (skillData.skill_id.startsWith('custom:')) { - // For custom skills, extract the name from skill_id and use the provided category apiPayload.name = skillData.skill_id.substring(7) // Remove 'custom:' prefix - apiPayload.category = skillData.category + if (skillData.category?.trim()) { + apiPayload.category = skillData.category.trim() + } } else { - // For existing skills, send the skill_id - apiPayload.skill_id = skillData.skill_id - apiPayload.category = skillData.category + let skillDetails: GlobalSkill | null = null + try { + skillDetails = await api.get(`/skill/${skillData.skill_id}/`) + } catch { + // Fallback: fetch all global skills and find the matching one + const allSkills = await api.get('/skill/') + skillDetails = allSkills.find(s => s.id === skillData.skill_id) || null + } + if (skillDetails) { + apiPayload.name = skillDetails.name + if (!skillData.category && skillDetails.category) { + apiPayload.category = skillDetails.category + } + } + } + + /* + * Only include category when it has a non-empty value. Sending an empty + * string causes a validation error on the backend for existing skills. + */ + if (skillData.category?.trim()) { + apiPayload.category = skillData.category.trim() } const data = await api.post(`/resume/${resumeId}/skill/`, apiPayload) @@ -96,17 +116,30 @@ export const updateResumeSkill = async ( // Handle skill name changes if (skillData.skill_id) { if (skillData.skill_id.startsWith('custom:')) { - // For custom skills, extract the name - apiPayload.name = skillData.skill_id.substring(7) // Remove 'custom:' prefix + // For custom skills, extract the name from the custom format + apiPayload.name = skillData.skill_id.substring(7) } else { - // For existing skills, use the skill_id - apiPayload.skill_id = skillData.skill_id + // For existing global skills, fetch the skill details to get the name + let skillDetails: GlobalSkill | null = null + try { + skillDetails = await api.get(`/skill/${skillData.skill_id}/`) + } catch { + // Fallback: fetch all global skills and find the matching one + const allSkills = await api.get('/skill/') + skillDetails = allSkills.find(s => s.id === skillData.skill_id) || null + } + if (skillDetails) { + apiPayload.name = skillDetails.name + } } } - // Include category if provided + /* + * Always include category if provided, even if empty (user might want to clear it) + * Use the category from the form, not from the global skill + */ if (skillData.category !== undefined) { - apiPayload.category = skillData.category + apiPayload.category = skillData.category.trim() || null } const data = await api.patch(`/resume/${resumeId}/skill/${skillId}/`, apiPayload) @@ -133,3 +166,33 @@ export const removeSkillFromResume = async ( handleErrors(error, 'remove skill from resume') } } + +/** + * Creates a new global skill in the database. + * @param {NewSkill} skillData - The skill data to create. + * @returns {Promise} The newly created global skill. + * @throws {Error} If API request fails. + */ +export const createGlobalSkill = async (skillData: NewSkill): Promise => { + try { + const data = await api.post('/skill/', skillData) + return GlobalSkillSchema.parse(data) + } catch (error) { + return handleErrors(error, 'create global skill') + } +} + +/** + * Fetches all skill categories for a resume. + * @param {string} [resumeId='base'] - The ID of the resume. Defaults to 'base'. + * @returns {Promise} Array of category names. + * @throws {Error} If API request fails. + */ +export const fetchSkillCategories = async (resumeId: string = 'base'): Promise => { + try { + const data = await api.get(`/resume/${resumeId}/skill/categories/`) + return data + } catch (error) { + return handleErrors(error, 'fetch skill categories') + } +} diff --git a/lib/skill/hooks.ts b/lib/skill/hooks.ts index e75276cd..fdc2b153 100644 --- a/lib/skill/hooks.ts +++ b/lib/skill/hooks.ts @@ -1,26 +1,8 @@ -import {useState, useEffect, useMemo} from 'react' - -interface SkillAlias { - id: string - category: string | null - name: string - preferred: boolean - created_at: string - updated_at: string -} - -export interface Skill { - id: string - name: string - category: string | null - preferred: boolean - alias: SkillAlias[] - created_at: string - updated_at: string -} +import {useMemo} from 'react' +import {GlobalSkill} from '@/lib/skill/types' interface UseSkillSearchProps { - skills: Skill[] + skills: GlobalSkill[] excludeSkillIds?: string[] searchQuery: string } @@ -49,8 +31,6 @@ export const useSkillSearch = ({skills, excludeSkillIds = [], searchQuery}: UseS name: s.name, preferred: s.preferred })) - // eslint-disable-next-line no-console - console.log(`Available skills (${availableSkills.length}):`, debugSkills) } // If no search query, return all skills sorted by preferred first, then alphabetically diff --git a/lib/skill/mutations.ts b/lib/skill/mutations.ts index 7e9785e1..9855ca4f 100644 --- a/lib/skill/mutations.ts +++ b/lib/skill/mutations.ts @@ -1,13 +1,13 @@ import {MutationOptions, useMutation} from '@tanstack/react-query' -import {ResumeSkill, SkillMutation} from '@/lib/skill/types' -import {addSkillToResume, removeSkillFromResume, updateResumeSkill} from '@/lib/skill/actions' +import {GlobalSkill, NewSkill, ResumeSkill, SkillMutation} from '@/lib/skill/types' +import {addSkillToResume, createGlobalSkill, removeSkillFromResume, updateResumeSkill} from '@/lib/skill/actions' /** * Mutation hook for adding a skill to the resume */ -export const useAddSkillMutation = (options?: MutationOptions) => { +export const useAddSkillMutation = (options?: MutationOptions) => { return useMutation({ - mutationFn: (skillData: SkillMutation) => addSkillToResume(skillData), + mutationFn: ({data, resumeId}) => addSkillToResume(data, resumeId || 'base'), ...options }) } @@ -15,9 +15,9 @@ export const useAddSkillMutation = (options?: MutationOptions }>) => { +export const useUpdateSkillMutation = (options?: MutationOptions, resumeId?: string }>) => { return useMutation({ - mutationFn: ({id, data}) => updateResumeSkill(id, data), + mutationFn: ({id, data, resumeId}) => updateResumeSkill(id, data, resumeId || 'base'), ...options }) } @@ -25,32 +25,19 @@ export const useUpdateSkillMutation = (options?: MutationOptions) => { +export const useRemoveSkillMutation = (options?: MutationOptions) => { return useMutation({ - mutationFn: (skillId: string) => removeSkillFromResume(skillId), + mutationFn: ({id, resumeId}) => removeSkillFromResume(id, resumeId || 'base'), ...options }) } -// Add a mutation for creating a new global skill -export const useCreateGlobalSkillMutation = (options: any = {}) => { +/** + * Mutation hook for creating a new global skill + */ +export const useCreateGlobalSkillMutation = (options?: MutationOptions) => { return useMutation({ - mutationFn: async (data: { name: string; category?: string; preferred?: boolean }) => { - const response = await fetch('/api/v1/skill/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(data) - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.message || 'Failed to create skill') - } - - return response.json() - }, + mutationFn: (skillData: NewSkill) => createGlobalSkill(skillData), ...options }) } diff --git a/lib/skill/queries.ts b/lib/skill/queries.ts index 2c2262c0..30ae3a4f 100644 --- a/lib/skill/queries.ts +++ b/lib/skill/queries.ts @@ -1,5 +1,5 @@ import {queryOptions, useQuery} from '@tanstack/react-query' -import {fetchGlobalSkills, fetchResumeSkills} from '@/lib/skill/actions' +import {fetchGlobalSkills, fetchResumeSkills, fetchSkillCategories} from '@/lib/skill/actions' import {GlobalSkill, ResumeSkill} from '@/lib/skill/types' /** @@ -30,8 +30,25 @@ export const resumeSkillsQueryOptions = (resumeId: string = 'base') => queryOpti /** * Query hook for fetching skills associated with the current resume */ -export const useCurrentResumeSkills = () => { +export const useCurrentResumeSkills = (resumeId: string) => { return useQuery({ - ...resumeSkillsQueryOptions() + ...resumeSkillsQueryOptions(resumeId) + }) +} + +/** + * Query options for fetching skill categories for a resume + */ +export const skillCategoriesQueryOptions = (resumeId: string = 'base') => queryOptions({ + queryKey: ['skills', 'categories', resumeId], + queryFn: () => fetchSkillCategories(resumeId) +}) + +/** + * Query hook for fetching skill categories for the current resume + */ +export const useSkillCategories = (resumeId: string) => { + return useQuery({ + ...skillCategoriesQueryOptions(resumeId) }) } diff --git a/lib/skill/types.ts b/lib/skill/types.ts index 6fe77975..4563aebb 100644 --- a/lib/skill/types.ts +++ b/lib/skill/types.ts @@ -58,41 +58,6 @@ export const skillLevels = [ } ] -export const employmentTypes = [ - { - value: 'FULL_TIME', - label: 'Full-time' - }, - { - value: 'PART_TIME', - label: 'Part-time' - }, - { - value: 'SELF_EMPLOYED', - label: 'Self-employed' - }, - { - value: 'FREELANCE', - label: 'Freelance' - }, - { - value: 'CONTRACT', - label: 'Contract' - }, - { - value: 'INTERNSHIP', - label: 'Internship' - }, - { - value: 'APPRENTICESHIP', - label: 'Apprenticeship' - }, - { - value: 'SEASONAL', - label: 'Seasonal' - } -] - /* * Schema for Resume Skill (the connection between a skill and a resume) */ @@ -103,6 +68,10 @@ export const ResumeSkillSchema = z.object({ level: SkillLevelEnum.nullable().describe('The proficiency level of the skill.') }) +export const ResumeSkillSectionSchema = z.object({ + skills: z.array(ResumeSkillSchema) +}) + /* * Schema for adding a new skill to a resume */ @@ -110,7 +79,7 @@ export const SkillMutationSchema = z.object({ skill_id: z.string({ required_error: 'Please select a skill' }), - level: z.string().nullable(), + level: SkillLevelEnum.nullable(), category: z.string().optional() }) @@ -120,6 +89,7 @@ export const SkillMutationSchema = z.object({ export type SkillAlias = z.infer export type GlobalSkill = z.infer export type ResumeSkill = z.infer +export type ResumeSkillSection = z.infer export type SkillMutation = z.infer export type SkillLevel = z.infer diff --git a/lib/user-info/__tests__/actions.test.ts b/lib/user-info/__tests__/actions.test.ts new file mode 100644 index 00000000..19498277 --- /dev/null +++ b/lib/user-info/__tests__/actions.test.ts @@ -0,0 +1,274 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest' +import {addOrUpdateUserInfoToDB, getPersonalInfoFromDB} from '@/lib/user-info/actions' +import {api} from '@/lib/config/api-client' +import {apiDateToDate, dateToApiFormat} from '@/lib/utils' + +// Mock dependencies +vi.mock('@/lib/config/api-client', () => ({ + api: { + post: vi.fn(), + patch: vi.fn(), + get: vi.fn() + } +})) + +vi.mock('@/lib/utils', () => ({ + dateToApiFormat: vi.fn(), + apiDateToDate: vi.fn() +})) + +vi.mock('@/lib/misc/error-handler', () => ({ + handleErrors: vi.fn() +})) + +describe('user-info actions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('addOrUpdateUserInfoToDB', () => { + it('should successfully add or update user info', async () => { + const mockUserInfo = { + title: 'Mr.', + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + phone: '1234567890', + dob: new Date('1990-01-01'), + nationality: 'American', + address: '123 Main St', + city: 'New York', + postal: '10001', + country: { + code: 'USA', + name: 'United States' + }, + website: 'https://example.com', + profile_text: 'Software developer' + } + + const mockApiResponse = { + id: 'user-123', + title: 'Mr.', + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + phone: '1234567890', + dob: new Date('1990-01-01'), + nationality: 'American', + address: '123 Main St', + city: 'New York', + postal: '10001', + country: { + code: 'USA', + name: 'United States' + }, + website: 'https://example.com', + profile_text: 'Software developer', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + } + + vi.mocked(dateToApiFormat).mockReturnValue('1990-01-01') + vi.mocked(apiDateToDate).mockReturnValue(new Date('1990-01-01')) + vi.mocked(api.patch).mockResolvedValue(mockApiResponse) + + const result = await addOrUpdateUserInfoToDB(mockUserInfo) + + expect(dateToApiFormat).toHaveBeenCalledWith(mockUserInfo.dob) + expect(api.patch).toHaveBeenCalledWith('/user/', { + ...mockUserInfo, + dob: '1990-01-01' + }) + expect(result).toEqual(mockApiResponse) + }) + + it('should handle null date of birth', async () => { + const mockUserInfo = { + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + country: { + code: 'USA', + name: 'United States' + }, + dob: null + } + + const mockApiResponse = { + id: 'user-123', + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + country: { + code: 'USA', + name: 'United States' + }, + dob: null, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + } + + vi.mocked(dateToApiFormat).mockReturnValue(null) + vi.mocked(apiDateToDate).mockReturnValue(null) + vi.mocked(api.patch).mockResolvedValue(mockApiResponse) + + const result = await addOrUpdateUserInfoToDB(mockUserInfo) + + expect(dateToApiFormat).toHaveBeenCalledWith(null) + expect(api.patch).toHaveBeenCalledWith('/user/', { + ...mockUserInfo, + dob: null + }) + expect(result).toEqual(mockApiResponse) + }) + + it('should handle API errors', async () => { + const mockUserInfo = { + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + country: { + code: 'USA', + name: 'United States' + } + } + + const apiError = new Error('API Error') + vi.mocked(api.patch).mockRejectedValue(apiError) + + await expect(addOrUpdateUserInfoToDB(mockUserInfo)).rejects.toThrow('API Error') + }) + }) + + describe('getPersonalInfoFromDB', () => { + it('should successfully retrieve personal information', async () => { + const mockApiResponse = { + id: 'user-123', + title: 'Mr.', + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + phone: '1234567890', + dob: '1990-01-01', + nationality: 'American', + address: '123 Main St', + city: 'New York', + postal: '10001', + country: { + code: 'USA', + name: 'United States' + }, + website: 'https://example.com', + profile_text: 'Software developer', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + } + + const expectedDate = new Date('1990-01-01') + vi.mocked(api.get).mockResolvedValue(mockApiResponse) + + const result = await getPersonalInfoFromDB() + + expect(api.get).toHaveBeenCalledWith('/user/') + expect(result).toEqual({ + ...mockApiResponse, + dob: expectedDate + }) + }) + + it('should handle null date of birth in response', async () => { + const mockApiResponse = { + id: 'user-123', + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + country: { + code: 'USA', + name: 'United States' + }, + dob: null, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + } + + vi.mocked(api.get).mockResolvedValue(mockApiResponse) + + const result = await getPersonalInfoFromDB() + + expect(result).toEqual({ + ...mockApiResponse, + dob: null + }) + }) + + it('should handle API errors', async () => { + const apiError = new Error('API Error') + vi.mocked(api.get).mockRejectedValue(apiError) + + await expect(getPersonalInfoFromDB()).rejects.toThrow('API Error') + }) + + it('should handle string date conversion', async () => { + const mockApiResponse = { + id: 'user-123', + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + country: { + code: 'USA', + name: 'United States' + }, + dob: '1990-01-01', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + } + + const expectedDate = new Date('1990-01-01') + vi.mocked(api.get).mockResolvedValue(mockApiResponse) + + const result = await getPersonalInfoFromDB() + + expect(result.dob).toEqual(expectedDate) + }) + }) + + describe('data transformation integration', () => { + it('should maintain date integrity through full cycle', async () => { + const originalDate = new Date('1990-01-01') + const userInfo = { + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + country: { + code: 'USA', + name: 'United States' + }, + dob: originalDate + } + + const mockApiResponse = { + id: 'user-123', + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + country: { + code: 'USA', + name: 'United States' + }, + dob: originalDate, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' + } + + vi.mocked(dateToApiFormat).mockReturnValue('1990-01-01') + vi.mocked(apiDateToDate).mockReturnValue(originalDate) + vi.mocked(api.patch).mockResolvedValue(mockApiResponse) + + const result = await addOrUpdateUserInfoToDB(userInfo) + + expect(dateToApiFormat).toHaveBeenCalledWith(originalDate) + expect(result.dob).toStrictEqual(originalDate) + }) + }) +}) diff --git a/lib/user-info/__tests__/mutations.test.ts b/lib/user-info/__tests__/mutations.test.ts new file mode 100644 index 00000000..6873c508 --- /dev/null +++ b/lib/user-info/__tests__/mutations.test.ts @@ -0,0 +1,77 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' + +// Mock the mutations module +vi.mock('@/lib/user-info/mutations', () => ({ + useUpdateUserInfoMutation: vi.fn() +})) + +// Mock the actions +vi.mock('@/lib/user-info/actions') + +describe('User Info Mutations', () => { + beforeEach(async () => { + vi.clearAllMocks() + + // Setup default mock implementations + const mutations = vi.mocked(await import('@/lib/user-info/mutations')) + + mutations.useUpdateUserInfoMutation.mockReturnValue({ + mutateAsync: vi.fn(() => Promise.resolve()), + isPending: false, + isError: false, + error: null + } as any) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('useUpdateUserInfoMutation', () => { + it('should be a function', async () => { + const {useUpdateUserInfoMutation} = await import('@/lib/user-info/mutations') + expect(typeof useUpdateUserInfoMutation).toBe('function') + }) + + it('should be mockable', async () => { + const {useUpdateUserInfoMutation} = vi.mocked(await import('@/lib/user-info/mutations')) + + expect(useUpdateUserInfoMutation).toHaveBeenCalledTimes(0) + + // Call the mocked function + const result = useUpdateUserInfoMutation() + + expect(useUpdateUserInfoMutation).toHaveBeenCalledTimes(1) + expect(result).toBeDefined() + expect(result.mutateAsync).toBeDefined() + expect(result.isPending).toBe(false) + }) + + it('should return mutation with proper structure', async () => { + const {useUpdateUserInfoMutation} = vi.mocked(await import('@/lib/user-info/mutations')) + + const result = useUpdateUserInfoMutation() + + expect(result).toHaveProperty('mutateAsync') + expect(result).toHaveProperty('isPending') + expect(result).toHaveProperty('isError') + expect(result).toHaveProperty('error') + expect(typeof result.mutateAsync).toBe('function') + expect(typeof result.isPending).toBe('boolean') + expect(typeof result.isError).toBe('boolean') + }) + + it('should accept options parameter', async () => { + const {useUpdateUserInfoMutation} = vi.mocked(await import('@/lib/user-info/mutations')) + + const mockOptions = { + onSuccess: vi.fn(), + onError: vi.fn() + } + + useUpdateUserInfoMutation(mockOptions) + + expect(useUpdateUserInfoMutation).toHaveBeenCalledWith(mockOptions) + }) + }) +}) diff --git a/lib/user-info/__tests__/queries.test.ts b/lib/user-info/__tests__/queries.test.ts new file mode 100644 index 00000000..71c8993d --- /dev/null +++ b/lib/user-info/__tests__/queries.test.ts @@ -0,0 +1,102 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {userInfoQueryOptions, useUserInfoQuery} from '@/lib/user-info/queries' +import {getPersonalInfoFromDB} from '@/lib/user-info/actions' +import {USER_INFO_QUERY_KEY} from '@/lib/user-info/keys' +import {UserInfo} from '@/lib/user-info/types' + +// Mock the actions +vi.mock('../actions') + +const mockGetPersonalInfoFromDB = vi.mocked(getPersonalInfoFromDB) + +// Mock data +const mockUserInfo: UserInfo = { + id: 'user-123', + title: 'Mr.', + first_name: 'John', + last_name: 'Doe', + email: 'john.doe@example.com', + phone: '+1234567890', + dob: new Date('1990-01-01'), + nationality: 'American', + address: '123 Main St', + city: 'San Francisco', + postal: '94105', + country: { + code: 'USA', + name: 'United States' + }, + website: 'https://johndoe.com', + profile_text: 'Experienced software engineer with 5+ years of experience', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z' +} + +describe('User Info Queries', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('userInfoQueryOptions', () => { + it('should have correct query key', () => { + const options = userInfoQueryOptions + + expect(options.queryKey).toEqual(USER_INFO_QUERY_KEY) + }) + + it('should have correct query function', () => { + const options = userInfoQueryOptions + + expect(options.queryFn).toBeInstanceOf(Function) + }) + + it('should call getPersonalInfoFromDB when query function is executed', async () => { + mockGetPersonalInfoFromDB.mockResolvedValue(mockUserInfo) + + const mockContext = { + queryKey: ['userInfo'], + client: {} as any, + signal: {} as AbortSignal, + meta: undefined + } + + const result = await userInfoQueryOptions.queryFn!(mockContext) + + expect(mockGetPersonalInfoFromDB).toHaveBeenCalledTimes(1) + expect(result).toEqual(mockUserInfo) + }) + + it('should handle query function errors', async () => { + const error = new Error('API Error') + mockGetPersonalInfoFromDB.mockRejectedValue(error) + + const mockContext = { + queryKey: ['userInfo'], + client: {} as any, + signal: {} as AbortSignal, + meta: undefined + } + + await expect(userInfoQueryOptions.queryFn!(mockContext)).rejects.toThrow('API Error') + expect(mockGetPersonalInfoFromDB).toHaveBeenCalledTimes(1) + }) + }) + + describe('useUserInfoQuery', () => { + it('should use the correct query options', () => { + const hook = useUserInfoQuery + + // Test that the hook is a function + expect(typeof hook).toBe('function') + }) + + it('should be available for import', () => { + expect(useUserInfoQuery).toBeDefined() + expect(typeof useUserInfoQuery).toBe('function') + }) + }) +}) diff --git a/lib/user-info/mutations.ts b/lib/user-info/mutations.ts index b6d3c862..403c4b85 100644 --- a/lib/user-info/mutations.ts +++ b/lib/user-info/mutations.ts @@ -1,6 +1,6 @@ import {UserInfo, UserInfoMutation} from '@/lib/user-info/types' import {MutationOptions, useMutation} from '@tanstack/react-query' -import {addOrUpdateUserInfoToDB} from './actions' +import {addOrUpdateUserInfoToDB} from '@/lib/user-info/actions' export const useUpdateUserInfoMutation = (options?: MutationOptions) => { return useMutation({ diff --git a/lib/user-info/queries.ts b/lib/user-info/queries.ts index b9ca1d51..c4578612 100644 --- a/lib/user-info/queries.ts +++ b/lib/user-info/queries.ts @@ -1,6 +1,6 @@ import {queryOptions, useQuery} from '@tanstack/react-query' -import {USER_INFO_QUERY_KEY} from './keys' -import {getPersonalInfoFromDB} from './actions' +import {USER_INFO_QUERY_KEY} from '@/lib/user-info/keys' +import {getPersonalInfoFromDB} from '@/lib/user-info/actions' export const userInfoQueryOptions = queryOptions({ queryKey: USER_INFO_QUERY_KEY, diff --git a/lib/waitlist/__tests__/actions.test.ts b/lib/waitlist/__tests__/actions.test.ts new file mode 100644 index 00000000..6eb770ed --- /dev/null +++ b/lib/waitlist/__tests__/actions.test.ts @@ -0,0 +1,307 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {signUpForWaitlist} from '@/lib/waitlist/actions' +import * as Sentry from '@sentry/nextjs' + +// Create a hoisted mock that's available during module import time +const mockEmailSend = vi.hoisted(() => vi.fn()) + +// Mock Resend with proper structure +vi.mock('resend', () => { + return { + Resend: vi.fn().mockImplementation(() => ({ + emails: { + send: mockEmailSend + } + })) + } +}) + +// Mock Sentry +vi.mock('@sentry/nextjs', () => ({ + captureException: vi.fn() +})) + +// Mock environment variables +const originalEnv = process.env + +beforeEach(() => { + process.env = { + ...originalEnv, + API_URL: 'https://api.example.com', + RESEND_API_KEY: 'test-resend-key' + } + // Clear only the email send mock, not the Resend constructor mock + mockEmailSend.mockClear() + vi.mocked(Sentry.captureException).mockClear() +}) + +afterEach(() => { + process.env = originalEnv +}) + +describe('signUpForWaitlist', () => { + it('should successfully sign up for waitlist and send welcome email', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + email: 'test@example.com', + referrer: 'friend' + }) + } + ;(global.fetch as any).mockResolvedValue(mockResponse) + mockEmailSend.mockResolvedValue({id: 'email-123'}) + + const result = await signUpForWaitlist('test@example.com', 'friend') + + expect(global.fetch).toHaveBeenCalledWith(`${process.env.API_URL}/waitlist/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + email: 'test@example.com', + referrer: 'friend' + }) + }) + expect(mockEmailSend).toHaveBeenCalledWith({ + from: 'Subhajit from Letraz ', + replyTo: 'Subhajit from Letraz ', + to: 'test@example.com', + subject: 'Welcome to Letraz waitlist!', + react: expect.any(Object) + }) + expect(result).toEqual({ + email: 'test@example.com', + referrer: 'friend' + }) + }) + + it('should handle signup without referrer', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + email: 'test@example.com', + referrer: undefined + }) + } + ;(global.fetch as any).mockResolvedValue(mockResponse) + mockEmailSend.mockResolvedValue({id: 'email-123'}) + + const result = await signUpForWaitlist('test@example.com') + + expect(global.fetch).toHaveBeenCalledWith(`${process.env.API_URL}/waitlist/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + email: 'test@example.com', + referrer: undefined + }) + }) + expect(result).toEqual({ + email: 'test@example.com', + referrer: undefined + }) + }) + + it('should handle API failure and return validated params', async () => { + const mockResponse = { + ok: false, + status: 400 + } + ;(global.fetch as any).mockResolvedValue(mockResponse) + + const result = await signUpForWaitlist('test@example.com', 'friend') + + expect(global.fetch).toHaveBeenCalledWith(`${process.env.API_URL}/waitlist/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + email: 'test@example.com', + referrer: 'friend' + }) + }) + expect(result).toEqual({ + email: 'test@example.com', + referrer: 'friend' + }) + expect(mockEmailSend).not.toHaveBeenCalled() + }) + + it('should handle email sending failure gracefully', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + email: 'test@example.com', + referrer: 'friend' + }) + } + ;(global.fetch as any).mockResolvedValue(mockResponse) + mockEmailSend.mockRejectedValue(new Error('Email sending failed')) + + const result = await signUpForWaitlist('test@example.com', 'friend') + + expect(global.fetch).toHaveBeenCalledWith(`${process.env.API_URL}/waitlist/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + email: 'test@example.com', + referrer: 'friend' + }) + }) + expect(result).toEqual({ + email: 'test@example.com', + referrer: 'friend' + }) + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Email sending failed')) + }) + + it('should handle network errors', async () => { + ;(global.fetch as any).mockRejectedValue(new Error('Network error')) + + await expect(signUpForWaitlist('test@example.com')).rejects.toThrow('Network error') + }) + + it('should validate email format', async () => { + await expect(signUpForWaitlist('invalid-email')).rejects.toThrow() + }) + + it('should handle long referrer strings', async () => { + const longReferrer = 'a'.repeat(100) // Very long referrer + + await expect(signUpForWaitlist('test@example.com', longReferrer)).rejects.toThrow() + }) + + it('should handle malformed API response', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + // Missing required fields - this should cause WaitlistMutationSchema.parse to throw + invalid: 'data' + }) + } + ;(global.fetch as any).mockResolvedValue(mockResponse) + + await expect(signUpForWaitlist('test@example.com')).rejects.toThrow() + }) + + it('should use Resend service for email sending', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + email: 'test@example.com' + }) + } + ;(global.fetch as any).mockResolvedValue(mockResponse) + mockEmailSend.mockResolvedValue({id: 'email-123'}) + + await signUpForWaitlist('test@example.com') + + // Verify that the Resend email service was used correctly + expect(mockEmailSend).toHaveBeenCalledWith({ + from: 'Subhajit from Letraz ', + replyTo: 'Subhajit from Letraz ', + to: 'test@example.com', + subject: 'Welcome to Letraz waitlist!', + react: expect.any(Object) + }) + }) + + it('should handle empty email string', async () => { + await expect(signUpForWaitlist('')).rejects.toThrow() + }) + + it('should handle null referrer explicitly', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + email: 'test@example.com', + referrer: null + }) + } + ;(global.fetch as any).mockResolvedValue(mockResponse) + mockEmailSend.mockResolvedValue({id: 'email-123'}) + + await signUpForWaitlist('test@example.com', null) + + expect(global.fetch).toHaveBeenCalledWith(`${process.env.API_URL}/waitlist/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + email: 'test@example.com', + referrer: null + }) + }) + }) + + it('should handle very long email addresses', async () => { + const longEmail = 'a'.repeat(250) + '@example.com' // Very long email + + await expect(signUpForWaitlist(longEmail)).rejects.toThrow() + }) +}) + +describe('signUpForWaitlist integration with external services', () => { + it('should properly integrate with Resend email service', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + email: 'test@example.com', + referrer: 'social' + }) + } + ;(global.fetch as any).mockResolvedValue(mockResponse) + mockEmailSend.mockResolvedValue({ + id: 'email-456', + from: 'Subhajit from Letraz ', + to: 'test@example.com', + subject: 'Welcome to Letraz waitlist!', + created_at: '2023-12-15T12:00:00Z' + }) + + const result = await signUpForWaitlist('test@example.com', 'social') + + expect(mockEmailSend).toHaveBeenCalledWith({ + from: 'Subhajit from Letraz ', + replyTo: 'Subhajit from Letraz ', + to: 'test@example.com', + subject: 'Welcome to Letraz waitlist!', + react: expect.any(Object) + }) + expect(result).toEqual({ + email: 'test@example.com', + referrer: 'social' + }) + }) + + it('should handle concurrent waitlist signups', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + email: 'test@example.com', + referrer: 'concurrent' + }) + } + ;(global.fetch as any).mockResolvedValue(mockResponse) + mockEmailSend.mockResolvedValue({id: 'email-789'}) + + const promises = [ + signUpForWaitlist('test1@example.com', 'concurrent'), + signUpForWaitlist('test2@example.com', 'concurrent'), + signUpForWaitlist('test3@example.com', 'concurrent') + ] + + const results = await Promise.all(promises) + + expect(results).toHaveLength(3) + expect(global.fetch).toHaveBeenCalledTimes(3) + expect(mockEmailSend).toHaveBeenCalledTimes(3) + }) +}) diff --git a/lib/waitlist/actions.ts b/lib/waitlist/actions.ts index 3735988d..dc87f7ec 100644 --- a/lib/waitlist/actions.ts +++ b/lib/waitlist/actions.ts @@ -7,7 +7,7 @@ import * as Sentry from '@sentry/nextjs' const resend = new Resend(process.env.RESEND_API_KEY) -export const signUpForWaitlist = async (email: string, referrer?: string) => { +export const signUpForWaitlist = async (email: string, referrer?: string | null) => { const params = WaitlistMutationSchema.parse({email, referrer}) const response = await fetch(`${process.env.API_URL}/waitlist/`, { diff --git a/middleware.ts b/middleware.ts index 273ba612..f603934a 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,9 +1,54 @@ import {clerkMiddleware, createRouteMatcher} from '@clerk/nextjs/server' +import {NextResponse} from 'next/server' +import {OnboardingMetadata} from '@/lib/onboarding/types' const isProtectedRoute = createRouteMatcher(['/app(.*)']) +const isOnboardingRoute = createRouteMatcher(['/app/onboarding(.*)']) +const isApiRoute = createRouteMatcher(['/api(.*)']) export default clerkMiddleware(async (auth, req) => { - if (isProtectedRoute(req)) await auth.protect() + // Auth header protection for all API routes using SELF_SECRET_KEY + if (isApiRoute(req)) { + const providedToken = req.headers.get('x-authentication') + const secretKey = process.env.SELF_SECRET_KEY + + if (!secretKey || providedToken !== secretKey) { + return NextResponse.json({error: 'Unauthorized'}, {status: 401}) + } + + // If token is valid, simply continue the request chain + return NextResponse.next() + } + + // Continue with Clerk protection for application routes + const {userId} = await auth() + + if (isProtectedRoute(req)) { + await auth.protect() + + // Check onboarding state for authenticated users + if (userId) { + try { + const {clerkClient} = await import('@clerk/nextjs/server') + const client = await clerkClient() + const user = await client.users.getUser(userId) + const metadata = user.publicMetadata as OnboardingMetadata + + // If user is on onboarding page but has completed onboarding, redirect to main app + if (isOnboardingRoute(req) && metadata.onboardingComplete) { + return NextResponse.redirect(new URL('/app', req.url)) + } + + // If user is not on onboarding page but hasn't completed onboarding, redirect to current step + if (!isOnboardingRoute(req) && !metadata.onboardingComplete) { + const currentStep = metadata.currentOnboardingStep || 'welcome' + return NextResponse.redirect(new URL(`/app/onboarding?step=${currentStep}`, req.url)) + } + } catch (error) { + // If metadata fetch fails, allow normal flow + } + } + } }) export const config = { diff --git a/next.config.ts b/next.config.ts index 828a699a..dfe9c021 100644 --- a/next.config.ts +++ b/next.config.ts @@ -47,15 +47,13 @@ export default withSentryConfig(nextConfig, { // Automatically annotate React components to show their full name in breadcrumbs and session replay reactComponentAnnotation: { - enabled: true + enabled: false, + ignoredComponents: ['ResumeSearch'] }, // Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. tunnelRoute: '/monitoring', - // Hides source maps from generated client bundles - hideSourceMaps: true, - // Automatically tree-shake Sentry logger statements to reduce bundle size disableLogger: true, diff --git a/package.json b/package.json index 393df29a..f39249a4 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,26 @@ "build": "next build", "start": "next start", "lint": "next lint", - "lint:fix": "next lint --fix" + "lint:fix": "next lint --fix", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage", + "test:coverage:watch": "vitest --coverage", + "test:coverage:open": "vitest run --coverage && open coverage/index.html", + "test:debug": "vitest --inspect-brk --no-coverage --reporter=verbose", + "test:silent": "vitest run --silent", + "test:verbose": "vitest run --reporter=verbose", + "test:changed": "vitest run --changed", + "test:related": "vitest run --related", + "test:bail": "vitest run --bail=1", + "test:ci": "vitest run --coverage --reporter=default --reporter=junit --outputFile=test-results.xml", + "test:coverage:threshold": "vitest run --coverage --reporter=verbose && echo 'Coverage thresholds check completed'", + "coverage:open": "open coverage/index.html" }, "dependencies": { "@ai-sdk/anthropic": "^0.0.50", - "@ai-sdk/google": "^0.0.48", + "@ai-sdk/google": "^2.0.2", "@clerk/nextjs": "^6.0.2", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", @@ -26,11 +41,12 @@ "@heroicons/react": "^2.1.5", "@hookform/resolvers": "^3.9.0", "@knocklabs/react": "^0.7.19", + "@mantine/hooks": "^8.2.4", "@neondatabase/serverless": "^0.9.5", - "@radix-ui/react-alert-dialog": "^1.1.6", + "@radix-ui/react-alert-dialog": "1.1.14", "@radix-ui/react-checkbox": "^1.1.3", - "@radix-ui/react-collapsible": "^1.1.3", - "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-collapsible": "1.1.7", + "@radix-ui/react-dialog": "1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", @@ -38,8 +54,8 @@ "@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.1.1", - "@radix-ui/react-slider": "^1.2.3", - "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-slider": "1.2.4", + "@radix-ui/react-slot": "1.2.0", "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toggle": "^1.1.0", @@ -47,7 +63,7 @@ "@react-email/components": "0.0.25", "@react-email/tailwind": "0.1.0", "@react-pdf/renderer": "^3.4.4", - "@sentry/nextjs": "8", + "@sentry/nextjs": "^10.5.0", "@tanstack/react-query": "^5.66.0", "@tanstack/react-query-devtools": "^5.66.0", "@tiptap/extension-bullet-list": "^2.8.0", @@ -60,8 +76,9 @@ "@tiptap/react": "^2.8.0", "@tiptap/starter-kit": "^2.8.0", "@types/luxon": "^3.4.2", - "ai": "^3.3.39", - "babel-plugin-react-compiler": "^19.0.0-beta-27714ef-20250124", + "ai": "^5.0.5", + "algoliasearch": "^5.35.0", + "babel-plugin-react-compiler": "^19.1.0-rc.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "1.0.0", @@ -70,7 +87,7 @@ "dompurify": "^3.2.4", "dotenv": "^16.4.5", "lenis": "^1.1.13", - "lucide-react": "^0.441.0", + "lucide-react": "^0.487.0", "luxon": "^3.5.0", "motion": "^11.16.1", "next": "15.3.5", @@ -83,31 +100,42 @@ "react-dom": "19.1.0", "react-hook-form": "^7.53.0", "react-icons": "^5.3.0", + "react-instantsearch": "^7.16.2", "react-pdf-tailwind": "^2.3.0", "resend": "^4.0.0", "sonner": "^1.5.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "turndown": "^7.2.0", + "use-debounce": "^10.0.5", + "usehooks-ts": "^3.1.1", "zod": "^3.23.8" }, "devDependencies": { "@hookform/devtools": "^4.3.3", "@stylistic/eslint-plugin-js": "^2.9.0", "@tailwindcss/typography": "^0.5.15", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/dompurify": "^3.2.0", "@types/node": "^20", "@types/react": "19.1.8", "@types/react-dom": "19.1.6", "@types/turndown": "^5.0.5", + "@vitejs/plugin-react": "^4.6.0", + "@vitest/coverage-v8": "3.2.4", + "@vitest/ui": "^3.2.4", "eslint": "^8", "eslint-config-next": "15.3.5", "eslint-plugin-import": "^2.31.0", "eslint-plugin-prefer-arrow": "^1.2.3", + "jsdom": "^26.1.0", "postcss": "^8", "react-email": "3.0.1", "tailwindcss": "^3.4.1", - "typescript": "^5" + "typescript": "^5", + "vitest": "^3.2.4" }, "packageManager": "bun@1.1.42", "overrides": { diff --git a/public/brain-pulse.webm b/public/brain-pulse.webm new file mode 100644 index 00000000..7df4aec9 Binary files /dev/null and b/public/brain-pulse.webm differ diff --git a/public/brain.webp b/public/brain.webp new file mode 100644 index 00000000..ccbeda2e Binary files /dev/null and b/public/brain.webp differ diff --git a/routes.ts b/routes.ts index b078d3e2..b50048a8 100644 --- a/routes.ts +++ b/routes.ts @@ -24,6 +24,18 @@ const routes: Record<'website' | 'app', Record> = { segment: 'changes', route: '/changes', mainNav: true + }, + privacy: { + title: 'Privacy', + segment: 'privacy', + route: '/privacy', + mainNav: false + }, + terms: { + title: 'Terms', + segment: 'terms', + route: '/terms', + mainNav: false } }, app: { diff --git a/sentry.client.config.ts b/sentry.client.config.ts index 4c1c05c6..eb38be1c 100644 --- a/sentry.client.config.ts +++ b/sentry.client.config.ts @@ -36,6 +36,21 @@ Sentry.init({ replaysOnErrorSampleRate: 1.0, // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: process.env.VERCEL_ENV !== 'production', - environment: process.env.VERCEL_ENV + debug: process.env.NODE_ENV === 'development', + environment: process.env.VERCEL_ENV, + + // Hook to modify events before they are sent to Sentry + beforeSend: (event, hint) => { + // Add additional context for user-related errors + if (event.user) { + // Add custom fingerprinting for user-specific issues + event.fingerprint = ['{{ default }}', (event.user.id as string) || 'anonymous'] + + // Add user segment information to extra context + if (!event.extra) event.extra = {} + event.extra.userSegment = event.user.segment || 'unknown' + } + + return event + } }) diff --git a/sentry.server.config.ts b/sentry.server.config.ts index 61bfa7d7..417c2450 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -14,6 +14,22 @@ if (process.env.VERCEL_ENV === 'production') { tracesSampleRate: 0.2, // Or use a function-based tracesSampler for more granular control. // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false + debug: false, + + // Hook to modify events before they are sent to Sentry (server-side) + beforeSend: (event, hint) => { + // Add server-side specific context + if (event.user) { + // Add custom fingerprinting for user-specific issues + event.fingerprint = ['{{ default }}', (event.user.id as string) || 'anonymous'] + + // Add server context + if (!event.extra) event.extra = {} + event.extra.serverSide = true + event.extra.userSegment = event.user.segment || 'unknown' + } + + return event + } }) } diff --git a/tailwind.config.ts b/tailwind.config.ts index 1b0c9adb..a5b0ae95 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -76,7 +76,21 @@ const config: Config = { }, boxShadow: ({theme}) => ({ subtle: `0 2px 16px ${theme('colors.neutral.950')}15` - }) + }), + keyframes: { + 'collapsible-down': { + from: { height: '0' }, + to: { height: 'var(--radix-collapsible-content-height)' } + }, + 'collapsible-up': { + from: { height: 'var(--radix-collapsible-content-height)' }, + to: { height: '0' } + } + }, + animation: { + 'collapsible-down': 'collapsible-down 0.2s ease-out', + 'collapsible-up': 'collapsible-up 0.2s ease-out' + } } }, plugins: [ diff --git a/test-setup.ts b/test-setup.ts new file mode 100644 index 00000000..bc2d9ab1 --- /dev/null +++ b/test-setup.ts @@ -0,0 +1,355 @@ +import '@testing-library/jest-dom' +import {cleanup} from '@testing-library/react' +import {afterEach, beforeAll, vi} from 'vitest' + +// Mock Next.js router +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + refresh: vi.fn() + }), + usePathname: () => '/', + useSearchParams: () => new URLSearchParams(), + useParams: () => ({}) +})) + +// Mock Next.js Image component +vi.mock('next/image', () => ({ + default: (props: any) => { + const {src, alt, ...rest} = props + // Return a simple object that can be rendered as an img element + return { + type: 'img', + props: {src, alt, ...rest} + } + } +})) + +// Mock Next.js Link component +vi.mock('next/link', () => ({ + default: (props: any) => { + const {children, href, ...rest} = props + // Return a simple object that can be rendered as an anchor element + return { + type: 'a', + props: {href, ...rest, children} + } + } +})) + +// Mock Clerk authentication (if using Clerk) +vi.mock('@clerk/nextjs', () => ({ + useUser: () => ({ + user: null, + isLoaded: true, + isSignedIn: false + }), + useAuth: () => ({ + isLoaded: true, + isSignedIn: false, + userId: null, + sessionId: null, + getToken: vi.fn(), + signOut: vi.fn() + }), + SignInButton: (props: any) => ({ + type: 'div', + props: {'data-testid': 'sign-in-button', children: props.children} + }), + SignUpButton: (props: any) => ({ + type: 'div', + props: {'data-testid': 'sign-up-button', children: props.children} + }), + UserButton: () => ({ + type: 'div', + props: {'data-testid': 'user-button'} + }), + ClerkProvider: (props: any) => ({ + type: 'div', + props: {children: props.children} + }) +})) + +// Mock React Query for API state management +vi.mock('@tanstack/react-query', async () => { + const actual = await vi.importActual('@tanstack/react-query') + return { + ...actual, + useQuery: vi.fn(), + useMutation: vi.fn(), + useQueryClient: vi.fn() + } +}) + +// Mock Framer Motion to avoid animation issues in tests +vi.mock('framer-motion', () => ({ + motion: { + div: 'div', + span: 'span', + button: 'button', + a: 'a', + img: 'img', + h1: 'h1', + h2: 'h2', + h3: 'h3', + p: 'p', + section: 'section', + article: 'article', + nav: 'nav', + header: 'header', + footer: 'footer', + main: 'main', + aside: 'aside', + ul: 'ul', + li: 'li', + form: 'form', + input: 'input', + textarea: 'textarea', + select: 'select', + option: 'option', + label: 'label' + }, + AnimatePresence: (props: any) => props.children, + useAnimation: () => ({ + start: vi.fn(), + stop: vi.fn(), + set: vi.fn() + }), + useMotionValue: (initial: any) => ({get: () => initial, set: vi.fn()}), + useTransform: (value: any, input: any, output: any) => value, + useSpring: (value: any) => value +})) + +// Mock PostHog analytics +vi.mock('posthog-js/react', () => ({ + usePostHog: () => ({ + capture: vi.fn(), + identify: vi.fn(), + reset: vi.fn() + }), + PostHogProvider: (props: any) => props.children +})) + +// Global test setup and configuration + +// Clean up after each test automatically +afterEach(() => { + cleanup() +}) + +// Set up global mocks before all tests +beforeAll(() => { + // Mock window.matchMedia for responsive components + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), // deprecated + removeListener: vi.fn(), // deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })) + }) + + // Mock window.ResizeObserver for components that use it + global.ResizeObserver = vi.fn((callback) => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn() + })) + + // Mock IntersectionObserver for components that use it + global.IntersectionObserver = vi.fn((callback) => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + takeRecords: vi.fn(() => []), + root: null, + rootMargin: '', + thresholds: [] + })) + + // Mock scrollTo for components that use scrolling + window.scrollTo = vi.fn() + Element.prototype.scrollTo = vi.fn() + + // Mock HTMLElement.prototype.scrollIntoView + HTMLElement.prototype.scrollIntoView = vi.fn() + + /* + * Mock console methods to reduce noise in tests (optional) + * Uncomment if you want to suppress console output during tests + * vi.spyOn(console, 'log').mockImplementation(() => {}) + * vi.spyOn(console, 'warn').mockImplementation(() => {}) + * vi.spyOn(console, 'error').mockImplementation(() => {}) + */ + + // Mock fetch for API testing + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: vi.fn().mockResolvedValue({}), + text: vi.fn().mockResolvedValue('{}'), + headers: new Headers(), + url: '', + redirected: false, + type: 'basic' as ResponseType, + body: null, + bodyUsed: false, + clone: vi.fn(), + arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0)), + blob: vi.fn().mockResolvedValue(new Blob()), + formData: vi.fn().mockResolvedValue(new FormData()), + bytes: vi.fn().mockResolvedValue(new Uint8Array()) + } as Response) as any + + // Mock localStorage and sessionStorage + const localStorageMock = { + getItem: vi.fn(() => null), // Return null by default to match browser behavior + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + length: 0, + key: vi.fn() + } + + const sessionStorageMock = { + getItem: vi.fn(() => null), // Return null by default to match browser behavior + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + length: 0, + key: vi.fn() + } + + Object.defineProperty(window, 'localStorage', { + value: localStorageMock, + writable: true + }) + + Object.defineProperty(window, 'sessionStorage', { + value: sessionStorageMock, + writable: true + }) + + // Mock URL.createObjectURL and URL.revokeObjectURL for file handling + global.URL.createObjectURL = vi.fn(() => 'mocked-url') + global.URL.revokeObjectURL = vi.fn() + + // Mock FileReader for file upload testing + const MockFileReader = vi.fn().mockImplementation(() => ({ + readAsDataURL: vi.fn(), + readAsText: vi.fn(), + readAsArrayBuffer: vi.fn(), + readAsBinaryString: vi.fn(), + onload: null, + onerror: null, + onabort: null, + onloadstart: null, + onloadend: null, + onprogress: null, + result: null, + error: null, + readyState: 0, + EMPTY: 0, + LOADING: 1, + DONE: 2, + abort: vi.fn() + })) + + // Add static properties to the mock constructor + ;(MockFileReader as any).EMPTY = 0 + ;(MockFileReader as any).LOADING = 1 + ;(MockFileReader as any).DONE = 2 + + global.FileReader = MockFileReader as any + + +}) + +// Global test utilities and helpers +export const mockConsole = { + log: vi.spyOn(console, 'log').mockImplementation(() => {}), + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + error: vi.spyOn(console, 'error').mockImplementation(() => {}), + info: vi.spyOn(console, 'info').mockImplementation(() => {}) +} + +// Helper to restore console methods +export const restoreConsole = () => { + mockConsole.log.mockRestore() + mockConsole.warn.mockRestore() + mockConsole.error.mockRestore() + mockConsole.info.mockRestore() +} + +// Helper to create mock fetch responses +export const createMockResponse = (data: any, status = 200) => { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + json: vi.fn().mockResolvedValue(data), + text: vi.fn().mockResolvedValue(JSON.stringify(data)), + headers: new Headers(), + url: '', + redirected: false, + type: 'basic' as ResponseType, + body: null, + bodyUsed: false, + clone: vi.fn(), + arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0)), + blob: vi.fn().mockResolvedValue(new Blob()), + formData: vi.fn().mockResolvedValue(new FormData()), + bytes: vi.fn().mockResolvedValue(new Uint8Array()) + } as Response +} + +// Helper to mock successful API responses +export const mockApiSuccess = (data: any, status = 200) => { + ;(global.fetch as any).mockResolvedValueOnce(createMockResponse(data, status)) +} + +// Helper to mock API errors +export const mockApiError = (status = 500, message = 'Internal Server Error') => { + ;(global.fetch as any).mockResolvedValueOnce(createMockResponse( + {error: {message}}, + status + )) +} + +// Helper to reset all mocks +export const resetAllMocks = () => { + vi.clearAllMocks() + vi.resetAllMocks() +} + +// DOM testing utilities +export const waitForElementToBeRemoved = async (element: HTMLElement) => { + const {waitForElementToBeRemoved: waitFor} = await import('@testing-library/react') + return waitFor(element) +} + +// Custom error for testing error boundaries +export class TestError extends Error { + constructor(message = 'Test error') { + super(message) + this.name = 'TestError' + } +} + +// Helper to trigger error boundary +export const ThrowError = (props: { shouldThrow?: boolean; error?: Error }) => { + if (props.shouldThrow) { + throw props.error || new TestError() + } + return null +} diff --git a/tsconfig.json b/tsconfig.json index d8b93235..93744aa8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,8 @@ ], "paths": { "@/*": ["./*"] - } + }, + "types": ["vitest/globals", "@testing-library/jest-dom"] }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..a064366f --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,187 @@ +import {defineConfig} from 'vitest/config' +import react from '@vitejs/plugin-react' +import * as path from 'path' + +export default defineConfig({ + plugins: [react()], + test: { + // Use jsdom environment for component testing + environment: 'jsdom', + + // Global test setup + setupFiles: ['./test-setup.ts'], + + // Enable global APIs (describe, it, expect, etc.) + globals: true, + + // Test file patterns + include: [ + '**/__tests__/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', + '**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}' + ], + + // Exclude patterns + exclude: [ + '**/node_modules/**', + '**/dist/**', + '**/.next/**', + '**/coverage/**', + '**/.git/**', + '**/build/**' + ], + + // Coverage configuration + coverage: { + // Use v8 provider for better performance and accuracy + provider: 'v8', + + // Coverage reporters - multiple formats for different use cases + reporter: [ + 'text', // Console output + 'text-summary', // Brief summary + 'json', // Machine readable + 'json-summary', // Brief machine readable + 'html', // Interactive HTML report + 'lcov', // For external tools + 'clover' // XML format for CI tools + ], + + // Coverage output directory + reportsDirectory: './coverage', + + // Coverage thresholds - enforce minimum coverage requirements + thresholds: { + global: { + branches: 75, + functions: 80, + lines: 80, + statements: 80 + } + }, + + // Enforce thresholds - fail tests if coverage is below thresholds + + // Clean coverage directory before each run + clean: true, + + // Skip coverage for files with no tests + skipFull: false, + + // Include all files in coverage, even if not tested + all: true, + + // Files to include in coverage + include: [ + 'app/**/*.{js,jsx,ts,tsx}', + 'components/**/*.{js,jsx,ts,tsx}', + 'lib/**/*.{js,jsx,ts,tsx}', + 'hooks/**/*.{js,jsx,ts,tsx}' + ], + + // Files to exclude from coverage + exclude: [ + // Test files + '**/__tests__/**', + '**/*.test.{js,jsx,ts,tsx}', + '**/*.spec.{js,jsx,ts,tsx}', + '**/test-setup.ts', + '**/vitest.config.ts', + + // Build and dependency directories + '**/node_modules/**', + '**/.next/**', + '**/coverage/**', + '**/dist/**', + '**/build/**', + + // Configuration files + '**/*.config.{js,ts,mjs}', + '**/*.d.ts', + '**/types/**', + + // Next.js specific files + 'app/layout.tsx', + 'app/global-error.tsx', + 'app/globals.css', + 'app/robots.ts', + 'app/sitemap.ts', + 'app/opengraph-image.png', + 'app/twitter-image.png', + 'app/favicon.ico', + + // Infrastructure files + 'middleware.ts', + 'instrumentation.ts', + 'next.config.ts', + 'tailwind.config.ts', + 'postcss.config.mjs', + 'sentry.*.config.ts', + 'env.ts', + 'config.ts', + 'constants.ts', + 'routes.ts', + + // Font and asset files + 'app/fonts/**', + 'public/**', + + // Email templates (often not unit testable) + 'emails/**', + + // Generated or external code + 'components.json', + 'bun.lock', + 'package.json', + 'tsconfig.json', + 'README.md', + 'LICENSE', + + // PDF and document files + 'documents/**' + ] + }, + + // Test timeout (30 seconds) + testTimeout: 30000, + + // Hook timeout (10 seconds) + hookTimeout: 10000, + + // Retry failed tests once + retry: 1, + + // Run tests in parallel + pool: 'threads', + + // Maximum number of threads + poolOptions: { + threads: { + maxThreads: 4, + minThreads: 1 + } + }, + + + + // Clear mocks between tests + clearMocks: true, + + // Restore mocks after each test + restoreMocks: true, + + // Mock reset between tests + mockReset: true + }, + + // Path resolution to match tsconfig.json + resolve: { + alias: { + '@': path.resolve(__dirname, './') + } + }, + + // Define global variables for testing + define: { + 'process.env.NODE_ENV': '"test"' + } +})