Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/skills/tdd-from-schema/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof users.$inferInsert> = {},
) {
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions scripts/__tests__/setup-project.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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")}`);
Expand Down
25 changes: 23 additions & 2 deletions scripts/setup-project.sh

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion templates/snippets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions templates/snippets/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions templates/snippets/shared/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
93 changes: 93 additions & 0 deletions templates/snippets/shared/tests/fixtures.test.ts
Original file line number Diff line number Diff line change
@@ -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<Token>(() => ({
value: faker.string.alphanumeric(32),
expiresInSeconds: 900,
}));
const token = tokenFactory({ expiresInSeconds: 60 });
expect(token.value).toHaveLength(32);
expect(token.expiresInSeconds).toBe(60);
});
});
88 changes: 88 additions & 0 deletions templates/snippets/shared/tests/fixtures/factory.ts
Original file line number Diff line number Diff line change
@@ -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<T> = Partial<T> | ((index: number) => Partial<T>);

export interface Factory<T> {
/** Build one entity, applying `overrides` on top of generated values. */
(overrides?: Partial<T>): T;
/** Alias of the call signature, for readability in table-driven tests. */
build(overrides?: Partial<T>): 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>): 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<T extends object>(generate: (sequence: number) => T): Factory<T> {
let sequence = 0;

const build = (overrides?: Partial<T>): T => {
sequence += 1;
return { ...generate(sequence), ...overrides };
};

const factory = ((overrides?: Partial<T>) => build(overrides)) as Factory<T>;
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<NewUser>((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}`,
};
});
9 changes: 9 additions & 0 deletions templates/snippets/shared/tests/fixtures/index.ts
Original file line number Diff line number Diff line change
@@ -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';
29 changes: 29 additions & 0 deletions templates/snippets/shared/tests/fixtures/tenancy.ts
Original file line number Diff line number Diff line change
@@ -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<NewTenant>((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<NewProject>(() => ({
tenantId: faker.string.uuid(),
name: faker.commerce.productName(),
}));
17 changes: 17 additions & 0 deletions templates/snippets/tsconfig.fixtures.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
Loading