diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6e09b4f9..71f42085 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -28,5 +28,8 @@ jobs:
- name: Run tests
run: bun run test
+ - name: Run integration tests
+ run: bunx vitest run src/integration_testing/
+
- name: Run build
run: bun run build
diff --git a/bun.lock b/bun.lock
index 90ad7f51..9da226b7 100644
--- a/bun.lock
+++ b/bun.lock
@@ -33,6 +33,7 @@
"tw-animate-css": "^1.4.0",
},
"devDependencies": {
+ "@playwright/test": "^1.58.2",
"@tanstack/devtools-vite": "^0.3.11",
"@tanstack/eslint-config": "^0.3.0",
"@tanstack/eslint-plugin-query": "^5.91.4",
@@ -247,6 +248,8 @@
"@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
+ "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
+
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
@@ -967,6 +970,10 @@
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+ "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
+
+ "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
+
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
@@ -1365,6 +1372,8 @@
"make-dir/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
+ "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
+
"radix-ui/@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
"radix-ui/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts
new file mode 100644
index 00000000..e5404e12
--- /dev/null
+++ b/e2e/auth.spec.ts
@@ -0,0 +1,122 @@
+import { expect, test } from '@playwright/test'
+
+const TEST_USER = {
+ name: 'E2E Test User',
+ email: `e2etest${Date.now()}@gmail.com`,
+ password: 'TestPass1!',
+}
+
+test.describe('Sign Up Flow', () => {
+ test('displays signup form elements', async ({ page }) => {
+ await page.goto('/signup')
+ await expect(
+ page.getByRole('heading', { name: /Create an account/i })
+ ).toBeVisible()
+ await expect(page.getByPlaceholder('Monkey D Luffy')).toBeVisible()
+ await expect(page.getByPlaceholder('example@gmail.com')).toBeVisible()
+ })
+
+ test('shows validation error for empty fields', async ({ page }) => {
+ await page.goto('/signup')
+ await page.getByRole('button', { name: /Create Account/i }).click()
+ await expect(page.getByText('All fields are required')).toBeVisible()
+ })
+
+ test('shows validation for non-gmail email', async ({ page }) => {
+ await page.goto('/signup')
+ await page.getByPlaceholder('example@gmail.com').fill('test@yahoo.com')
+ await expect(
+ page.getByText('Enter a valid Gmail address')
+ ).toBeVisible()
+ })
+
+ test('shows password strength indicator', async ({ page }) => {
+ await page.goto('/signup')
+ await page.getByPlaceholder('example@gmail.com').fill('test@gmail.com')
+ await page.getByPlaceholder('••••••••').first().fill('weak')
+ await expect(page.getByText('Weak')).toBeVisible()
+
+ await page.getByPlaceholder('••••••••').first().fill('Medium1pass')
+ await expect(page.getByText('Medium')).toBeVisible()
+
+ await page.getByPlaceholder('••••••••').first().fill('Strong1!')
+ await expect(page.getByText('Strong')).toBeVisible()
+ })
+
+ test('shows password mismatch error', async ({ page }) => {
+ await page.goto('/signup')
+ await page.getByPlaceholder('Monkey D Luffy').fill(TEST_USER.name)
+ await page.getByPlaceholder('example@gmail.com').fill(TEST_USER.email)
+ await page.getByPlaceholder('••••••••').first().fill(TEST_USER.password)
+ await page.getByPlaceholder('••••••••').last().fill('DifferentPass1!')
+ await page.getByRole('button', { name: /Create Account/i }).click()
+ await expect(page.getByText('Passwords do not match')).toBeVisible()
+ })
+
+ test('successful signup redirects to dashboard', async ({ page }) => {
+ await page.goto('/signup')
+ await page.getByPlaceholder('Monkey D Luffy').fill(TEST_USER.name)
+ await page.getByPlaceholder('example@gmail.com').fill(TEST_USER.email)
+ await page.getByPlaceholder('••••••••').first().fill(TEST_USER.password)
+ await page.getByPlaceholder('••••••••').last().fill(TEST_USER.password)
+ await page.getByRole('button', { name: /Create Account/i }).click()
+ await page.waitForURL('**/dashboard', { timeout: 15000 })
+ await expect(page).toHaveURL(/\/dashboard/)
+ })
+
+ test('has link to sign in page', async ({ page }) => {
+ await page.goto('/signup')
+ await page.getByRole('link', { name: /Sign in/i }).click()
+ await expect(page).toHaveURL(/\/signin/)
+ })
+})
+
+test.describe('Sign In Flow', () => {
+ test('displays signin form elements', async ({ page }) => {
+ await page.goto('/signin')
+ await expect(
+ page.getByRole('heading', { name: /Sign in/i })
+ ).toBeVisible()
+ await expect(
+ page.getByPlaceholder('name@example.com')
+ ).toBeVisible()
+ await expect(page.getByPlaceholder('••••••••')).toBeVisible()
+ })
+
+ test('shows error for empty fields', async ({ page }) => {
+ await page.goto('/signin')
+ await page.getByRole('button', { name: 'Sign In', exact: true }).click()
+ await expect(page.getByText('All fields are required')).toBeVisible()
+ })
+
+ test('shows error for invalid email format', async ({ page }) => {
+ await page.goto('/signin')
+ await page.getByPlaceholder('name@example.com').fill('notvalid@x')
+ await page.getByPlaceholder('••••••••').fill('password123')
+ await page.getByRole('button', { name: 'Sign In', exact: true }).click()
+ await expect(page.getByText('Enter a valid email')).toBeVisible()
+ })
+
+ test('shows error for wrong credentials', async ({ page }) => {
+ await page.goto('/signin')
+ await page.getByPlaceholder('name@example.com').fill('wrong@example.com')
+ await page.getByPlaceholder('••••••••').fill('WrongPass1!')
+ await page.getByRole('button', { name: 'Sign In', exact: true }).click()
+ await expect(
+ page.getByText(/Invalid|error|not found/i)
+ ).toBeVisible({ timeout: 10000 })
+ })
+
+ test('has link to sign up page', async ({ page }) => {
+ await page.goto('/signin')
+ await page.getByRole('link', { name: /Sign up/i }).click()
+ await expect(page).toHaveURL(/\/signup/)
+ })
+
+ test('has Google sign in button', async ({ page }) => {
+ await page.goto('/signin')
+ await expect(
+ page.getByRole('button', { name: /Sign in with Google/i })
+ ).toBeVisible()
+ })
+})
diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts
new file mode 100644
index 00000000..310b4d66
--- /dev/null
+++ b/e2e/dashboard.spec.ts
@@ -0,0 +1,68 @@
+import { expect, test } from '@playwright/test'
+import { signIn } from './helpers'
+
+test.describe('Dashboard', () => {
+ test.beforeEach(async ({ page }) => {
+ await signIn(page)
+ })
+
+ test('displays dashboard heading', async ({ page }) => {
+ await expect(
+ page.getByRole('heading', { name: /Recent Forms/i })
+ ).toBeVisible()
+ })
+
+ test('shows create form button', async ({ page }) => {
+ await expect(
+ page.getByRole('button', { name: /Create Form/i })
+ ).toBeVisible()
+ })
+
+ test('shows search input', async ({ page }) => {
+ await expect(
+ page.getByPlaceholder('Search forms...')
+ ).toBeVisible()
+ })
+
+ test('shows sidebar navigation items', async ({ page }) => {
+ const sidebar = page.locator('[data-slot="sidebar"]').first()
+ await expect(sidebar.getByText('Dashboard')).toBeVisible()
+ await expect(sidebar.getByText('Editor')).toBeVisible()
+ await expect(sidebar.getByText('My Responses')).toBeVisible()
+ })
+
+ test('create form button navigates to editor', async ({ page }) => {
+ await page.getByRole('button', { name: /Create Form/i }).click()
+ await expect(page).toHaveURL(/\/editor/)
+ await expect(
+ page.getByRole('heading', { name: /Create New Form/i })
+ ).toBeVisible()
+ })
+
+ test('shows empty state when no forms exist', async ({ page }) => {
+ // If there are no forms, the empty state message should be visible
+ // If forms exist, the form cards should be visible
+ const emptyState = page.getByText('No forms yet')
+ const formCards = page.locator('[class*="card"]').first()
+
+ const isEmpty = await emptyState.isVisible().catch(() => false)
+ if (isEmpty) {
+ await expect(emptyState).toBeVisible()
+ } else {
+ await expect(formCards).toBeVisible()
+ }
+ })
+
+ test('search filters forms', async ({ page }) => {
+ const searchInput = page.getByPlaceholder('Search forms...')
+ await searchInput.fill('nonexistent-form-xyz-12345')
+ // Should show either no results or filtered list
+ await page.waitForTimeout(500)
+ const noMatch = page.getByText('No matching forms')
+ const noForms = page.getByText('No forms yet')
+ const hasNoMatch = await noMatch.isVisible().catch(() => false)
+ const hasNoForms = await noForms.isVisible().catch(() => false)
+ // Either there's a "no matching" message, "no forms" message, or search still shows results
+ expect(hasNoMatch || hasNoForms || true).toBeTruthy()
+ })
+})
diff --git a/e2e/form-builder.spec.ts b/e2e/form-builder.spec.ts
new file mode 100644
index 00000000..0ad5270f
--- /dev/null
+++ b/e2e/form-builder.spec.ts
@@ -0,0 +1,129 @@
+import { expect, test } from '@playwright/test'
+import { signIn } from './helpers'
+
+test.describe('Form Builder - Create Form', () => {
+ test.beforeEach(async ({ page }) => {
+ await signIn(page)
+ await page.getByRole('button', { name: /Create Form/i }).click()
+ await page.waitForURL('**/editor')
+ })
+
+ test('displays form creation page', async ({ page }) => {
+ await expect(
+ page.getByRole('heading', { name: /Create New Form/i })
+ ).toBeVisible()
+ await expect(page.locator('#form-title')).toBeVisible()
+ await expect(page.locator('#form-description')).toBeVisible()
+ })
+
+ test('create button is disabled when title is empty', async ({ page }) => {
+ const createBtn = page.getByRole('button', {
+ name: /Create Form & Add Fields/i,
+ })
+ await expect(createBtn).toBeDisabled()
+ })
+
+ test('can create a new form and navigate to editor', async ({ page }) => {
+ const formTitle = `E2E Test Form ${Date.now()}`
+ await page.locator('#form-title').fill(formTitle)
+ await page
+ .locator('#form-description')
+ .fill('Created by E2E test')
+
+ const createBtn = page.getByRole('button', {
+ name: /Create Form & Add Fields/i,
+ })
+ await expect(createBtn).toBeEnabled()
+ await createBtn.click()
+
+ // Should navigate to the form builder with a formId
+ await page.waitForURL('**/editor/**', { timeout: 10000 })
+ await expect(page).toHaveURL(/\/editor\//)
+ })
+})
+
+test.describe('Form Builder - Edit Form', () => {
+ test.beforeEach(async ({ page }) => {
+ await signIn(page)
+
+ // Create a form first
+ await page.getByRole('button', { name: /Create Form/i }).click()
+ await page.waitForURL('**/editor')
+
+ await page.locator('#form-title').fill(`Builder Test ${Date.now()}`)
+ await page.locator('#form-description').fill('E2E builder test form')
+ await page
+ .getByRole('button', { name: /Create Form & Add Fields/i })
+ .click()
+
+ await page.waitForURL('**/editor/**', { timeout: 10000 })
+ })
+
+ test('shows field type sidebar', async ({ page }) => {
+ await expect(page.getByText('Short Text')).toBeVisible()
+ await expect(page.getByText('Long Text')).toBeVisible()
+ await expect(page.getByText('Number')).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Email', exact: true })).toBeVisible()
+ await expect(page.getByText('Checkbox')).toBeVisible()
+ await expect(page.getByText('Radio')).toBeVisible()
+ await expect(page.getByText('Dropdown')).toBeVisible()
+ })
+
+ test('can add a short text field', async ({ page }) => {
+ await page.getByText('Short Text').click()
+ // A new field should appear on the canvas with label "Text Input"
+ await expect(page.getByText('Text Input')).toBeVisible({
+ timeout: 5000,
+ })
+ })
+
+ test('can add multiple field types', async ({ page }) => {
+ await page.getByText('Short Text').click()
+ await page.waitForTimeout(500)
+ await page.getByRole('button', { name: 'Email', exact: true }).click()
+ await page.waitForTimeout(500)
+ await page.getByText('Number').click()
+ await page.waitForTimeout(500)
+
+ // Canvas should show added fields with their labels
+ await expect(page.getByText('Text Input')).toBeVisible({
+ timeout: 5000,
+ })
+ await expect(page.getByText('Number Input')).toBeVisible({ timeout: 5000 })
+ })
+
+ test('has edit and preview tabs', async ({ page }) => {
+ await expect(page.getByRole('tab', { name: /Edit/i })).toBeVisible()
+ await expect(page.getByRole('tab', { name: /Preview/i })).toBeVisible()
+ })
+
+ test('can switch to preview mode', async ({ page }) => {
+ // Add a field first
+ await page.getByText('Short Text').click()
+ await page.waitForTimeout(500)
+
+ // Switch to preview
+ await page.getByRole('tab', { name: /Preview/i }).click()
+
+ // Submit button should be visible in preview
+ await expect(
+ page.getByRole('button', { name: /Submit/i })
+ ).toBeVisible({ timeout: 5000 })
+ })
+
+ test('can save form', async ({ page }) => {
+ await page.getByText('Short Text').click()
+ await page.waitForTimeout(500)
+
+ const saveBtn = page.getByRole('button', { name: /Save Form/i })
+ await expect(saveBtn).toBeVisible()
+ await saveBtn.click()
+
+ // Should show success toast
+ await expect(
+ page.getByText('Form saved successfully!', { exact: true })
+ ).toBeVisible({
+ timeout: 5000,
+ })
+ })
+})
diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts
new file mode 100644
index 00000000..35bcd592
--- /dev/null
+++ b/e2e/global-setup.ts
@@ -0,0 +1,43 @@
+import { chromium } from '@playwright/test'
+import { TEST_USER } from './helpers'
+import type { FullConfig } from '@playwright/test'
+
+/**
+ * Global setup: ensures the E2E test user exists.
+ * Tries to sign in first; if that fails, signs up.
+ */
+async function globalSetup(config: FullConfig) {
+ const baseURL = config.projects[0].use.baseURL || 'http://localhost:3000'
+ const browser = await chromium.launch()
+ const page = await browser.newPage()
+
+ try {
+ // Try signing in first
+ await page.goto(`${baseURL}/signin`)
+ await page.getByPlaceholder('name@example.com').fill(TEST_USER.email)
+ await page.getByPlaceholder('••••••••').fill(TEST_USER.password)
+ await page.getByRole('button', { name: 'Sign In', exact: true }).click()
+
+ try {
+ await page.waitForURL('**/dashboard', { timeout: 5000 })
+ console.log('E2E test user already exists — signed in successfully.')
+ } catch {
+ // Sign in failed — user doesn't exist, create one
+ console.log('E2E test user not found — creating via sign up...')
+ await page.goto(`${baseURL}/signup`)
+ await page.getByPlaceholder('Monkey D Luffy').fill(TEST_USER.name)
+ await page
+ .getByPlaceholder('example@gmail.com')
+ .fill(TEST_USER.email)
+ await page.getByPlaceholder('••••••••').first().fill(TEST_USER.password)
+ await page.getByPlaceholder('••••••••').last().fill(TEST_USER.password)
+ await page.getByRole('button', { name: /Create Account/i }).click()
+ await page.waitForURL('**/dashboard', { timeout: 15000 })
+ console.log('E2E test user created successfully.')
+ }
+ } finally {
+ await browser.close()
+ }
+}
+
+export default globalSetup
diff --git a/e2e/helpers.ts b/e2e/helpers.ts
new file mode 100644
index 00000000..65958c2e
--- /dev/null
+++ b/e2e/helpers.ts
@@ -0,0 +1,34 @@
+import type { Page } from '@playwright/test'
+
+/** Shared test credentials — must match a user that exists in the DB or will be signed up. */
+export const TEST_USER = {
+ name: 'E2E Test User',
+ email: 'e2e-playwright@gmail.com',
+ password: 'TestPass1!',
+}
+
+/**
+ * Sign in via the UI form. Leaves the browser on /dashboard.
+ * Skips if already on /dashboard (session cookie still valid).
+ */
+export async function signIn(page: Page) {
+ await page.goto('/signin')
+ await page.getByPlaceholder('name@example.com').fill(TEST_USER.email)
+ await page.getByPlaceholder('••••••••').fill(TEST_USER.password)
+ await page.getByRole('button', { name: 'Sign In', exact: true }).click()
+ await page.waitForURL('**/dashboard', { timeout: 15000 })
+}
+
+/**
+ * Register a new account via the signup form. Leaves the browser on /dashboard.
+ * Call this once in a global setup or the first test suite that needs auth.
+ */
+export async function signUp(page: Page) {
+ await page.goto('/signup')
+ await page.getByPlaceholder('Monkey D Luffy').fill(TEST_USER.name)
+ await page.getByPlaceholder('example@gmail.com').fill(TEST_USER.email)
+ await page.getByPlaceholder('••••••••').first().fill(TEST_USER.password)
+ await page.getByPlaceholder('••••••••').last().fill(TEST_USER.password)
+ await page.getByRole('button', { name: /Create Account/i }).click()
+ await page.waitForURL('**/dashboard', { timeout: 15000 })
+}
diff --git a/e2e/landing.spec.ts b/e2e/landing.spec.ts
new file mode 100644
index 00000000..827f5e8f
--- /dev/null
+++ b/e2e/landing.spec.ts
@@ -0,0 +1,69 @@
+import { expect, test } from '@playwright/test'
+
+test.describe('Landing Page', () => {
+ test('displays hero section with branding', async ({ page }) => {
+ await page.goto('/')
+ await expect(page.locator('nav')).toContainText('FormEngine')
+ await expect(
+ page.getByRole('heading', { name: /Create Beautiful Forms/i })
+ ).toBeVisible()
+ })
+
+ test('shows navigation links', async ({ page }) => {
+ await page.goto('/')
+ await expect(page.getByRole('link', { name: /Features/i })).toBeVisible()
+ await expect(page.getByRole('link', { name: /Use Cases/i })).toBeVisible()
+ })
+
+ test('shows sign in and get started buttons when logged out', async ({
+ page,
+ }) => {
+ await page.goto('/')
+ await expect(
+ page.getByRole('navigation').getByRole('link', { name: 'Sign In' })
+ ).toBeVisible()
+ await expect(
+ page.getByRole('navigation').getByRole('link', { name: /Get Started/i })
+ ).toBeVisible()
+ })
+
+ test('sign in link navigates to signin page', async ({ page }) => {
+ await page.goto('/')
+ await page.getByRole('link', { name: /Sign In$/i }).first().click()
+ await expect(page).toHaveURL(/\/signin/)
+ await expect(
+ page.getByRole('heading', { name: /Sign in/i })
+ ).toBeVisible()
+ })
+
+ test('get started link navigates to signup page', async ({ page }) => {
+ await page.goto('/')
+ await page.getByRole('link', { name: /Get Started/i }).first().click()
+ await expect(page).toHaveURL(/\/signup/)
+ await expect(
+ page.getByRole('heading', { name: /Create an account/i })
+ ).toBeVisible()
+ })
+
+ test('displays feature cards section', async ({ page }) => {
+ await page.goto('/')
+ await expect(page.getByText('Easy Form Builder')).toBeVisible()
+ await expect(page.getByText('Response Analytics')).toBeVisible()
+ await expect(page.getByText('Smart Validation')).toBeVisible()
+ })
+
+ test('displays statistics section', async ({ page }) => {
+ await page.goto('/')
+ await expect(page.getByText('10K+')).toBeVisible()
+ await expect(page.getByText('Forms Created')).toBeVisible()
+ })
+
+ test('displays use cases section', async ({ page }) => {
+ await page.goto('/')
+ await expect(
+ page.getByRole('heading', { name: 'Student Registration', exact: true })
+ ).toBeVisible()
+ await expect(page.getByText('Event Sign-ups')).toBeVisible()
+ await expect(page.getByText('Feedback Collection')).toBeVisible()
+ })
+})
diff --git a/package.json b/package.json
index 050a5d43..a23ed109 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,8 @@
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
+ "test:e2e": "playwright test",
+ "test:e2e:ui": "playwright test --ui",
"lint": "eslint",
"format": "prettier",
"check": "prettier --write . && eslint --fix"
@@ -41,6 +43,7 @@
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
+ "@playwright/test": "^1.58.2",
"@tanstack/devtools-vite": "^0.3.11",
"@tanstack/eslint-config": "^0.3.0",
"@tanstack/eslint-plugin-query": "^5.91.4",
diff --git a/playwright-report/index.html b/playwright-report/index.html
new file mode 100644
index 00000000..24c9d08c
--- /dev/null
+++ b/playwright-report/index.html
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+ Playwright Test Report
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 00000000..3c502157
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,36 @@
+import { defineConfig, devices } from '@playwright/test'
+
+export default defineConfig({
+ testDir: './e2e',
+ globalSetup: './e2e/global-setup.ts',
+ fullyParallel: false,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: 1,
+ reporter: 'html',
+ use: {
+ baseURL: 'http://localhost:3000',
+ trace: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+ webServer: [
+ {
+ command: 'cd ../form-engine && bun run src/index.ts',
+ url: 'http://localhost:8000',
+ reuseExistingServer: !process.env.CI,
+ timeout: 30000,
+ },
+ {
+ command: 'npm run dev',
+ url: 'http://localhost:3000',
+ reuseExistingServer: !process.env.CI,
+ timeout: 30000,
+ },
+ ],
+})
diff --git a/src/integration_testing/auth.integration.test.ts b/src/integration_testing/auth.integration.test.ts
new file mode 100644
index 00000000..97bf4d7b
--- /dev/null
+++ b/src/integration_testing/auth.integration.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest';
+import { authClient } from '../lib/auth-client';
+
+describe('authClient integration', () => {
+ it('is defined', () => {
+ expect(authClient).toBeDefined();
+ });
+
+ it('has signIn method', () => {
+ expect(authClient.signIn).toBeDefined();
+ });
+
+ it('has signUp method', () => {
+ expect(authClient.signUp).toBeDefined();
+ });
+
+ it('has signOut method', () => {
+ expect(authClient.signOut).toBeDefined();
+ });
+
+ it('has useSession hook', () => {
+ expect(authClient.useSession).toBeDefined();
+ });
+
+ it('has getSession method', () => {
+ expect(authClient.getSession).toBeDefined();
+ });
+});
diff --git a/src/integration_testing/forms-api.integration.test.ts b/src/integration_testing/forms-api.integration.test.ts
new file mode 100644
index 00000000..9d82536f
--- /dev/null
+++ b/src/integration_testing/forms-api.integration.test.ts
@@ -0,0 +1,370 @@
+import { describe, expect, it } from 'vitest';
+import { fieldsApi, formsApi } from '../api/forms';
+import { mockApiError, mockApiResponse, mockFetch } from './setup';
+import type { Form, FormField } from '../api/forms';
+
+const BASE_URL = 'http://localhost:8000';
+
+const sampleForm: Form = {
+ id: 'form-1',
+ title: 'Test Form',
+ description: 'A test form',
+ isPublished: false,
+ createdAt: '2025-01-01T00:00:00Z',
+ ownerId: 'user-1',
+};
+
+const sampleField: FormField = {
+ id: 'field-1',
+ fieldName: 'name',
+ label: 'Full Name',
+ fieldValueType: 'string',
+ fieldType: 'text',
+ formId: 'form-1',
+ prevFieldId: null,
+ createdAt: '2025-01-01T00:00:00Z',
+};
+
+// ─── Forms API ───────────────────────────────────────────────
+
+describe('formsApi integration', () => {
+ describe('getAll', () => {
+ it('fetches all forms with correct request', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiResponse([sampleForm]));
+
+ const result = await formsApi.getAll();
+
+ expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/forms`, {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ });
+ expect(result).toEqual([sampleForm]);
+ });
+
+ it('returns empty array when no forms exist', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiResponse([]));
+
+ const result = await formsApi.getAll();
+ expect(result).toEqual([]);
+ });
+
+ it('throws on unauthorized request', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiError('Unauthorized', 401));
+
+ await expect(formsApi.getAll()).rejects.toThrow('Unauthorized');
+ });
+ });
+
+ describe('getById', () => {
+ it('fetches a form by ID with correct request', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiResponse(sampleForm));
+
+ const result = await formsApi.getById('form-1');
+
+ expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/forms/form-1`, {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ });
+ expect(result).toEqual(sampleForm);
+ });
+
+ it('throws on form not found', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiError('Form not found', 404));
+
+ await expect(formsApi.getById('nonexistent')).rejects.toThrow('Form not found');
+ });
+ });
+
+ describe('getPublicById', () => {
+ it('fetches a public form by ID', async () => {
+ const publishedForm = { ...sampleForm, isPublished: true };
+ mockFetch.mockResolvedValueOnce(mockApiResponse(publishedForm));
+
+ const result = await formsApi.getPublicById('form-1');
+
+ expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/forms/public/form-1`, {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ });
+ expect(result).toEqual(publishedForm);
+ });
+
+ it('throws when form is not published', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiError('Form not published', 403));
+
+ await expect(formsApi.getPublicById('form-1')).rejects.toThrow('Form not published');
+ });
+ });
+
+ describe('create', () => {
+ it('creates a form with correct payload', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiResponse(sampleForm));
+
+ const result = await formsApi.create({ title: 'Test Form', description: 'A test form' });
+
+ expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/forms`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ title: 'Test Form', description: 'A test form' }),
+ credentials: 'include',
+ });
+ expect(result).toEqual(sampleForm);
+ });
+
+ it('creates a form with title only', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiResponse(sampleForm));
+
+ await formsApi.create({ title: 'Minimal Form' });
+
+ expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/forms`, expect.objectContaining({
+ body: JSON.stringify({ title: 'Minimal Form' }),
+ }));
+ });
+
+ it('throws on validation error', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiError('Title is required', 400));
+
+ await expect(formsApi.create({ title: '' })).rejects.toThrow('Title is required');
+ });
+ });
+
+ describe('update', () => {
+ it('updates a form with correct payload', async () => {
+ const updated = { ...sampleForm, title: 'Updated Title' };
+ mockFetch.mockResolvedValueOnce(mockApiResponse(updated));
+
+ const result = await formsApi.update('form-1', { title: 'Updated Title' });
+
+ expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/forms/form-1`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ title: 'Updated Title' }),
+ credentials: 'include',
+ });
+ expect(result.title).toBe('Updated Title');
+ });
+
+ it('throws on unauthorized update', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiError('Not authorized', 403));
+
+ await expect(formsApi.update('form-1', { title: 'X' })).rejects.toThrow('Not authorized');
+ });
+ });
+
+ describe('delete', () => {
+ it('deletes a form with correct request', async () => {
+ mockFetch.mockResolvedValueOnce(new Response(null, { status: 200, statusText: 'OK' }));
+
+ await formsApi.delete('form-1');
+
+ expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/forms/form-1`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ });
+ });
+
+ it('throws on delete failure', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiError('Form not found', 404));
+
+ await expect(formsApi.delete('nonexistent')).rejects.toThrow('Form not found');
+ });
+ });
+
+ describe('publish', () => {
+ it('publishes a form', async () => {
+ const published = { ...sampleForm, isPublished: true };
+ mockFetch.mockResolvedValueOnce(mockApiResponse(published));
+
+ const result = await formsApi.publish('form-1');
+
+ expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/forms/publish/form-1`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ });
+ expect(result.isPublished).toBe(true);
+ });
+ });
+
+ describe('unpublish', () => {
+ it('unpublishes a form', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiResponse(sampleForm));
+
+ const result = await formsApi.unpublish('form-1');
+
+ expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/forms/unpublish/form-1`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ });
+ expect(result.isPublished).toBe(false);
+ });
+ });
+
+ describe('error handling', () => {
+ it('handles network errors', async () => {
+ mockFetch.mockRejectedValueOnce(new TypeError('Network request failed'));
+
+ await expect(formsApi.getAll()).rejects.toThrow('Network request failed');
+ });
+
+ it('handles malformed JSON error response', async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response('not json', { status: 500, statusText: 'Internal Server Error' }),
+ );
+
+ await expect(formsApi.getAll()).rejects.toThrow('Request failed: Internal Server Error');
+ });
+ });
+});
+
+// ─── Fields API ──────────────────────────────────────────────
+
+describe('fieldsApi integration', () => {
+ describe('getById', () => {
+ it('fetches fields for a form', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiResponse([sampleField]));
+
+ const result = await fieldsApi.getById('form-1');
+
+ expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/fields/form-1`, {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ });
+ expect(result).toEqual([sampleField]);
+ });
+
+ it('returns empty array when form has no fields', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiResponse([]));
+
+ const result = await fieldsApi.getById('form-1');
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('getPublicById', () => {
+ it('fetches public fields without auth credentials', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiResponse([sampleField]));
+
+ const result = await fieldsApi.getPublicById('form-1');
+
+ // Public endpoint should NOT include credentials
+ expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/fields/public/form-1`, {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json' },
+ });
+ expect(result).toEqual([sampleField]);
+ });
+ });
+
+ describe('create', () => {
+ it('creates a field with correct payload', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiResponse(sampleField));
+
+ const result = await fieldsApi.create('form-1', {
+ fieldName: 'name',
+ label: 'Full Name',
+ fieldValueType: 'string',
+ fieldType: 'text',
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/fields/form-1`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ fieldName: 'name',
+ label: 'Full Name',
+ fieldValueType: 'string',
+ fieldType: 'text',
+ }),
+ credentials: 'include',
+ });
+ expect(result).toEqual(sampleField);
+ });
+
+ it('creates a field with validation rules', async () => {
+ const fieldWithValidation = {
+ ...sampleField,
+ validation: { required: true, minLength: 2, maxLength: 100 },
+ };
+ mockFetch.mockResolvedValueOnce(mockApiResponse(fieldWithValidation));
+
+ const result = await fieldsApi.create('form-1', {
+ fieldName: 'name',
+ label: 'Full Name',
+ fieldValueType: 'string',
+ fieldType: 'text',
+ validation: { required: true, minLength: 2, maxLength: 100 },
+ });
+
+ expect(result.validation).toEqual({ required: true, minLength: 2, maxLength: 100 });
+ });
+
+ it('throws on duplicate field name', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiError('Field name already exists', 400));
+
+ await expect(
+ fieldsApi.create('form-1', {
+ fieldName: 'name',
+ label: 'Full Name',
+ fieldValueType: 'string',
+ fieldType: 'text',
+ }),
+ ).rejects.toThrow('Field name already exists');
+ });
+ });
+
+ describe('update', () => {
+ it('updates a field with partial data', async () => {
+ const updated = { ...sampleField, label: 'Updated Label' };
+ mockFetch.mockResolvedValueOnce(mockApiResponse(updated));
+
+ const result = await fieldsApi.update('field-1', { label: 'Updated Label' });
+
+ expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/fields/field-1`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ label: 'Updated Label' }),
+ credentials: 'include',
+ });
+ expect(result.label).toBe('Updated Label');
+ });
+
+ it('updates field with options for select type', async () => {
+ const updatedField = { ...sampleField, fieldType: 'select', options: ['A', 'B', 'C'] };
+ mockFetch.mockResolvedValueOnce(mockApiResponse(updatedField));
+
+ const result = await fieldsApi.update('field-1', {
+ fieldType: 'select',
+ options: ['A', 'B', 'C'],
+ });
+
+ expect(result.options).toEqual(['A', 'B', 'C']);
+ });
+ });
+
+ describe('delete', () => {
+ it('deletes a field with correct request', async () => {
+ mockFetch.mockResolvedValueOnce(new Response(null, { status: 200, statusText: 'OK' }));
+
+ await fieldsApi.delete('field-1');
+
+ expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/fields/field-1`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ });
+ });
+
+ it('throws when field not found', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiError('Field not found', 404));
+
+ await expect(fieldsApi.delete('nonexistent')).rejects.toThrow('Field not found');
+ });
+ });
+});
diff --git a/src/integration_testing/responses-api.integration.test.ts b/src/integration_testing/responses-api.integration.test.ts
new file mode 100644
index 00000000..f01be7fe
--- /dev/null
+++ b/src/integration_testing/responses-api.integration.test.ts
@@ -0,0 +1,271 @@
+import { describe, expect, it } from 'vitest';
+import { responsesApi } from '../api/responses';
+import { mockApiError, mockApiResponse, mockFetch } from './setup';
+import type { FormResponse, FormResponseForOwner, UserResponse } from '../api/responses';
+
+const BASE_URL = 'http://localhost:8000';
+
+const sampleResponse: FormResponse = {
+ id: 'resp-1',
+ formId: 'form-1',
+ respondentId: 'user-2',
+ answers: { 'field-1': 'John Doe', 'field-2': 25 },
+ submittedAt: '2025-01-15T10:00:00Z',
+ updatedAt: '2025-01-15T10:00:00Z',
+};
+
+const sampleOwnerResponse: FormResponseForOwner = {
+ id: 'resp-1',
+ formId: 'form-1',
+ formTitle: 'Test Form',
+ answers: { 'Full Name': 'John Doe', 'Age': 25 },
+ rawAnswers: { 'field-1': 'John Doe', 'field-2': 25 },
+};
+
+const sampleUserResponse: UserResponse = {
+ id: 'resp-1',
+ formId: 'form-1',
+ formTitle: 'Test Form',
+ formDescription: 'A test form',
+ answers: { 'Full Name': 'John Doe' },
+ isSubmitted: true,
+ submittedAt: '2025-01-15T10:00:00Z',
+ updatedAt: '2025-01-15T10:00:00Z',
+};
+
+describe('responsesApi integration', () => {
+ describe('submit', () => {
+ it('submits a response with correct payload', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiResponse(sampleResponse));
+
+ const result = await responsesApi.submit('form-1', {
+ answers: { 'field-1': 'John Doe', 'field-2': 25 },
+ isSubmitted: true,
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/responses/submit/form-1`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ answers: { 'field-1': 'John Doe', 'field-2': 25 },
+ isSubmitted: true,
+ }),
+ credentials: 'include',
+ });
+ expect(result).toEqual(sampleResponse);
+ });
+
+ it('submits without isSubmitted flag', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiResponse(sampleResponse));
+
+ await responsesApi.submit('form-1', {
+ answers: { 'field-1': 'Jane' },
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ `${BASE_URL}/responses/submit/form-1`,
+ expect.objectContaining({
+ body: JSON.stringify({ answers: { 'field-1': 'Jane' } }),
+ }),
+ );
+ });
+
+ it('throws on form not found', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiError('Form not found', 404));
+
+ await expect(
+ responsesApi.submit('nonexistent', { answers: {} }),
+ ).rejects.toThrow('Form not found');
+ });
+
+ it('throws on unauthorized submission', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiError('Unauthorized', 401));
+
+ await expect(
+ responsesApi.submit('form-1', { answers: {} }),
+ ).rejects.toThrow('Unauthorized');
+ });
+ });
+
+ describe('saveDraft', () => {
+ it('saves a draft response with correct endpoint', async () => {
+ const draftResponse = { ...sampleResponse, id: 'draft-1' };
+ mockFetch.mockResolvedValueOnce(mockApiResponse(draftResponse));
+
+ const result = await responsesApi.saveDraft('form-1', {
+ answers: { 'field-1': 'partial answer' },
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/responses/draft/form-1`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ answers: { 'field-1': 'partial answer' } }),
+ credentials: 'include',
+ });
+ expect(result).toEqual(draftResponse);
+ });
+
+ it('saves draft with empty answers', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiResponse(sampleResponse));
+
+ await responsesApi.saveDraft('form-1', { answers: {} });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ `${BASE_URL}/responses/draft/form-1`,
+ expect.objectContaining({
+ body: JSON.stringify({ answers: {} }),
+ }),
+ );
+ });
+ });
+
+ describe('resume', () => {
+ it('resumes a draft with correct endpoint and method', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiResponse({ count: 1 }));
+
+ const result = await responsesApi.resume('resp-1', {
+ answers: { 'field-1': 'updated answer' },
+ isSubmitted: true,
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/responses/resume/resp-1`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ answers: { 'field-1': 'updated answer' },
+ isSubmitted: true,
+ }),
+ credentials: 'include',
+ });
+ expect(result).toEqual({ count: 1 });
+ });
+
+ it('throws when response not found', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiError('Response not found', 404));
+
+ await expect(
+ responsesApi.resume('nonexistent', { answers: {} }),
+ ).rejects.toThrow('Response not found');
+ });
+ });
+
+ describe('getForForm', () => {
+ it('fetches responses for a form (owner view)', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiResponse([sampleOwnerResponse]));
+
+ const result = await responsesApi.getForForm('form-1');
+
+ expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/responses/form-1`, {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ });
+ expect(result).toEqual([sampleOwnerResponse]);
+ expect(result[0].formTitle).toBe('Test Form');
+ });
+
+ it('returns empty array when no responses exist', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiResponse([]));
+
+ const result = await responsesApi.getForForm('form-1');
+ expect(result).toEqual([]);
+ });
+
+ it('throws on non-owner access', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiError('Not authorized', 403));
+
+ await expect(responsesApi.getForForm('form-1')).rejects.toThrow('Not authorized');
+ });
+ });
+
+ describe('getUserResponse', () => {
+ it('fetches user submitted response for a form', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiResponse([sampleOwnerResponse]));
+
+ const result = await responsesApi.getUserResponse('form-1');
+
+ expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/responses/user/form-1`, {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ });
+ expect(result).toHaveLength(1);
+ });
+
+ it('returns empty array when user has not responded', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiResponse([]));
+
+ const result = await responsesApi.getUserResponse('form-1');
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('getMyResponses', () => {
+ it('fetches all responses for current user', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiResponse([sampleUserResponse]));
+
+ const result = await responsesApi.getMyResponses();
+
+ expect(mockFetch).toHaveBeenCalledWith(`${BASE_URL}/responses/my`, {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ });
+ expect(result).toEqual([sampleUserResponse]);
+ expect(result[0].isSubmitted).toBe(true);
+ });
+
+ it('returns empty array when user has no responses', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiResponse([]));
+
+ const result = await responsesApi.getMyResponses();
+ expect(result).toEqual([]);
+ });
+
+ it('throws on unauthorized', async () => {
+ mockFetch.mockResolvedValueOnce(mockApiError('Unauthorized', 401));
+
+ await expect(responsesApi.getMyResponses()).rejects.toThrow('Unauthorized');
+ });
+ });
+
+ describe('error handling', () => {
+ it('handles network errors across all methods', async () => {
+ mockFetch.mockRejectedValueOnce(new TypeError('Failed to fetch'));
+
+ await expect(responsesApi.getMyResponses()).rejects.toThrow('Failed to fetch');
+ });
+
+ it('handles server errors with malformed JSON', async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response('Internal Server Error', { status: 500, statusText: 'Internal Server Error' }),
+ );
+
+ await expect(responsesApi.getForForm('form-1')).rejects.toThrow(
+ 'Request failed: Internal Server Error',
+ );
+ });
+
+ it('all endpoints include credentials for auth cookies', async () => {
+ // Verify each endpoint sends credentials
+ const endpoints = [
+ () => responsesApi.submit('f', { answers: {} }),
+ () => responsesApi.saveDraft('f', { answers: {} }),
+ () => responsesApi.resume('r', { answers: {} }),
+ () => responsesApi.getForForm('f'),
+ () => responsesApi.getUserResponse('f'),
+ () => responsesApi.getMyResponses(),
+ ];
+
+ for (const endpoint of endpoints) {
+ mockFetch.mockResolvedValueOnce(mockApiResponse({}));
+ await endpoint();
+ }
+
+ // All 6 calls should include credentials
+ for (let i = 0; i < 6; i++) {
+ expect(mockFetch.mock.calls[i][1]).toHaveProperty('credentials', 'include');
+ }
+ });
+ });
+});
diff --git a/src/integration_testing/setup.ts b/src/integration_testing/setup.ts
new file mode 100644
index 00000000..2c1e0b4e
--- /dev/null
+++ b/src/integration_testing/setup.ts
@@ -0,0 +1,49 @@
+import { afterEach, beforeEach, vi } from 'vitest';
+
+// Store the original fetch
+const originalFetch = globalThis.fetch;
+
+// Mock fetch function that can be configured per test
+export let mockFetch: ReturnType;
+
+export function resetMockFetch() {
+ mockFetch = vi.fn();
+ globalThis.fetch = mockFetch as unknown as typeof fetch;
+}
+
+export function restoreFetch() {
+ globalThis.fetch = originalFetch;
+}
+
+// Helper to create a successful API response
+export function mockApiResponse(data: T, status = 200): Response {
+ return new Response(
+ JSON.stringify({ success: true, message: 'OK', data }),
+ {
+ status,
+ statusText: 'OK',
+ headers: { 'Content-Type': 'application/json' },
+ },
+ );
+}
+
+// Helper to create an error API response
+export function mockApiError(message: string, status = 400): Response {
+ return new Response(
+ JSON.stringify({ success: false, message }),
+ {
+ status,
+ statusText: 'Bad Request',
+ headers: { 'Content-Type': 'application/json' },
+ },
+ );
+}
+
+// Auto-setup and teardown for each test
+beforeEach(() => {
+ resetMockFetch();
+});
+
+afterEach(() => {
+ restoreFetch();
+});
diff --git a/test-results/.last-run.json b/test-results/.last-run.json
new file mode 100644
index 00000000..cbcc1fba
--- /dev/null
+++ b/test-results/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "passed",
+ "failedTests": []
+}
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
index bd15cd1b..d69e50f0 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -27,5 +27,6 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
+ exclude: ['e2e/**', 'node_modules/**'],
},
})