From 158c63e38e448b43588a6162259a7df64c32e4e5 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 9 Sep 2026 17:25:46 -0400 Subject: [PATCH] feat(templates): add faker-backed test fixture factories to generated projects Every generated project now ships tests/fixtures/ with a generic createFactory() (overrides, createMany with per-index overrides, a sequence for unique fields, reset) and a userFactory built on the users schema, plus tests/fixtures.test.ts as living documentation and @faker-js/faker as a dev dependency. Multi-tenant projects also get tenantFactory and projectFactory. The schema now exports User/NewUser, the snippet type-check covers the fixtures via tsconfig.fixtures.json, and the generated README and tdd-from-schema skill point at the factories. Closes #26 Co-Authored-By: Claude Fable 5.1 --- .claude/skills/tdd-from-schema/SKILL.md | 4 +- .github/workflows/ci.yml | 4 + scripts/__tests__/setup-project.test.js | 4 + scripts/setup-project.sh | 25 ++++- templates/snippets/package.json | 4 +- templates/snippets/pnpm-lock.yaml | 9 ++ templates/snippets/shared/src/db/schema.ts | 3 + .../snippets/shared/tests/fixtures.test.ts | 93 +++++++++++++++++++ .../snippets/shared/tests/fixtures/factory.ts | 88 ++++++++++++++++++ .../snippets/shared/tests/fixtures/index.ts | 9 ++ .../snippets/shared/tests/fixtures/tenancy.ts | 29 ++++++ templates/snippets/tsconfig.fixtures.json | 17 ++++ 12 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 templates/snippets/shared/tests/fixtures.test.ts create mode 100644 templates/snippets/shared/tests/fixtures/factory.ts create mode 100644 templates/snippets/shared/tests/fixtures/index.ts create mode 100644 templates/snippets/shared/tests/fixtures/tenancy.ts create mode 100644 templates/snippets/tsconfig.fixtures.json diff --git a/.claude/skills/tdd-from-schema/SKILL.md b/.claude/skills/tdd-from-schema/SKILL.md index aa1254f..d2c7716 100644 --- a/.claude/skills/tdd-from-schema/SKILL.md +++ b/.claude/skills/tdd-from-schema/SKILL.md @@ -144,7 +144,9 @@ import { SignJWT } from 'jose'; const JWT_SECRET = new TextEncoder().encode('test-secret-key-at-least-32-chars!!'); -// Factory functions +// Factory functions. Build the insert values with the faker-backed factories +// every generated project ships in tests/fixtures/ (userFactory, createFactory) +// so helpers only add the database round-trip. export async function createTestUser( overrides: Partial = {}, ) { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acd0af8..9c5fe65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -288,6 +288,10 @@ jobs: run: pnpm exec tsc --noEmit -p tsconfig.lambda.json working-directory: templates/snippets + - name: Type-check test fixture snippets + run: pnpm exec tsc --noEmit -p tsconfig.fixtures.json + working-directory: templates/snippets + # Generates a real project per target with setup-project.sh and type-checks # it. Unlike typecheck-templates this has no lockfile on purpose: it installs # whatever `pnpm add` resolves today, so a dependency release that breaks diff --git a/scripts/__tests__/setup-project.test.js b/scripts/__tests__/setup-project.test.js index 56f790c..fc5d973 100644 --- a/scripts/__tests__/setup-project.test.js +++ b/scripts/__tests__/setup-project.test.js @@ -70,6 +70,7 @@ const SHARED_FILES = [ "api/tsconfig.base.json", "api/tsconfig.json", "api/eslint.config.js", "api/prettier.config.js", "api/vitest.config.ts", "api/package.json", "api/pnpm-workspace.yaml", "api/drizzle.config.ts", "api/src/db/schema.ts", "api/src/db/client.ts", "api/src/db/schema-drift.ts", "api/tests/setup.ts", + "api/tests/fixtures", "api/tests/fixtures/factory.ts", "api/tests/fixtures/index.ts", "api/tests/fixtures.test.ts", "api/scripts/check-destructive-migrations.mjs", ".github/workflows/schema-drift.yml", ".github/workflows/schema-applied.yml", "postman/collection.json", "postman/environment.json", "README.md", "api/.gitignore", @@ -152,11 +153,14 @@ describe("setup-project.sh", () => { } r.listed("api/src/db/rls-policies.sql"); r.listed("api/tests/tenancy.test.ts"); + r.listed("api/tests/fixtures/tenancy.ts"); + expect(r.stdout).toContain(`Would append to: ${join(r.target, "api/tests/fixtures/index.ts")}`); expect(r.stdout).toContain(`Would append to: ${join(r.target, "api/.env.example")}`); expect(r.stdout).toMatch(/Tenancy:\s+multi-tenant/); const plain = dryRun("plain-proj", ["--node"]); plain.notListed("api/src/tenancy"); plain.notListed("api/tests/tenancy.test.ts"); + plain.notListed("api/tests/fixtures/tenancy.ts"); // cloudflare keeps its env in .dev.vars.example const cf = dryRun("mt-cf", ["--cloudflare", "--multi-tenant"]); expect(cf.stdout).toContain(`Would append to: ${join(cf.target, "api/.dev.vars.example")}`); diff --git a/scripts/setup-project.sh b/scripts/setup-project.sh index 45ec841..a6b4803 100755 --- a/scripts/setup-project.sh +++ b/scripts/setup-project.sh @@ -133,7 +133,7 @@ info "Creating Nerva project: ${CYAN}$PROJECT_NAME${NC} (platform: $PLATFORM)" step "Creating directory structure..." make_dirs "$TARGET_DIR/api/src"/{routes,db/migrations,middleware,lib,types} -make_dirs "$TARGET_DIR/api/tests"/{unit,integration,load} +make_dirs "$TARGET_DIR/api/tests"/{unit,integration,load,fixtures} make_dirs "$TARGET_DIR/api/scripts" make_dirs "$TARGET_DIR/.github/workflows" make_dirs "$TARGET_DIR/docs" @@ -240,7 +240,7 @@ step "Installing dev dependencies..." # generated projects compile (TS 6 stopped auto-including @types/*); bump it # deliberately, together with the snippets and the shared tsconfig templates. run_cmd pnpm add -D vitest typescript@^6 eslint prettier drizzle-kit @types/node tsx \ - @eslint/js typescript-eslint @vitest/coverage-v8 + @eslint/js typescript-eslint @vitest/coverage-v8 @faker-js/faker success "Dev dependencies installed." step "Creating initial source files..." @@ -314,6 +314,12 @@ fi copy_file "$TEMPLATES_DIR/snippets/shared/tests/setup.ts" "$API_DIR/tests/setup.ts" copy_file "$TEMPLATES_DIR/snippets/shared/tests/soft-delete.test.ts" "$API_DIR/tests/soft-delete.test.ts" +# Test data factories (@faker-js/faker): a generic createFactory plus a +# userFactory for the users table, and an example test that documents usage. +copy_file "$TEMPLATES_DIR/snippets/shared/tests/fixtures/factory.ts" "$API_DIR/tests/fixtures/factory.ts" +copy_file "$TEMPLATES_DIR/snippets/shared/tests/fixtures/index.ts" "$API_DIR/tests/fixtures/index.ts" +copy_file "$TEMPLATES_DIR/snippets/shared/tests/fixtures.test.ts" "$API_DIR/tests/fixtures.test.ts" + if [[ "$PLATFORM" == "cloudflare" ]]; then copy_file "$TEMPLATES_DIR/snippets/cloudflare/tests/unit/health.test.ts" "$API_DIR/tests/unit/health.test.ts" elif [[ "$PLATFORM" == "lambda" ]]; then @@ -487,6 +493,12 @@ if $MULTI_TENANT; then # live RLS/search_path suite activates when TENANCY_TEST_DATABASE_URL is set. copy_file "$TEMPLATES_DIR/snippets/shared/tests/tenancy.test.ts" "$API_DIR/tests/tenancy.test.ts" + # Factories for the tenants and projects tables, re-exported from tests/fixtures. + copy_file "$TEMPLATES_DIR/snippets/shared/tests/fixtures/tenancy.ts" "$API_DIR/tests/fixtures/tenancy.ts" + append_file "$API_DIR/tests/fixtures/index.ts" << 'MTFIXEOF' +export { tenantFactory, projectFactory } from './tenancy.js'; +MTFIXEOF + if [[ "$PLATFORM" == "cloudflare" ]]; then ENV_EXAMPLE_FILE=".dev.vars.example" else @@ -693,6 +705,15 @@ pnpm test # run the test suite \`\`\` The dev server listens at $DEV_BASE_URL -- verify with a GET to /health. + +## Test data + +\`api/tests/fixtures/\` holds faker-backed factories. \`userFactory()\` builds an +insert-ready user, \`userFactory({ name: 'Custom' })\` overrides fields, and +\`userFactory.createMany(5)\` builds a batch with unique emails. Call +\`seedFactories()\` at the top of a test for reproducible data. Add one factory +per table and re-export it from \`tests/fixtures/index.ts\`; +\`tests/fixtures.test.ts\` shows the pattern. READMEDYN if [[ "$PLATFORM" == "cloudflare" ]]; then cat << 'READMECF' diff --git a/templates/snippets/package.json b/templates/snippets/package.json index 511619f..9038cce 100644 --- a/templates/snippets/package.json +++ b/templates/snippets/package.json @@ -7,10 +7,12 @@ "typecheck:cf": "tsc --noEmit -p tsconfig.cloudflare.json", "typecheck:node": "tsc --noEmit -p tsconfig.node.json", "typecheck:lambda": "tsc --noEmit -p tsconfig.lambda.json", - "typecheck": "pnpm run typecheck:cf && pnpm run typecheck:node && pnpm run typecheck:lambda" + "typecheck": "pnpm run typecheck:cf && pnpm run typecheck:node && pnpm run typecheck:lambda && pnpm run typecheck:fixtures", + "typecheck:fixtures": "tsc --noEmit -p tsconfig.fixtures.json" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260415.1", + "@faker-js/faker": "^10.6.0", "@hono/node-server": "^1.16.0", "@types/node": "^25.6.0", "drizzle-orm": "^0.45.2", diff --git a/templates/snippets/pnpm-lock.yaml b/templates/snippets/pnpm-lock.yaml index c240b27..2caa0d5 100644 --- a/templates/snippets/pnpm-lock.yaml +++ b/templates/snippets/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@cloudflare/workers-types': specifier: ^4.20260415.1 version: 4.20260511.1 + '@faker-js/faker': + specifier: ^10.6.0 + version: 10.6.0 '@hono/node-server': specifier: ^1.16.0 version: 1.19.14(hono@4.12.18) @@ -38,6 +41,10 @@ packages: '@cloudflare/workers-types@4.20260511.1': resolution: {integrity: sha512-FA+si7cOq9i/gtCHhIc0XJL0l1F/ApF+m00752Aj7WZFJrj3ZulT2T8/+rT3BabMT0QEnqFEGIqCgrmqhgEfMg==} + '@faker-js/faker@10.6.0': + resolution: {integrity: sha512-3RQHgEtvL1Frl/d1cSreo7qhJ3Gk1OdNUai/CtZ8G+wYeRQnJih3s9xJ9/kgYekPQRdwgh0HXRPqMlzWGwivIQ==} + engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -162,6 +169,8 @@ snapshots: '@cloudflare/workers-types@4.20260511.1': {} + '@faker-js/faker@10.6.0': {} + '@hono/node-server@1.19.14(hono@4.12.18)': dependencies: hono: 4.12.18 diff --git a/templates/snippets/shared/src/db/schema.ts b/templates/snippets/shared/src/db/schema.ts index c5d6ed6..36b860c 100644 --- a/templates/snippets/shared/src/db/schema.ts +++ b/templates/snippets/shared/src/db/schema.ts @@ -10,3 +10,6 @@ export const users = pgTable('users', { // Read queries must filter `WHERE deleted_at IS NULL` (see ./soft-delete.ts). deletedAt: timestamp('deleted_at', { withTimezone: true }), }); + +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; diff --git a/templates/snippets/shared/tests/fixtures.test.ts b/templates/snippets/shared/tests/fixtures.test.ts new file mode 100644 index 0000000..50fc8a0 --- /dev/null +++ b/templates/snippets/shared/tests/fixtures.test.ts @@ -0,0 +1,93 @@ +/** + * Example usage of the test data factories in tests/fixtures/. + * + * These tests double as living documentation: they show building a single + * entity, applying overrides, building many, and seeding faker for + * reproducible data. They need no database. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { faker } from '@faker-js/faker'; +import { createFactory, seedFactories, userFactory } from './fixtures/index.js'; +import type { NewUser } from '../src/db/schema.js'; + +describe('userFactory', () => { + beforeEach(() => { + userFactory.reset(); + seedFactories(); + }); + + it('builds a user with a realistic name and a lowercase example.com email', () => { + const user = userFactory(); + expect(user.name).toMatch(/^\S+ \S+$/); + expect(user.email).toMatch(/^[a-z0-9.'-]+@example\.com$/); + expect(user.email).toBe(user.email.toLowerCase()); + }); + + it('applies overrides on top of generated values', () => { + const user = userFactory({ name: 'Custom Name' }); + expect(user.name).toBe('Custom Name'); + expect(user.email).toContain('@example.com'); + expect(userFactory.build({ email: 'fixed@example.com' }).email).toBe('fixed@example.com'); + }); + + it('createMany returns the requested number of users with unique emails', () => { + const users = userFactory.createMany(5); + expect(users).toHaveLength(5); + expect(new Set(users.map((u) => u.email)).size).toBe(5); + }); + + it('createMany accepts per-entity overrides by index', () => { + const users = userFactory.createMany(3, (i) => ({ name: `User ${i}` })); + expect(users.map((u) => u.name)).toEqual(['User 0', 'User 1', 'User 2']); + }); + + it('createMany rejects a non-integer or negative count', () => { + expect(() => userFactory.createMany(-1)).toThrow(RangeError); + expect(() => userFactory.createMany(1.5)).toThrow(RangeError); + expect(userFactory.createMany(0)).toEqual([]); + }); + + it('produces the same data on every run once seeded', () => { + seedFactories(42); + userFactory.reset(); + const first = userFactory.createMany(3); + seedFactories(42); + userFactory.reset(); + const second = userFactory.createMany(3); + expect(second).toEqual(first); + }); + + it('matches the insert shape of the users table', () => { + // Compile-time check: a factory result is assignable to NewUser, so it can + // be passed straight to db.insert(users).values(...). + const values: NewUser[] = userFactory.createMany(2); + expect(values.every((v) => typeof v.email === 'string' && typeof v.name === 'string')).toBe(true); + // Columns with database defaults are intentionally left unset. + expect(values[0]).not.toHaveProperty('id'); + expect(values[0]).not.toHaveProperty('createdAt'); + }); +}); + +describe('createFactory', () => { + it('threads a 1-based sequence through the generator and resets on demand', () => { + const numbered = createFactory<{ n: number }>((sequence) => ({ n: sequence })); + expect(numbered().n).toBe(1); + expect(numbered().n).toBe(2); + numbered.reset(); + expect(numbered().n).toBe(1); + }); + + it('works with any entity shape, not just schema tables', () => { + interface Token { + value: string; + expiresInSeconds: number; + } + const tokenFactory = createFactory(() => ({ + value: faker.string.alphanumeric(32), + expiresInSeconds: 900, + })); + const token = tokenFactory({ expiresInSeconds: 60 }); + expect(token.value).toHaveLength(32); + expect(token.expiresInSeconds).toBe(60); + }); +}); diff --git a/templates/snippets/shared/tests/fixtures/factory.ts b/templates/snippets/shared/tests/fixtures/factory.ts new file mode 100644 index 0000000..b3881fc --- /dev/null +++ b/templates/snippets/shared/tests/fixtures/factory.ts @@ -0,0 +1,88 @@ +/** + * Test data factories. + * + * A factory builds plain insert-shaped objects with realistic values from + * `@faker-js/faker`, so tests never hand-write entities. Every call takes an + * optional `overrides` object for the fields a test cares about; everything + * else is generated. A per-factory sequence keeps generated identifiers unique + * across a test run even when faker repeats a value. + * + * Usage: + * + * const user = userFactory(); // one NewUser + * const admin = userFactory({ name: 'Admin User' });// with overrides + * const users = userFactory.createMany(5); // five unique users + * const numbered = userFactory.createMany(3, (i) => ({ name: `User ${i}` })); + * + * Reproducibility: call `seedFactories()` (or `faker.seed(n)`) at the top of a + * test file to get the same data on every run. Add a factory per entity below + * as your schema grows; keep them in this directory and re-export from + * ./index.ts so tests import from one place. + */ +import { faker } from '@faker-js/faker'; +import type { NewUser } from '../../src/db/schema.js'; + +export type Overrides = Partial | ((index: number) => Partial); + +export interface Factory { + /** Build one entity, applying `overrides` on top of generated values. */ + (overrides?: Partial): T; + /** Alias of the call signature, for readability in table-driven tests. */ + build(overrides?: Partial): T; + /** + * Build `count` entities. `overrides` may be an object applied to every + * entity or a function of the zero-based index for per-entity values. + */ + createMany(count: number, overrides?: Overrides): T[]; + /** Reset the sequence counter (useful between seeded runs). */ + reset(): void; +} + +/** + * Create a typed factory from a generator. The generator receives a + * monotonically increasing sequence number, starting at 1, that it can fold + * into unique fields such as emails or slugs. + */ +export function createFactory(generate: (sequence: number) => T): Factory { + let sequence = 0; + + const build = (overrides?: Partial): T => { + sequence += 1; + return { ...generate(sequence), ...overrides }; + }; + + const factory = ((overrides?: Partial) => build(overrides)) as Factory; + factory.build = build; + factory.createMany = (count, overrides) => { + if (!Number.isInteger(count) || count < 0) { + throw new RangeError(`createMany expects a non-negative integer, received ${String(count)}`); + } + return Array.from({ length: count }, (_, index) => + build(typeof overrides === 'function' ? overrides(index) : overrides), + ); + }; + factory.reset = () => { + sequence = 0; + }; + return factory; +} + +/** Seed faker so factories produce identical data on every run. */ +export function seedFactories(seed = 12345): void { + faker.seed(seed); +} + +/** + * Users, matching `users` in src/db/schema.ts. Columns with database defaults + * (id, timestamps, deleted_at) are left for PostgreSQL to fill, so the result + * can be passed straight to `db.insert(users).values(...)`. + */ +export const userFactory = createFactory((sequence) => { + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + return { + // The sequence guarantees uniqueness; the name keeps it readable. + email: `${firstName}.${lastName}.${sequence}@example.com`.toLowerCase(), + name: `${firstName} ${lastName}`, + }; +}); diff --git a/templates/snippets/shared/tests/fixtures/index.ts b/templates/snippets/shared/tests/fixtures/index.ts new file mode 100644 index 0000000..1ef364d --- /dev/null +++ b/templates/snippets/shared/tests/fixtures/index.ts @@ -0,0 +1,9 @@ +/** + * Single import point for test fixtures: + * + * import { userFactory, seedFactories } from './fixtures/index.js'; + * + * Add a line here for each new factory module. + */ +export { createFactory, seedFactories, userFactory } from './factory.js'; +export type { Factory, Overrides } from './factory.js'; diff --git a/templates/snippets/shared/tests/fixtures/tenancy.ts b/templates/snippets/shared/tests/fixtures/tenancy.ts new file mode 100644 index 0000000..20450fb --- /dev/null +++ b/templates/snippets/shared/tests/fixtures/tenancy.ts @@ -0,0 +1,29 @@ +/** + * Factories for the multi-tenant tables in src/tenancy/schema.ts. + * + * const tenant = tenantFactory({ plan: 'pro' }); + * const [row] = await db.insert(tenants).values(tenant).returning(); + * const project = projectFactory({ tenantId: row.id }); + * + * `projectFactory` generates a random tenantId so the object is complete on + * its own; pass the real id of an inserted tenant when writing to the + * database, since the column is a foreign key. + */ +import { faker } from '@faker-js/faker'; +import type { NewProject, NewTenant } from '../../src/tenancy/schema.js'; +import { createFactory } from './factory.js'; + +export const tenantFactory = createFactory((sequence) => { + const name = faker.company.name(); + return { + name, + // Slugs must be unique and URL-safe: derived from the name, suffixed by the sequence. + slug: `${faker.helpers.slugify(name).toLowerCase().replace(/[^a-z0-9-]/g, '')}-${sequence}`, + plan: 'free', + }; +}); + +export const projectFactory = createFactory(() => ({ + tenantId: faker.string.uuid(), + name: faker.commerce.productName(), +})); diff --git a/templates/snippets/tsconfig.fixtures.json b/templates/snippets/tsconfig.fixtures.json new file mode 100644 index 0000000..418261b --- /dev/null +++ b/templates/snippets/tsconfig.fixtures.json @@ -0,0 +1,17 @@ +{ + "extends": "../shared/tsconfig.node.json", + "compilerOptions": { + "noEmit": true, + "moduleResolution": "bundler", + "rootDir": ".", + "paths": {} + }, + "include": [ + "shared/tests/fixtures/**/*.ts", + "shared/src/db/schema.ts", + "shared/src/tenancy/schema.ts" + ], + "exclude": [ + "node_modules" + ] +}