diff --git a/functions/_shared/form-data-parser.ts b/functions/_shared/form-data-parser.ts new file mode 100644 index 0000000..42e3c71 --- /dev/null +++ b/functions/_shared/form-data-parser.ts @@ -0,0 +1,100 @@ +/** + * Safely gets a string value from FormData + * @param formData - The FormData object to extract from + * @param fieldName - The name of the field to extract + * @returns The trimmed string value, or undefined if field doesn't exist or is empty + */ +export function getStringField(formData: FormData, fieldName: string): string | undefined { + const value = formData.get(fieldName) + + if (value === null || value === undefined) { + return undefined + } + + if (typeof value !== 'string') { + return undefined + } + + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +/** + * Safely parses an integer from FormData + * @param formData - The FormData object to extract from + * @param fieldName - The name of the field to parse + * @returns The parsed integer, or undefined if parsing fails or value is invalid + */ +export function getIntegerField(formData: FormData, fieldName: string): number | undefined { + const value = getStringField(formData, fieldName) + + if (value === undefined) { + return undefined + } + + const parsed = parseInt(value, 10) + + if (isNaN(parsed)) { + return undefined + } + + return parsed +} + +/** + * Safely parses a JSON array from FormData + * @param formData - The FormData object to extract from + * @param fieldName - The name of the field to parse as JSON array + * @returns The parsed array, or undefined if parsing fails or result is not an array + * @template T - The expected type of array elements + */ +export function getJsonArrayField( + formData: FormData, + fieldName: string +): T[] | undefined { + const value = getStringField(formData, fieldName) + + if (value === undefined) { + return undefined + } + + try { + const parsed = JSON.parse(value) + + if (!Array.isArray(parsed)) { + console.warn(`Field "${fieldName}" is not an array:`, parsed) + return undefined + } + + return parsed as T[] + } catch (error) { + if (error instanceof Error) { + console.warn(`Failed to parse JSON for field "${fieldName}":`, error.message) + } + return undefined + } +} + +/** + * Gets a File from FormData + * @param formData - The FormData object to extract from + * @param fieldName - The name of the file field + * @returns The File object, or undefined if field doesn't exist, is not a File, or is empty + */ +export function getFileField(formData: FormData, fieldName: string): File | undefined { + const value = formData.get(fieldName) + + if (value === null || value === undefined) { + return undefined + } + + if (!(value instanceof File)) { + return undefined + } + + if (value.size === 0) { + return undefined + } + + return value +} diff --git a/functions/_shared/schemas.ts b/functions/_shared/schemas.ts new file mode 100644 index 0000000..99ef3da --- /dev/null +++ b/functions/_shared/schemas.ts @@ -0,0 +1,107 @@ +import { z } from 'zod' + +const basePetitionFields = { + title: z + .string({ + error: iss => (iss.input === undefined ? 'Title is required' : 'Title must be a string'), + }) + .min(10, 'Title must be at least 10 characters') + .max(150, 'Title must not exceed 150 characters') + .trim(), + + description: z + .string({ + error: iss => + iss.input === undefined ? 'Description is required' : 'Description must be a string', + }) + .min(100, 'Description must be at least 100 characters') + .trim(), + + type: z.enum(['local', 'national'], { + error: iss => + iss.input === undefined + ? 'Petition type is required' + : 'Petition type must be either "local" or "national"', + }), + + location: z + .string({ + error: 'Location must be a string', + }) + .trim() + .optional(), + + target_count: z + .number({ + error: 'Target count must be a number', + }) + .int('Target count must be an integer') + .min(1, 'Target count must be at least 1') + .max(1000000, 'Target count must not exceed 1,000,000'), + + category_ids: z + .array( + z.number({ + error: 'Category ID must be a number', + }) + ) + .optional(), + + image_url: z.string().optional(), +} + +export const createPetitionSchema = z + .object({ + ...basePetitionFields, + target_count: basePetitionFields.target_count.default(1000), + created_by: z.string(), + }) + // validate location is required when type is 'local' + .refine( + data => { + if (data.type === 'local') { + return data.location !== undefined && data.location.length > 0 + } + return true + }, + { + message: 'Location is required for local petitions', + path: ['location'], + } + ) + +export type CreatePetitionRequest = z.input +export type ValidatedPetitionData = z.output + +export const updatePetitionSchema = z + .object({ + ...basePetitionFields, + status: z + .enum(['active', 'completed', 'closed'], { + error: 'Status must be one of: active, completed, closed', + }) + .optional(), + }) + .partial() + .refine( + data => { + if (data.type === 'local') { + return data.location !== undefined && data.location.length > 0 + } + return true + }, + { + message: 'Location is required when changing petition type to local', + path: ['location'], + } + ) + +export type UpdatePetitionRequest = z.input +export type ValidatedUpdateData = z.output + +export function formatZodError(error: z.ZodError): { field: string; message: string }[] { + return error.issues.map(issue => ({ + field: issue.path.join('.'), + message: issue.message, + })) +} diff --git a/functions/_shared/utils.ts b/functions/_shared/utils.ts index 97dde17..fdeda6a 100644 --- a/functions/_shared/utils.ts +++ b/functions/_shared/utils.ts @@ -186,6 +186,29 @@ export function createCachedErrorResponse( }) } +export function createValidationErrorResponse( + errors: Array<{ field: string; message: string }>, + request: Request, + env: Env +): Response { + const corsHeaders = getCorsHeaders(request, env) + + return new Response( + JSON.stringify({ + message: 'Validation failed', + errors, + }), + { + status: 422, + headers: { + ...corsHeaders, + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + }, + } + ) +} + // KV Cache utilities export function generateCacheKey(request: Request, prefix: string = 'api'): string { const url = new URL(request.url) diff --git a/functions/api/petitions.ts b/functions/api/petitions.ts index 12c79cd..494a283 100644 --- a/functions/api/petitions.ts +++ b/functions/api/petitions.ts @@ -1,9 +1,9 @@ -import type { CreatePetitionInput } from '../../src/db/schemas/types' import type { Env, EventContext } from '../_shared/types' import { createCachedErrorResponse, createCachedResponse, createSuccessResponse, + createValidationErrorResponse, generateCacheKey, getDbService, getOrSetCache, @@ -11,6 +11,18 @@ import { invalidateCachePattern, type AuthenticatedUser, } from '../_shared/utils' +import { + getStringField, + getIntegerField, + getJsonArrayField, + getFileField, +} from '../_shared/form-data-parser' +import { + createPetitionSchema, + formatZodError, + type CreatePetitionRequest, + type ValidatedPetitionData, +} from '../_shared/schemas' export const onRequest = async (context: EventContext): Promise => { const corsResponse = handleCORS(context.request, context.env) @@ -35,27 +47,34 @@ export const onRequest = async (context: EventContext): Promise = // Check if this is a multipart form (with image) or JSON const contentType = context.request.headers.get('content-type') || '' - let petitionData: CreatePetitionInput - let imageFile: File | null = null + let petitionData: CreatePetitionRequest + let imageFile: File | undefined = undefined if (contentType.includes('multipart/form-data')) { // Handle form data with potential image const formData = await context.request.formData() // Extract petition data from form + const title = getStringField(formData, 'title') + const description = getStringField(formData, 'description') + const type = getStringField(formData, 'type') + const location = getStringField(formData, 'location') + const targetCount = getIntegerField(formData, 'target_count') + const categoryIds = getJsonArrayField(formData, 'category_ids') + petitionData = { - title: formData.get('title') as string, - description: formData.get('description') as string, - type: formData.get('type') as 'local' | 'national', - location: (formData.get('location') as string) || undefined, - target_count: parseInt(formData.get('target_count') as string), - created_by: user.id, // Use authenticated user's ID - category_ids: JSON.parse((formData.get('category_ids') as string) || '[]'), - image_url: '', // Will be set after upload + title: title ?? '', + description: description ?? '', + type: (type as 'local' | 'national') ?? 'national', + location: location, + target_count: targetCount, + created_by: user.id, + category_ids: categoryIds, + image_url: '', } // Get image file if provided - imageFile = formData.get('image') as File | null + imageFile = getFileField(formData, 'image') } else { // Handle JSON data (no image) petitionData = await context.request.json() @@ -63,9 +82,17 @@ export const onRequest = async (context: EventContext): Promise = petitionData.created_by = user.id } + const validationResult = createPetitionSchema.safeParse(petitionData) + if (!validationResult.success) { + const formattedErrors = formatZodError(validationResult.error) + return createValidationErrorResponse(formattedErrors, context.request, context.env) + } + + const validatedData: ValidatedPetitionData = validationResult.data + // Step 1: Create petition record first (without image_url) const petition = await db.createPetition({ - ...petitionData, + ...validatedData, image_url: '', // Start with empty image_url }) diff --git a/functions/api/petitions/[id].ts b/functions/api/petitions/[id].ts index f7fc00b..09b20d6 100644 --- a/functions/api/petitions/[id].ts +++ b/functions/api/petitions/[id].ts @@ -1,15 +1,27 @@ -import type { CreatePetitionInput } from '../../../src/db/schemas/types' import type { Env, EventContext } from '../../_shared/types' import { handleCORS, createSuccessResponse, createCachedResponse, createCachedErrorResponse, + createValidationErrorResponse, getDbService, invalidateCachePattern, generateCacheKey, getOrSetCache, } from '../../_shared/utils' +import { + getStringField, + getIntegerField, + getJsonArrayField, + getFileField, +} from '../../_shared/form-data-parser' +import { + updatePetitionSchema, + formatZodError, + type UpdatePetitionRequest, + type ValidatedUpdateData, +} from '../../_shared/schemas' export const onRequest = async (context: EventContext): Promise => { const corsResponse = handleCORS(context.request, context.env) @@ -47,36 +59,50 @@ export const onRequest = async (context: EventContext): Promise = // Check if this is a multipart form (with image) or JSON const contentType = context.request.headers.get('content-type') || '' - let petitionData: Partial - let imageFile: File | null = null + let petitionData: UpdatePetitionRequest + let imageFile: File | undefined = undefined if (contentType.includes('multipart/form-data')) { // Handle form data with potential image const formData = await context.request.formData() - // Extract petition data from form - petitionData = {} - if (formData.get('title')) petitionData.title = formData.get('title') as string - if (formData.get('description')) - petitionData.description = formData.get('description') as string - if (formData.get('type')) petitionData.type = formData.get('type') as 'local' | 'national' - if (formData.get('location')) petitionData.location = formData.get('location') as string - if (formData.get('target_count')) - petitionData.target_count = parseInt(formData.get('target_count') as string) - if (formData.get('category_ids')) - petitionData.category_ids = JSON.parse((formData.get('category_ids') as string) || '[]') - if (formData.get('status')) - petitionData.status = formData.get('status') as 'active' | 'completed' | 'closed' + const title = getStringField(formData, 'title') + const description = getStringField(formData, 'description') + const type = getStringField(formData, 'type') + const location = getStringField(formData, 'location') + const targetCount = getIntegerField(formData, 'target_count') + const categoryIds = getJsonArrayField(formData, 'category_ids') + const status = getStringField(formData, 'status') + + // Construct and only include fields that were provided + petitionData = { + ...(title !== undefined && { title }), + ...(description !== undefined && { description }), + ...(type !== undefined && { type: type as 'local' | 'national' }), + ...(location !== undefined && { location }), + ...(targetCount !== undefined && { target_count: targetCount }), + ...(categoryIds !== undefined && { category_ids: categoryIds }), + ...(status !== undefined && { status: status as 'active' | 'completed' | 'closed' }), + } // Get image file if provided - imageFile = formData.get('image') as File | null + imageFile = getFileField(formData, 'image') } else { // Handle JSON data (no image) petitionData = await context.request.json() } + const validationResult = updatePetitionSchema.safeParse(petitionData) + if (!validationResult.success) { + const formattedErrors = formatZodError(validationResult.error) + return createValidationErrorResponse(formattedErrors, context.request, context.env) + } + + // Use validated data + const validatedData: ValidatedUpdateData = validationResult.data + // Step 1: Update petition record first - const updatedPetition = await db.updatePetition(petitionId, petitionData) + const updatedPetition = await db.updatePetition(petitionId, validatedData) // Step 2: If image provided, upload to R2 and update petition if (imageFile && imageFile.size > 0) { diff --git a/package.json b/package.json index 17fef99..0906a87 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,11 @@ "format": "prettier --write .", "format:check": "prettier --check .", "preview": "vite preview", + "test": "vitest", + "test:unit": "vitest run tests/unit", + "test:integration": "vitest run tests/integration", + "test:watch": "vitest watch", + "test:ui": "vitest --ui", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:headed": "playwright test --headed", @@ -50,9 +55,11 @@ "react-router-dom": "^6.30.1", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.13", - "wrangler": "^4.40.0" + "wrangler": "^4.40.0", + "zod": "^4.3.5" }, "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.12.0", "@cloudflare/workers-types": "^4.20250924.0", "@eslint/js": "^9.36.0", "@playwright/test": "^1.55.1", @@ -75,6 +82,7 @@ "prettier": "^3.6.2", "typescript": "~5.8.3", "typescript-eslint": "^8.44.0", - "vite": "^7.1.7" + "vite": "^7.1.7", + "vitest": "3.2.0" } } diff --git a/tests/README.md b/tests/README.md index 02748cb..4190ed6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,9 +1,74 @@ -# E2E Tests for Petition Application +# Tests for Petition Application -This directory contains end-to-end tests using Playwright for the petition application. +This directory contains all tests for the petition application. ## Test Structure +``` +tests/ +├── unit/ ← Unit tests (fast, isolated) +├── e2e/ ← E2E tests (full workflows) +└── README.md ← This file +``` + +## Unit Tests (Vitest) + +**Location:** `tests/unit/` \ +**Framework:** Vitest with @cloudflare/vitest-pool-workers \ +**Purpose:** Test validation schemas, utilities, and business logic in isolation + +### Test Files + +- **`create-petition.test.ts`** - Tests for createPetitionSchema validation +- **`update-petition.test.ts`** - Tests for updatePetitionSchema validation +- **`setup.test.ts`** - Vitest setup verification + +### Running Unit Tests + +```bash +# Run all unit tests +pnpm test:unit + +# Run in watch mode +pnpm test:watch + +# Run with UI +pnpm test:ui +``` + +### Unit Test Coverage + +**Create Petition Validation (27 tests):** + +- ✅ Valid national petitions with all fields +- ✅ Valid local petitions with location +- ✅ Default values (target_count = 1000) +- ✅ Category IDs validation +- ✅ Title validation (min/max length, required, type) +- ✅ Description validation (min length, required) +- ✅ Type validation (enum: local/national) +- ✅ Location validation (required for local petitions) +- ✅ Target count validation (min/max, integer, NaN) +- ✅ Whitespace trimming +- ✅ Error message formatting + +**Update Petition Validation (13 tests):** + +- ✅ Partial updates (single field, multiple fields, empty object) +- ✅ Status validation (active, completed, closed) +- ✅ Location validation when updating to local type +- ✅ Same validation rules as create (reused validators) + +**Total:** 42 unit tests, all passing + +## E2E Tests (Playwright) + +**Location:** `tests/e2e/` +**Framework:** Playwright +**Purpose:** Test complete user workflows with real browser interactions + +### Test Files + - **`example.spec.ts`** - Simple example tests to verify basic functionality - **`petitions.spec.ts`** - Tests for browsing and listing petitions - **`petition-detail.spec.ts`** - Tests for viewing individual petition details diff --git a/tests/unit/create-petition.test.ts b/tests/unit/create-petition.test.ts new file mode 100644 index 0000000..0542089 --- /dev/null +++ b/tests/unit/create-petition.test.ts @@ -0,0 +1,616 @@ +import { describe, it, expect } from 'vitest' +import { createPetitionSchema, formatZodError } from '../../functions/_shared/schemas' + +describe('createPetitionSchema', () => { + describe('Happy Path - Valid Data', () => { + it('should accept valid national petition with all fields', () => { + const validData = { + title: 'This is a valid petition title', + description: + 'This is a valid description that meets the minimum character requirement of 100 characters. It contains enough text to pass validation.', + type: 'national' as const, + location: 'Philippines', + target_count: 5000, + created_by: 'user123', + category_ids: [1, 2, 3], + image_url: 'https://example.com/image.jpg', + } + + const result = createPetitionSchema.safeParse(validData) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toEqual(validData) + } + }) + + it('should accept valid local petition with location', () => { + const validData = { + title: 'Valid local petition title here', + description: + 'This is a valid description for a local petition that meets the minimum character requirement of 100 characters in total.', + type: 'local' as const, + location: 'Manila, Philippines', + created_by: 'user456', + } + + const result = createPetitionSchema.safeParse(validData) + + expect(result.success).toBe(true) + }) + + it('should accept valid array of category IDs', () => { + const validData = { + title: 'Valid petition title', + description: + 'This is a valid description that meets the minimum character requirement of 100 characters for testing.', + type: 'national' as const, + created_by: 'user123', + category_ids: [1, 2, 3, 4], + } + + const result = createPetitionSchema.safeParse(validData) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.category_ids).toEqual([1, 2, 3, 4]) + } + }) + + it('should accept empty category_ids array', () => { + const validData = { + title: 'Valid petition title', + description: + 'This is a valid description that meets the minimum character requirement of 100 characters for testing.', + type: 'national' as const, + created_by: 'user123', + category_ids: [], + } + + const result = createPetitionSchema.safeParse(validData) + + expect(result.success).toBe(true) + }) + + it('should apply default target_count of 1000 when not provided', () => { + const validData = { + title: 'Valid petition title', + description: + 'This description is long enough to meet the minimum requirement of 100 characters for petition descriptions.', + type: 'national' as const, + created_by: 'user789', + } + + const result = createPetitionSchema.safeParse(validData) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.target_count).toBe(1000) + } + }) + + it('should allow national petition without location', () => { + const validData = { + title: 'Valid national petition', + description: + 'This is a valid description that meets the minimum character requirement of 100 characters for testing.', + type: 'national' as const, + created_by: 'user123', + } + + const result = createPetitionSchema.safeParse(validData) + + expect(result.success).toBe(true) + }) + }) + + describe('Sad Path - Invalid Data', () => { + describe('Title Validation', () => { + it('should reject title shorter than 10 characters', () => { + const invalidData = { + title: 'Too short', + description: + 'This is a valid description that meets the minimum character requirement of 100 characters for testing.', + type: 'national' as const, + created_by: 'user123', + } + + const result = createPetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = formatZodError(result.error) + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'title', + message: 'Title must be at least 10 characters', + }), + ]) + ) + } + }) + + it('should reject title longer than 150 characters', () => { + const invalidData = { + title: 'A'.repeat(151), + description: + 'This is a valid description that meets the minimum character requirement of 100 characters for testing.', + type: 'national' as const, + created_by: 'user123', + } + + const result = createPetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = formatZodError(result.error) + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'title', + message: 'Title must not exceed 150 characters', + }), + ]) + ) + } + }) + + it('should reject missing title', () => { + const invalidData = { + description: + 'This is a valid description that meets the minimum character requirement of 100 characters for testing.', + type: 'national' as const, + created_by: 'user123', + } + + const result = createPetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = formatZodError(result.error) + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'title', + message: 'Title is required', + }), + ]) + ) + } + }) + + it('should reject non-string title', () => { + const invalidData = { + title: 12345, + description: + 'This is a valid description that meets the minimum character requirement of 100 characters for testing.', + type: 'national' as const, + created_by: 'user123', + } + + const result = createPetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = formatZodError(result.error) + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'title', + message: 'Title must be a string', + }), + ]) + ) + } + }) + }) + + describe('Description Validation', () => { + it('should reject description shorter than 100 characters', () => { + const invalidData = { + title: 'Valid petition title', + description: 'This description is too short.', + type: 'national' as const, + created_by: 'user123', + } + + const result = createPetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = formatZodError(result.error) + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'description', + message: 'Description must be at least 100 characters', + }), + ]) + ) + } + }) + + it('should reject missing description', () => { + const invalidData = { + title: 'Valid petition title', + type: 'national' as const, + created_by: 'user123', + } + + const result = createPetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = formatZodError(result.error) + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'description', + message: 'Description is required', + }), + ]) + ) + } + }) + }) + + describe('Type Validation', () => { + it('should reject invalid petition type', () => { + const invalidData = { + title: 'Valid petition title', + description: + 'This is a valid description that meets the minimum character requirement of 100 characters for testing.', + type: 'invalid', + created_by: 'user123', + } + + const result = createPetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = formatZodError(result.error) + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'type', + message: 'Petition type must be either "local" or "national"', + }), + ]) + ) + } + }) + + it('should reject missing type', () => { + const invalidData = { + title: 'Valid petition title', + description: + 'This is a valid description that meets the minimum character requirement of 100 characters for testing.', + created_by: 'user123', + } + + const result = createPetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = formatZodError(result.error) + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'type', + message: 'Petition type is required', + }), + ]) + ) + } + }) + }) + + describe('Location Validation', () => { + it('should reject local petition without location', () => { + const invalidData = { + title: 'Valid local petition title', + description: + 'This is a valid description that meets the minimum character requirement of 100 characters for testing.', + type: 'local' as const, + created_by: 'user123', + } + + const result = createPetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = formatZodError(result.error) + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'location', + message: 'Location is required for local petitions', + }), + ]) + ) + } + }) + + it('should reject local petition with empty location', () => { + const invalidData = { + title: 'Valid local petition title', + description: + 'This is a valid description that meets the minimum character requirement of 100 characters for testing.', + type: 'local' as const, + location: '', + created_by: 'user123', + } + + const result = createPetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = formatZodError(result.error) + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'location', + message: 'Location is required for local petitions', + }), + ]) + ) + } + }) + }) + + describe('Target Count Validation', () => { + it('should reject negative target_count', () => { + const invalidData = { + title: 'Valid petition title', + description: + 'This is a valid description that meets the minimum character requirement of 100 characters for testing.', + type: 'national' as const, + target_count: -100, + created_by: 'user123', + } + + const result = createPetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = formatZodError(result.error) + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'target_count', + message: 'Target count must be at least 1', + }), + ]) + ) + } + }) + + it('should reject zero target_count', () => { + const invalidData = { + title: 'Valid petition title', + description: + 'This is a valid description that meets the minimum character requirement of 100 characters for testing.', + type: 'national' as const, + target_count: 0, + created_by: 'user123', + } + + const result = createPetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = formatZodError(result.error) + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'target_count', + message: 'Target count must be at least 1', + }), + ]) + ) + } + }) + + it('should reject target_count exceeding 1,000,000', () => { + const invalidData = { + title: 'Valid petition title', + description: + 'This is a valid description that meets the minimum character requirement of 100 characters for testing.', + type: 'national' as const, + target_count: 1000001, + created_by: 'user123', + } + + const result = createPetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = formatZodError(result.error) + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'target_count', + message: 'Target count must not exceed 1,000,000', + }), + ]) + ) + } + }) + + it('should reject non-integer target_count', () => { + const invalidData = { + title: 'Valid petition title', + description: + 'This is a valid description that meets the minimum character requirement of 100 characters for testing.', + type: 'national' as const, + target_count: 1000.5, + created_by: 'user123', + } + + const result = createPetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = formatZodError(result.error) + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'target_count', + message: 'Target count must be an integer', + }), + ]) + ) + } + }) + + it('should reject non-number target_count', () => { + const invalidData = { + title: 'Valid petition title', + description: + 'This is a valid description that meets the minimum character requirement of 100 characters for testing.', + type: 'national' as const, + target_count: '1000' as unknown as number, + created_by: 'user123', + } + + const result = createPetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = formatZodError(result.error) + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'target_count', + message: 'Target count must be a number', + }), + ]) + ) + } + }) + + it('should reject NaN target_count', () => { + const invalidData = { + title: 'Valid petition title', + description: + 'This is a valid description that meets the minimum character requirement of 100 characters for testing.', + type: 'national' as const, + target_count: NaN, + created_by: 'user123', + } + + const result = createPetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + }) + }) + + describe('Category IDs Validation', () => { + it('should reject category_ids with non-number elements', () => { + const invalidData = { + title: 'Valid petition title', + description: + 'This is a valid description that meets the minimum character requirement of 100 characters for testing.', + type: 'national' as const, + created_by: 'user123', + category_ids: [1, '2', 3] as unknown as number[], + } + + const result = createPetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = formatZodError(result.error) + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'category_ids.1', + message: 'Category ID must be a number', + }), + ]) + ) + } + }) + }) + }) + + describe('Edge Cases', () => { + it('should trim whitespace from title and description', () => { + const dataWithWhitespace = { + title: ' Valid petition title ', + description: + ' This description has leading and trailing whitespace but is still long enough to meet the requirements. ', + type: 'national' as const, + created_by: 'user999', + } + + const result = createPetitionSchema.safeParse(dataWithWhitespace) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.title).toBe('Valid petition title') + expect(result.data.description).not.toMatch(/^\s|\s$/) + } + }) + }) +}) + +describe('formatZodError', () => { + it('should format single error correctly', () => { + const invalidData = { + type: 'national' as const, + created_by: 'user123', + } + + const result = createPetitionSchema.safeParse(invalidData) + + if (!result.success) { + const formatted = formatZodError(result.error) + expect(formatted).toBeInstanceOf(Array) + expect(formatted.length).toBeGreaterThan(0) + expect(formatted[0]).toHaveProperty('field') + expect(formatted[0]).toHaveProperty('message') + } + }) + + it('should format multiple errors correctly', () => { + const invalidData = { + type: 'invalid', + target_count: -1, + } + + const result = createPetitionSchema.safeParse(invalidData) + + if (!result.success) { + const formatted = formatZodError(result.error) + expect(formatted.length).toBeGreaterThan(1) + formatted.forEach(error => { + expect(error).toHaveProperty('field') + expect(error).toHaveProperty('message') + expect(typeof error.field).toBe('string') + expect(typeof error.message).toBe('string') + }) + } + }) + + it('should handle nested field paths', () => { + const invalidData = { + title: 'Valid petition title', + description: + 'This is a valid description that meets the minimum character requirement of 100 characters for testing.', + type: 'national' as const, + created_by: 'user123', + category_ids: [1, 'invalid', 3] as unknown as number[], + } + + const result = createPetitionSchema.safeParse(invalidData) + + if (!result.success) { + const formatted = formatZodError(result.error) + const categoryError = formatted.find(e => e.field.startsWith('category_ids')) + expect(categoryError).toBeDefined() + expect(categoryError?.field).toBe('category_ids.1') + } + }) +}) diff --git a/tests/unit/setup.test.ts b/tests/unit/setup.test.ts new file mode 100644 index 0000000..31ee814 --- /dev/null +++ b/tests/unit/setup.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest' + +describe('Vitest Setup', () => { + it('should run tests successfully', () => { + expect(true).toBe(true) + }) + + it('should support basic assertions', () => { + const sum = 1 + 1 + expect(sum).toBe(2) + }) +}) diff --git a/tests/unit/update-petition.test.ts b/tests/unit/update-petition.test.ts new file mode 100644 index 0000000..5486cb2 --- /dev/null +++ b/tests/unit/update-petition.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect } from 'vitest' +import { updatePetitionSchema, formatZodError } from '../../functions/_shared/schemas' + +describe('updatePetitionSchema', () => { + describe('Happy Path - Valid Partial Updates', () => { + it('should accept update with only title', () => { + const validData = { + title: 'Updated petition title here', + } + + const result = updatePetitionSchema.safeParse(validData) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toEqual({ title: 'Updated petition title here' }) + } + }) + + it('should accept update with only description', () => { + const validData = { + description: + 'This is an updated description that meets the minimum character requirement of 100 characters for validation.', + } + + const result = updatePetitionSchema.safeParse(validData) + + expect(result.success).toBe(true) + }) + + it('should accept update with only status', () => { + const validData = { + status: 'completed' as const, + } + + const result = updatePetitionSchema.safeParse(validData) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toEqual({ status: 'completed' }) + } + }) + + it('should accept update with multiple fields', () => { + const validData = { + title: 'Updated petition title', + target_count: 2000, + status: 'active' as const, + } + + const result = updatePetitionSchema.safeParse(validData) + + expect(result.success).toBe(true) + }) + + it('should accept empty update object', () => { + const validData = {} + + const result = updatePetitionSchema.safeParse(validData) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toEqual({}) + } + }) + + it('should accept update to local type with location', () => { + const validData = { + type: 'local' as const, + location: 'Manila, Philippines', + } + + const result = updatePetitionSchema.safeParse(validData) + + expect(result.success).toBe(true) + }) + + it('should allow updating to national type without location', () => { + const validData = { + type: 'national' as const, + } + + const result = updatePetitionSchema.safeParse(validData) + + expect(result.success).toBe(true) + }) + + it('should accept all valid status values', () => { + const statuses: Array<'active' | 'completed' | 'closed'> = ['active', 'completed', 'closed'] + + statuses.forEach(status => { + const result = updatePetitionSchema.safeParse({ status }) + expect(result.success).toBe(true) + }) + }) + }) + + describe('Sad Path - Invalid Updates', () => { + describe('Location Validation', () => { + it('should reject update to local type without location', () => { + const invalidData = { + type: 'local' as const, + } + + const result = updatePetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = formatZodError(result.error) + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'location', + message: 'Location is required when changing petition type to local', + }), + ]) + ) + } + }) + }) + + describe('Status Validation', () => { + it('should reject invalid status value', () => { + const invalidData = { + status: 'invalid', + } + + const result = updatePetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = formatZodError(result.error) + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'status', + message: 'Status must be one of: active, completed, closed', + }), + ]) + ) + } + }) + }) + + describe('Field Validation (Same Rules as Create)', () => { + it('should reject title shorter than 10 characters', () => { + const invalidData = { + title: 'Too short', + } + + const result = updatePetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = formatZodError(result.error) + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'title', + message: 'Title must be at least 10 characters', + }), + ]) + ) + } + }) + + it('should reject description shorter than 100 characters', () => { + const invalidData = { + description: 'Too short', + } + + const result = updatePetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = formatZodError(result.error) + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'description', + message: 'Description must be at least 100 characters', + }), + ]) + ) + } + }) + + it('should reject invalid target_count', () => { + const invalidData = { + target_count: 0, + } + + const result = updatePetitionSchema.safeParse(invalidData) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = formatZodError(result.error) + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'target_count', + message: 'Target count must be at least 1', + }), + ]) + ) + } + }) + }) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..409e55a --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config' + +export default defineWorkersConfig({ + test: { + globals: true, + exclude: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/e2e/**'], + poolOptions: { + workers: { + wrangler: { + configPath: './wrangler.toml', + }, + miniflare: { + assets: { + directory: './dist/client', + }, + }, + }, + }, + }, +})