From 1ae75dff9b11e6f1d6073394e12b1972cbfa21a1 Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Fri, 9 Jan 2026 15:20:48 +0000 Subject: [PATCH 1/3] feat: add api tests --- bun.lock | 5 + packages/api/routers/api-v1/index.ts | 7 +- tests/api/api-v1.test.ts | 527 +++++++++++++++++++++++++++ tests/helpers/api-v1.ts | 17 + tests/helpers/db.ts | 15 + tests/helpers/factories.ts | 55 ++- tests/helpers/index.ts | 1 + tests/package.json | 25 +- tests/vitest.config.ts | 12 + 9 files changed, 651 insertions(+), 13 deletions(-) create mode 100644 tests/api/api-v1.test.ts create mode 100644 tests/helpers/api-v1.ts diff --git a/bun.lock b/bun.lock index eb41542..ca71257 100644 --- a/bun.lock +++ b/bun.lock @@ -279,6 +279,9 @@ "tests": { "name": "@formbase/tests", "version": "0.1.0", + "dependencies": { + "zod": "3.23.8", + }, "devDependencies": { "@formbase/api": "workspace:*", "@formbase/db": "workspace:*", @@ -2456,6 +2459,8 @@ "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@formbase/tests/zod": ["zod@3.23.8", "", {}, "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g=="], + "@formbase/utils/tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="], "@formbase/web/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], diff --git a/packages/api/routers/api-v1/index.ts b/packages/api/routers/api-v1/index.ts index 91bcf60..51db99c 100644 --- a/packages/api/routers/api-v1/index.ts +++ b/packages/api/routers/api-v1/index.ts @@ -1,6 +1,8 @@ +import { initTRPC } from '@trpc/server'; + import { formsRouter } from './forms'; import { submissionsRouter } from './submissions'; -import { createApiV1Router } from './trpc'; +import { createApiV1Router, type ApiV1Context } from './trpc'; export const apiV1Router = createApiV1Router({ forms: formsRouter, @@ -9,4 +11,7 @@ export const apiV1Router = createApiV1Router({ export type ApiV1Router = typeof apiV1Router; +const t = initTRPC.context().create(); +export const createApiV1CallerFactory = t.createCallerFactory(apiV1Router); + export { createApiV1Context, type ApiV1Context } from './trpc'; diff --git a/tests/api/api-v1.test.ts b/tests/api/api-v1.test.ts new file mode 100644 index 0000000..88f75e2 --- /dev/null +++ b/tests/api/api-v1.test.ts @@ -0,0 +1,527 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + createApiV1Caller, + createExpiredApiKey, + createTestApiKey, + createTestForm, + createTestFormData, + createTestUser, + type TestApiKey, + type TestUser, +} from '../helpers'; + +describe('API v1', () => { + let user: TestUser; + let apiKey: TestApiKey; + + beforeEach(async () => { + user = await createTestUser({ email: 'api-test@example.com' }); + apiKey = await createTestApiKey({ userId: user.id }); + }); + + describe('Authentication', () => { + it('succeeds with valid API key', async () => { + const caller = await createApiV1Caller(apiKey.key); + const result = await caller.forms.list({ page: 1, perPage: 20 }); + expect(result.forms).toBeDefined(); + }); + + it('fails with missing auth header', async () => { + const caller = await createApiV1Caller(); + await expect(caller.forms.list({ page: 1, perPage: 20 })).rejects.toThrow( + 'Invalid or missing API key', + ); + }); + + it('fails with invalid API key', async () => { + const caller = await createApiV1Caller('invalid-key'); + await expect(caller.forms.list({ page: 1, perPage: 20 })).rejects.toThrow( + 'Invalid or missing API key', + ); + }); + + it('fails with expired API key', async () => { + const expiredKey = await createExpiredApiKey({ userId: user.id }); + const caller = await createApiV1Caller(expiredKey.key); + await expect(caller.forms.list({ page: 1, perPage: 20 })).rejects.toThrow( + 'Invalid or missing API key', + ); + }); + }); + + describe('Rate Limiting', () => { + it('returns 429 when rate limit exceeded', async () => { + const rateLimitKey = await createTestApiKey({ + userId: user.id, + name: 'Rate Limit Test Key', + }); + + const caller = await createApiV1Caller(rateLimitKey.key); + + for (let i = 0; i < 100; i++) { + await caller.forms.list({ page: 1, perPage: 20 }); + } + + await expect( + caller.forms.list({ page: 1, perPage: 20 }), + ).rejects.toThrow('Rate limit exceeded'); + }); + }); + + describe('Forms', () => { + describe('list', () => { + it('returns paginated list of forms', async () => { + await createTestForm({ userId: user.id, title: 'Form 1' }); + await createTestForm({ userId: user.id, title: 'Form 2' }); + + const caller = await createApiV1Caller(apiKey.key); + const result = await caller.forms.list({ page: 1, perPage: 20 }); + + expect(result.forms).toHaveLength(2); + expect(result.pagination).toMatchObject({ + page: 1, + perPage: 20, + total: 2, + totalPages: 1, + }); + }); + + it('respects pagination parameters', async () => { + for (let i = 0; i < 5; i++) { + await createTestForm({ userId: user.id, title: `Form ${i}` }); + } + + const caller = await createApiV1Caller(apiKey.key); + const result = await caller.forms.list({ page: 2, perPage: 2 }); + + expect(result.forms).toHaveLength(2); + expect(result.pagination.page).toBe(2); + expect(result.pagination.totalPages).toBe(3); + }); + + it('returns empty list for user with no forms', async () => { + const caller = await createApiV1Caller(apiKey.key); + const result = await caller.forms.list({ page: 1, perPage: 20 }); + + expect(result.forms).toHaveLength(0); + expect(result.pagination.total).toBe(0); + }); + }); + + describe('create', () => { + it('creates a form with valid input', async () => { + const caller = await createApiV1Caller(apiKey.key); + const result = await caller.forms.create({ + title: 'New Form', + description: 'Test description', + }); + + expect(result.id).toBeDefined(); + expect(result.id).toHaveLength(15); + }); + + it('creates form with optional fields', async () => { + const caller = await createApiV1Caller(apiKey.key); + const result = await caller.forms.create({ + title: 'Test Form', + description: 'A description', + returnUrl: 'https://example.com/thanks', + }); + + expect(result.id).toBeDefined(); + + const form = await caller.forms.get({ formId: result.id }); + expect(form.returnUrl).toBe('https://example.com/thanks'); + }); + }); + + describe('get', () => { + it('returns form for owner', async () => { + const form = await createTestForm({ + userId: user.id, + title: 'My Form', + description: 'Test', + }); + + const caller = await createApiV1Caller(apiKey.key); + const result = await caller.forms.get({ formId: form.id }); + + expect(result.title).toBe('My Form'); + expect(result.description).toBe('Test'); + }); + + it('throws NOT_FOUND for non-existent form', async () => { + const caller = await createApiV1Caller(apiKey.key); + await expect( + caller.forms.get({ formId: 'nonexistent12345' }), + ).rejects.toThrow('Form not found'); + }); + + it('throws NOT_FOUND for form owned by another user', async () => { + const otherUser = await createTestUser({ email: 'other@example.com' }); + const otherForm = await createTestForm({ + userId: otherUser.id, + title: 'Other Form', + }); + + const caller = await createApiV1Caller(apiKey.key); + await expect( + caller.forms.get({ formId: otherForm.id }), + ).rejects.toThrow('Form not found'); + }); + }); + + describe('update', () => { + it('updates form properties', async () => { + const form = await createTestForm({ + userId: user.id, + title: 'Original', + }); + + const caller = await createApiV1Caller(apiKey.key); + await caller.forms.update({ + formId: form.id, + title: 'Updated', + description: 'New description', + }); + + const updated = await caller.forms.get({ formId: form.id }); + expect(updated.title).toBe('Updated'); + expect(updated.description).toBe('New description'); + }); + + it('throws NOT_FOUND for form owned by another user', async () => { + const otherUser = await createTestUser({ email: 'other2@example.com' }); + const otherForm = await createTestForm({ + userId: otherUser.id, + title: 'Other Form', + }); + + const caller = await createApiV1Caller(apiKey.key); + await expect( + caller.forms.update({ formId: otherForm.id, title: 'Hacked' }), + ).rejects.toThrow('Form not found'); + }); + }); + + describe('delete', () => { + it('deletes form owned by user', async () => { + const form = await createTestForm({ + userId: user.id, + title: 'To Delete', + }); + + const caller = await createApiV1Caller(apiKey.key); + await caller.forms.delete({ formId: form.id }); + + await expect( + caller.forms.get({ formId: form.id }), + ).rejects.toThrow('Form not found'); + }); + + it('throws NOT_FOUND for form owned by another user', async () => { + const otherUser = await createTestUser({ email: 'other3@example.com' }); + const otherForm = await createTestForm({ + userId: otherUser.id, + title: 'Protected', + }); + + const caller = await createApiV1Caller(apiKey.key); + await expect( + caller.forms.delete({ formId: otherForm.id }), + ).rejects.toThrow('Form not found'); + }); + }); + + describe('duplicate', () => { + it('duplicates form with configuration', async () => { + const form = await createTestForm({ + userId: user.id, + title: 'Original Form', + description: 'Test description', + }); + + const caller = await createApiV1Caller(apiKey.key); + const result = await caller.forms.duplicate({ formId: form.id }); + + expect(result.id).toBeDefined(); + expect(result.id).not.toBe(form.id); + + const duplicated = await caller.forms.get({ formId: result.id }); + expect(duplicated.title).toBe('Original Form (Copy)'); + }); + + it('throws NOT_FOUND for non-existent form', async () => { + const caller = await createApiV1Caller(apiKey.key); + await expect( + caller.forms.duplicate({ formId: 'nonexistent12345' }), + ).rejects.toThrow('Form not found'); + }); + }); + + describe('bulkCreate', () => { + it('creates multiple forms', async () => { + const caller = await createApiV1Caller(apiKey.key); + const result = await caller.forms.bulkCreate({ + forms: [ + { title: 'Bulk Form 1' }, + { title: 'Bulk Form 2' }, + { title: 'Bulk Form 3' }, + ], + }); + + expect(result.ids).toHaveLength(3); + }); + }); + + describe('bulkUpdate', () => { + it('updates multiple forms', async () => { + const form1 = await createTestForm({ userId: user.id, title: 'Form 1' }); + const form2 = await createTestForm({ userId: user.id, title: 'Form 2' }); + + const caller = await createApiV1Caller(apiKey.key); + await caller.forms.bulkUpdate({ + forms: [ + { id: form1.id, title: 'Updated 1' }, + { id: form2.id, title: 'Updated 2' }, + ], + }); + + const list = await caller.forms.list({ page: 1, perPage: 20 }); + const titles = list.forms.map((f) => f.title); + expect(titles).toContain('Updated 1'); + expect(titles).toContain('Updated 2'); + }); + + it('fails if any form not owned by user', async () => { + const form1 = await createTestForm({ userId: user.id, title: 'Mine' }); + const otherUser = await createTestUser({ email: 'other4@example.com' }); + const form2 = await createTestForm({ + userId: otherUser.id, + title: 'Not Mine', + }); + + const caller = await createApiV1Caller(apiKey.key); + await expect( + caller.forms.bulkUpdate({ + forms: [ + { id: form1.id, title: 'Updated' }, + { id: form2.id, title: 'Hacked' }, + ], + }), + ).rejects.toThrow('Form not found'); + }); + }); + + describe('bulkDelete', () => { + it('deletes multiple forms', async () => { + const form1 = await createTestForm({ + userId: user.id, + title: 'Delete 1', + }); + const form2 = await createTestForm({ + userId: user.id, + title: 'Delete 2', + }); + + const caller = await createApiV1Caller(apiKey.key); + await caller.forms.bulkDelete({ ids: [form1.id, form2.id] }); + + const list = await caller.forms.list({ page: 1, perPage: 20 }); + expect(list.forms).toHaveLength(0); + }); + + it('fails with mixed ownership', async () => { + const myForm = await createTestForm({ userId: user.id, title: 'Mine' }); + const otherUser = await createTestUser({ email: 'other5@example.com' }); + const otherForm = await createTestForm({ + userId: otherUser.id, + title: 'Not Mine', + }); + + const caller = await createApiV1Caller(apiKey.key); + await expect( + caller.forms.bulkDelete({ ids: [myForm.id, otherForm.id] }), + ).rejects.toThrow('Forms not found'); + }); + }); + }); + + describe('Submissions', () => { + let form: Awaited>; + + beforeEach(async () => { + form = await createTestForm({ userId: user.id, title: 'Test Form' }); + }); + + describe('list', () => { + it('returns paginated list of submissions', async () => { + await createTestFormData({ formId: form.id, data: { name: 'John' } }); + await createTestFormData({ formId: form.id, data: { name: 'Jane' } }); + + const caller = await createApiV1Caller(apiKey.key); + const result = await caller.submissions.list({ + formId: form.id, + page: 1, + perPage: 20, + }); + + expect(result.submissions).toHaveLength(2); + expect(result.pagination.total).toBe(2); + }); + + it('supports date filtering', async () => { + await createTestFormData({ formId: form.id, data: { name: 'Old' } }); + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const caller = await createApiV1Caller(apiKey.key); + const result = await caller.submissions.list({ + formId: form.id, + page: 1, + perPage: 20, + startDate: tomorrow.toISOString(), + }); + + expect(result.submissions).toHaveLength(0); + }); + + it('throws NOT_FOUND for form not owned by user', async () => { + const otherUser = await createTestUser({ email: 'other6@example.com' }); + const otherForm = await createTestForm({ + userId: otherUser.id, + title: 'Other', + }); + + const caller = await createApiV1Caller(apiKey.key); + await expect( + caller.submissions.list({ + formId: otherForm.id, + page: 1, + perPage: 20, + }), + ).rejects.toThrow('Form not found'); + }); + }); + + describe('get', () => { + it('returns single submission', async () => { + const submission = await createTestFormData({ + formId: form.id, + data: { name: 'Test', email: 'test@test.com' }, + }); + + const caller = await createApiV1Caller(apiKey.key); + const result = await caller.submissions.get({ + formId: form.id, + submissionId: submission.id, + }); + + expect(result.data.name).toBe('Test'); + expect(result.data.email).toBe('test@test.com'); + }); + + it('throws NOT_FOUND if submission not in form', async () => { + const otherForm = await createTestForm({ + userId: user.id, + title: 'Other Form', + }); + const submission = await createTestFormData({ + formId: otherForm.id, + data: { name: 'Other' }, + }); + + const caller = await createApiV1Caller(apiKey.key); + await expect( + caller.submissions.get({ + formId: form.id, + submissionId: submission.id, + }), + ).rejects.toThrow('Submission not found'); + }); + }); + + describe('delete', () => { + it('deletes single submission', async () => { + const submission = await createTestFormData({ + formId: form.id, + data: { name: 'Delete Me' }, + }); + + const caller = await createApiV1Caller(apiKey.key); + await caller.submissions.delete({ + formId: form.id, + submissionId: submission.id, + }); + + await expect( + caller.submissions.get({ + formId: form.id, + submissionId: submission.id, + }), + ).rejects.toThrow('Submission not found'); + }); + + it('throws NOT_FOUND for non-existent submission', async () => { + const caller = await createApiV1Caller(apiKey.key); + await expect( + caller.submissions.delete({ + formId: form.id, + submissionId: 'nonexistent123', + }), + ).rejects.toThrow('Submission not found'); + }); + }); + + describe('bulkDelete', () => { + it('deletes multiple submissions', async () => { + const sub1 = await createTestFormData({ + formId: form.id, + data: { name: 'Sub 1' }, + }); + const sub2 = await createTestFormData({ + formId: form.id, + data: { name: 'Sub 2' }, + }); + + const caller = await createApiV1Caller(apiKey.key); + await caller.submissions.bulkDelete({ + formId: form.id, + ids: [sub1.id, sub2.id], + }); + + const list = await caller.submissions.list({ + formId: form.id, + page: 1, + perPage: 20, + }); + expect(list.submissions).toHaveLength(0); + }); + + it('fails with submissions from different form', async () => { + const sub1 = await createTestFormData({ + formId: form.id, + data: { name: 'Mine' }, + }); + const otherForm = await createTestForm({ + userId: user.id, + title: 'Other', + }); + const sub2 = await createTestFormData({ + formId: otherForm.id, + data: { name: 'Other' }, + }); + + const caller = await createApiV1Caller(apiKey.key); + await expect( + caller.submissions.bulkDelete({ + formId: form.id, + ids: [sub1.id, sub2.id], + }), + ).rejects.toThrow('Submissions not found'); + }); + }); + }); +}); diff --git a/tests/helpers/api-v1.ts b/tests/helpers/api-v1.ts new file mode 100644 index 0000000..75ad907 --- /dev/null +++ b/tests/helpers/api-v1.ts @@ -0,0 +1,17 @@ +import { getTestDb } from './db'; + +export async function createApiV1Caller(apiKeyToken?: string) { + const { createApiV1CallerFactory } = await import('@formbase/api/routers/api-v1'); + + const headers = new Headers(); + if (apiKeyToken) { + headers.set('Authorization', `Bearer ${apiKeyToken}`); + } + + const ctx = { + db: getTestDb(), + headers, + }; + + return createApiV1CallerFactory(() => ctx); +} diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index a44421b..0feda56 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -88,6 +88,18 @@ CREATE TABLE IF NOT EXISTS verification ( updated_at INTEGER DEFAULT (cast(unixepoch('subsec') * 1000 as integer)) NOT NULL ); +CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + key_prefix TEXT NOT NULL, + expires_at INTEGER, + last_used_at INTEGER, + created_at INTEGER DEFAULT (cast(unixepoch('subsec') * 1000 as integer)) NOT NULL, + FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE +); + CREATE INDEX IF NOT EXISTS user_email_idx ON user(email); CREATE INDEX IF NOT EXISTS session_userId_idx ON session(user_id); CREATE INDEX IF NOT EXISTS account_userId_idx ON account(user_id); @@ -96,9 +108,12 @@ CREATE INDEX IF NOT EXISTS form_created_at_idx ON forms(created_at); CREATE INDEX IF NOT EXISTS form_idx ON form_datas(form_id); CREATE INDEX IF NOT EXISTS form_data_created_at_idx ON form_datas(created_at); CREATE INDEX IF NOT EXISTS verification_identifier_idx ON verification(identifier); +CREATE INDEX IF NOT EXISTS api_key_user_idx ON api_keys(user_id); +CREATE INDEX IF NOT EXISTS api_key_hash_idx ON api_keys(key_hash); `; const RESET_SQL = ` +DELETE FROM api_keys; DELETE FROM form_datas; DELETE FROM onboarding_forms; DELETE FROM forms; diff --git a/tests/helpers/factories.ts b/tests/helpers/factories.ts index 7e7e12d..e1d0d9c 100644 --- a/tests/helpers/factories.ts +++ b/tests/helpers/factories.ts @@ -1,5 +1,6 @@ -import { formDatas, forms } from '@formbase/db/schema'; +import { apiKeys, formDatas, forms } from '@formbase/db/schema'; import { generateId } from '@formbase/utils/generate-id'; +import { generateApiKey, hashApiKey } from '@formbase/api/lib/api-key'; import { getTestDb } from './db'; @@ -74,3 +75,55 @@ export async function createTestFormData(options: { data, }; } + +export interface TestApiKey { + id: string; + userId: string; + name: string; + key: string; + keyPrefix: string; + expiresAt: Date | null; +} + +export async function createTestApiKey(options: { + userId: string; + name?: string; + expiresAt?: Date | null; +}): Promise { + const db = getTestDb(); + const id = generateId(15); + const name = options.name ?? 'Test API Key'; + const { key, prefix, hash } = generateApiKey(); + + db.insert(apiKeys) + .values({ + id, + userId: options.userId, + name, + keyHash: hash, + keyPrefix: prefix, + expiresAt: options.expiresAt ?? null, + }) + .run(); + + return { + id, + userId: options.userId, + name, + key, + keyPrefix: prefix, + expiresAt: options.expiresAt ?? null, + }; +} + +export async function createExpiredApiKey(options: { + userId: string; + name?: string; +}): Promise { + const expiredDate = new Date(Date.now() - 1000 * 60 * 60); + return createTestApiKey({ + userId: options.userId, + name: options.name ?? 'Expired API Key', + expiresAt: expiredDate, + }); +} diff --git a/tests/helpers/index.ts b/tests/helpers/index.ts index 5956fd4..fffbe96 100644 --- a/tests/helpers/index.ts +++ b/tests/helpers/index.ts @@ -2,3 +2,4 @@ export * from './auth'; export * from './db'; export * from './factories'; export * from './trpc'; +export * from './api-v1'; diff --git a/tests/package.json b/tests/package.json index 9e08424..2e49c14 100644 --- a/tests/package.json +++ b/tests/package.json @@ -1,17 +1,6 @@ { "name": "@formbase/tests", "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage", - "test:e2e": "playwright test", - "test:e2e:ui": "playwright test --ui", - "test:e2e:seed": "bun run --env-file=../apps/web/.env.local ./e2e/seed.ts", - "test:e2e:cleanup": "bun run --env-file=../apps/web/.env.local ./e2e/cleanup.ts" - }, "devDependencies": { "@formbase/api": "workspace:*", "@formbase/db": "workspace:*", @@ -23,5 +12,19 @@ "drizzle-orm": "^0.30.10", "typescript": "5.8.2", "vitest": "^2.1.8" + }, + "private": true, + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:seed": "bun run --env-file=../apps/web/.env.local ./e2e/seed.ts", + "test:e2e:cleanup": "bun run --env-file=../apps/web/.env.local ./e2e/cleanup.ts" + }, + "type": "module", + "dependencies": { + "zod": "3.23.8" } } diff --git a/tests/vitest.config.ts b/tests/vitest.config.ts index 7fd980c..0cead19 100644 --- a/tests/vitest.config.ts +++ b/tests/vitest.config.ts @@ -1,6 +1,13 @@ import path from 'path'; import { defineConfig } from 'vitest/config'; +process.env['SKIP_ENV_VALIDATION'] = 'true'; +process.env['NODE_ENV'] = 'test'; +process.env['DATABASE_URL'] = 'file::memory:?cache=shared'; +process.env['BETTER_AUTH_SECRET'] = 'test-secret-minimum-32-characters-long-for-testing'; +process.env['NEXT_PUBLIC_APP_URL'] = 'http://localhost:3000'; +process.env['ALLOW_SIGNIN_SIGNUP'] = 'true'; + export default defineConfig({ test: { globals: true, @@ -16,6 +23,11 @@ export default defineConfig({ singleFork: true, // Serial execution for SQLite }, }, + server: { + deps: { + inline: ['zod', '@t3-oss/env-core'], + }, + }, coverage: { provider: 'v8', reporter: ['text', 'json', 'html', 'lcov'], From 63b8742e6b97a55e65f6c2e9c212e0c74b160fae Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Fri, 9 Jan 2026 16:03:58 +0000 Subject: [PATCH 2/3] chore: cleanup --- bun.lock | 5 ----- tests/api/api-v1.test.ts | 10 +++++----- tests/package.json | 5 +---- tests/vitest.setup.ts | 1 - 4 files changed, 6 insertions(+), 15 deletions(-) diff --git a/bun.lock b/bun.lock index ca71257..eb41542 100644 --- a/bun.lock +++ b/bun.lock @@ -279,9 +279,6 @@ "tests": { "name": "@formbase/tests", "version": "0.1.0", - "dependencies": { - "zod": "3.23.8", - }, "devDependencies": { "@formbase/api": "workspace:*", "@formbase/db": "workspace:*", @@ -2459,8 +2456,6 @@ "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], - "@formbase/tests/zod": ["zod@3.23.8", "", {}, "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g=="], - "@formbase/utils/tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="], "@formbase/web/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], diff --git a/tests/api/api-v1.test.ts b/tests/api/api-v1.test.ts index 88f75e2..7a0fe9f 100644 --- a/tests/api/api-v1.test.ts +++ b/tests/api/api-v1.test.ts @@ -192,7 +192,7 @@ describe('API v1', () => { }); it('throws NOT_FOUND for form owned by another user', async () => { - const otherUser = await createTestUser({ email: 'other2@example.com' }); + const otherUser = await createTestUser({ email: 'other@example.com' }); const otherForm = await createTestForm({ userId: otherUser.id, title: 'Other Form', @@ -221,7 +221,7 @@ describe('API v1', () => { }); it('throws NOT_FOUND for form owned by another user', async () => { - const otherUser = await createTestUser({ email: 'other3@example.com' }); + const otherUser = await createTestUser({ email: 'other@example.com' }); const otherForm = await createTestForm({ userId: otherUser.id, title: 'Protected', @@ -296,7 +296,7 @@ describe('API v1', () => { it('fails if any form not owned by user', async () => { const form1 = await createTestForm({ userId: user.id, title: 'Mine' }); - const otherUser = await createTestUser({ email: 'other4@example.com' }); + const otherUser = await createTestUser({ email: 'other@example.com' }); const form2 = await createTestForm({ userId: otherUser.id, title: 'Not Mine', @@ -334,7 +334,7 @@ describe('API v1', () => { it('fails with mixed ownership', async () => { const myForm = await createTestForm({ userId: user.id, title: 'Mine' }); - const otherUser = await createTestUser({ email: 'other5@example.com' }); + const otherUser = await createTestUser({ email: 'other@example.com' }); const otherForm = await createTestForm({ userId: otherUser.id, title: 'Not Mine', @@ -389,7 +389,7 @@ describe('API v1', () => { }); it('throws NOT_FOUND for form not owned by user', async () => { - const otherUser = await createTestUser({ email: 'other6@example.com' }); + const otherUser = await createTestUser({ email: 'other@example.com' }); const otherForm = await createTestForm({ userId: otherUser.id, title: 'Other', diff --git a/tests/package.json b/tests/package.json index 2e49c14..b10be38 100644 --- a/tests/package.json +++ b/tests/package.json @@ -23,8 +23,5 @@ "test:e2e:seed": "bun run --env-file=../apps/web/.env.local ./e2e/seed.ts", "test:e2e:cleanup": "bun run --env-file=../apps/web/.env.local ./e2e/cleanup.ts" }, - "type": "module", - "dependencies": { - "zod": "3.23.8" - } + "type": "module" } diff --git a/tests/vitest.setup.ts b/tests/vitest.setup.ts index fc0aa99..c04cac5 100644 --- a/tests/vitest.setup.ts +++ b/tests/vitest.setup.ts @@ -6,7 +6,6 @@ import { teardownTestDatabase, } from './helpers/db'; -// Set test environment variables before any imports process.env['SKIP_ENV_VALIDATION'] = 'true'; process.env['NODE_ENV'] = 'test'; process.env['DATABASE_URL'] = 'file::memory:?cache=shared'; From cc59806cabc598eb113dec596f85cd45a7621e47 Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Thu, 15 Jan 2026 08:33:40 +0000 Subject: [PATCH 3/3] fix: resolve linting errors in test files --- tests/helpers/factories.ts | 2 +- tests/vitest.config.ts | 2 +- tests/vitest.setup.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/helpers/factories.ts b/tests/helpers/factories.ts index e1d0d9c..aa02f2a 100644 --- a/tests/helpers/factories.ts +++ b/tests/helpers/factories.ts @@ -1,6 +1,6 @@ import { apiKeys, formDatas, forms } from '@formbase/db/schema'; import { generateId } from '@formbase/utils/generate-id'; -import { generateApiKey, hashApiKey } from '@formbase/api/lib/api-key'; +import { generateApiKey } from '@formbase/api/lib/api-key'; import { getTestDb } from './db'; diff --git a/tests/vitest.config.ts b/tests/vitest.config.ts index 0cead19..296ae6c 100644 --- a/tests/vitest.config.ts +++ b/tests/vitest.config.ts @@ -2,7 +2,7 @@ import path from 'path'; import { defineConfig } from 'vitest/config'; process.env['SKIP_ENV_VALIDATION'] = 'true'; -process.env['NODE_ENV'] = 'test'; +process.env.NODE_ENV = 'test'; process.env['DATABASE_URL'] = 'file::memory:?cache=shared'; process.env['BETTER_AUTH_SECRET'] = 'test-secret-minimum-32-characters-long-for-testing'; process.env['NEXT_PUBLIC_APP_URL'] = 'http://localhost:3000'; diff --git a/tests/vitest.setup.ts b/tests/vitest.setup.ts index c04cac5..ba27345 100644 --- a/tests/vitest.setup.ts +++ b/tests/vitest.setup.ts @@ -7,7 +7,7 @@ import { } from './helpers/db'; process.env['SKIP_ENV_VALIDATION'] = 'true'; -process.env['NODE_ENV'] = 'test'; +process.env.NODE_ENV = 'test'; process.env['DATABASE_URL'] = 'file::memory:?cache=shared'; process.env['BETTER_AUTH_SECRET'] = 'test-secret-minimum-32-characters-long-for-testing';