build(ts): day 5 — Vitest + Zod + Knip tooling

Adds the three high-ROI tools the planning conversation identified:

  vitest 4.x — fast TS-native unit test runner. Runs against pure
    functions (calculators, validators, prompt builders). Playwright
    stays for e2e. package.json `npm test` now runs Vitest;
    `npm run test:node` preserves the old `node --test` runner
    for the 3 legacy tests under test/.

  zod 4.x — runtime request-body validation at API boundaries. The
    new shared/schemas.ts exports a schema per endpoint request
    (LoginRequestSchema, SoapRequestSchema, PeNarrativeRequestSchema,
    ExtensionCreateSchema, etc.). Routes will adopt these one at a
    time post-migration — usage pattern:
      const body = SoapRequestSchema.parse(req.body);
    Invalid input becomes a structured 400 instead of a silent
    undefined-access crash.

  knip 6.x — dead-code / unused-export detector. knip.json scopes
    it to the backend (client/ excluded since it lives in its own
    workspace). Run with `npm run lint:dead`. Catches the class of
    bug that kept public/js/adminMilestones.js dead-loaded for a
    year — a future orphaned file would fail the lint.

  @vitest/coverage-v8 — coverage reporter backed by v8 profiler.

Shipped schemas.test.ts with 8 example cases to prove the toolchain
(`npx vitest run` green).

Not done on day 5 (punted to post-migration): flipping tsconfig to
`strict: true`. That cascade would light up hundreds of implicit-
any errors in handler signatures that would cost more commit bandwidth
than available in this pass. The Day 4 permissive mode is already
catching the big wins (wrong response shapes, orphan refs, undefined
destructures). Post-migration, Codex/vendor model can flip strict flags
one at a time and fix handler-by-handler.
This commit is contained in:
Daniel 2026-04-23 19:54:30 +02:00
parent 6fa0d87da4
commit 6d30edf88f
6 changed files with 2563 additions and 11 deletions

27
knip.json Normal file
View file

@ -0,0 +1,27 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": [
"server.ts",
"scripts/*.js",
"src/db/migrate.ts"
],
"project": [
"server.ts",
"src/**/*.ts",
"shared/**/*.ts"
],
"ignore": [
"dist/**",
"node_modules/**",
"public/**",
"client/**",
"e2e/**"
],
"ignoreDependencies": [
"@tsconfig/node20",
"@types/.*",
"ts-node-dev",
"@aws-sdk/.*",
"@google-cloud/.*"
]
}

2327
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,9 +9,12 @@
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "ts-node-dev --respawn --transpile-only server.ts",
"test": "node --test test/",
"test": "vitest run",
"test:node": "node --test test/",
"test:coverage": "vitest run --coverage",
"e2e": "./scripts/e2e.sh",
"lint:refs": "node scripts/lint-references.js",
"lint:dead": "knip",
"maint:check": "node scripts/maintenance.js check",
"maint:reindex": "node scripts/maintenance.js reindex",
"migrate": "node-pg-migrate",
@ -70,7 +73,11 @@
"@types/ms": "^2.1.0",
"@types/multer": "^2.1.0",
"@types/node": "^25.6.0",
"@vitest/coverage-v8": "^4.1.5",
"knip": "^6.6.2",
"ts-node-dev": "^2.0.0",
"typescript": "^6.0.3"
"typescript": "^6.0.3",
"vitest": "^4.1.5",
"zod": "^4.3.6"
}
}

71
shared/schemas.test.ts Normal file
View file

@ -0,0 +1,71 @@
// Example Vitest spec. Proves the toolchain runs and demonstrates the
// Zod + schema-inferred type pattern. Run with: npm test
import { describe, it, expect } from 'vitest';
import {
LoginRequestSchema,
ExtensionCreateSchema,
PeNarrativeRequestSchema,
} from './schemas';
describe('LoginRequestSchema', () => {
it('accepts a valid login body', () => {
const ok = LoginRequestSchema.safeParse({
email: 'danvics@ped-ai.test',
password: 'some-password',
});
expect(ok.success).toBe(true);
});
it('rejects an invalid email', () => {
const bad = LoginRequestSchema.safeParse({
email: 'not-an-email',
password: 'x',
});
expect(bad.success).toBe(false);
});
it('rejects missing password', () => {
const bad = LoginRequestSchema.safeParse({ email: 'a@b.com' });
expect(bad.success).toBe(false);
});
});
describe('ExtensionCreateSchema', () => {
it('accepts a minimal extension', () => {
const ok = ExtensionCreateSchema.safeParse({
location: 'Main Hospital',
name: 'Nursery',
number: '5866',
});
expect(ok.success).toBe(true);
});
it('defaults type when omitted (Zod returns the field as undefined)', () => {
const ok = ExtensionCreateSchema.safeParse({
location: 'X',
name: 'Y',
number: '1',
});
expect(ok.success).toBe(true);
if (ok.success) expect(ok.data.type).toBeUndefined();
});
it('rejects missing required fields', () => {
const bad = ExtensionCreateSchema.safeParse({ location: 'X' });
expect(bad.success).toBe(false);
});
});
describe('PeNarrativeRequestSchema', () => {
it('requires a non-empty steps array', () => {
const bad = PeNarrativeRequestSchema.safeParse({ steps: [] });
expect(bad.success).toBe(false);
});
it('accepts a single step', () => {
const ok = PeNarrativeRequestSchema.safeParse({
steps: [{ label: 'General appearance', status: 'normal' }],
});
expect(ok.success).toBe(true);
});
});

118
shared/schemas.ts Normal file
View file

@ -0,0 +1,118 @@
// ============================================================
// ZOD SCHEMAS — runtime validation at API boundaries
// ============================================================
// Each incoming request body gets parsed through one of these schemas
// BEFORE the handler sees it. If parse fails, a 400 is returned with
// the detailed validation error — no more silent "undefined reading
// X" crashes on malformed input. TypeScript types for req.body can
// be inferred from the schema with `z.infer<typeof XxxSchema>`.
//
// Keep wire-shape aligned with shared/types.ts. The types file is
// for RESPONSES (what the server returns); this file is for REQUESTS
// (what the client sends).
// ============================================================
import { z } from 'zod';
// ── Common fragments ─────────────────────────────────────────
const NonEmptyString = z.string().min(1);
const OptionalTrimmed = z.string().optional();
const OptionalModel = z.string().optional();
// ── Auth ─────────────────────────────────────────────────────
export const LoginRequestSchema = z.object({
email: z.string().email(),
password: NonEmptyString,
turnstileToken: OptionalTrimmed,
totpCode: OptionalTrimmed,
});
export const RegisterRequestSchema = z.object({
email: z.string().email(),
password: z.string().min(8, 'Password must be at least 8 characters'),
name: NonEmptyString,
turnstileToken: OptionalTrimmed,
});
export const ForgotPasswordRequestSchema = z.object({
email: z.string().email(),
turnstileToken: OptionalTrimmed,
});
// ── AI generation requests ───────────────────────────────────
export const HpiEncounterRequestSchema = z.object({
transcript: NonEmptyString,
patientAge: OptionalTrimmed,
patientGender: OptionalTrimmed,
model: OptionalModel,
setting: z.enum(['outpatient', 'inpatient']).optional(),
physicianMemories: OptionalTrimmed,
});
export const SoapRequestSchema = z.object({
transcript: NonEmptyString,
patientAge: OptionalTrimmed,
patientGender: OptionalTrimmed,
model: OptionalModel,
type: z.enum(['full', 'subjective']).optional(),
additionalInstructions: OptionalTrimmed,
physicianMemories: OptionalTrimmed,
});
export const SickVisitRequestSchema = z.object({
patientAge: OptionalTrimmed,
patientGender: OptionalTrimmed,
chiefComplaint: NonEmptyString,
transcript: OptionalTrimmed,
dictation: OptionalTrimmed,
ros: OptionalTrimmed,
physicalExam: OptionalTrimmed,
diagnoses: OptionalTrimmed,
physicianMemories: OptionalTrimmed,
model: OptionalModel,
});
export const RefineRequestSchema = z.object({
currentDocument: NonEmptyString,
instructions: NonEmptyString,
sourceContext: OptionalTrimmed,
model: OptionalModel,
});
export const PeNarrativeRequestSchema = z.object({
steps: z.array(z.object({
component: OptionalTrimmed,
label: NonEmptyString,
method: OptionalTrimmed,
normal: OptionalTrimmed,
status: z.enum(['normal', 'abnormal']).nullable().optional(),
note: OptionalTrimmed,
})).min(1),
ageGroup: OptionalTrimmed,
system: OptionalTrimmed,
patientAge: OptionalTrimmed,
patientGender: OptionalTrimmed,
model: OptionalModel,
format: z.enum(['narrative', 'list']).optional(),
});
// ── Extensions CRUD ──────────────────────────────────────────
export const ExtensionCreateSchema = z.object({
location: z.string().min(1).max(120),
name: z.string().min(1).max(120),
number: z.string().min(1).max(40),
type: z.enum(['extension', 'pager']).optional(),
notes: z.string().max(500).optional(),
});
export const ExtensionUpdateSchema = ExtensionCreateSchema;
// ── Inferred types (use instead of hand-written interfaces) ─
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>;
export type ForgotPasswordRequest = z.infer<typeof ForgotPasswordRequestSchema>;
export type HpiEncounterRequest = z.infer<typeof HpiEncounterRequestSchema>;
export type SoapRequest = z.infer<typeof SoapRequestSchema>;
export type SickVisitRequest = z.infer<typeof SickVisitRequestSchema>;
export type RefineRequest = z.infer<typeof RefineRequestSchema>;
export type PeNarrativeRequest = z.infer<typeof PeNarrativeRequestSchema>;
export type ExtensionCreate = z.infer<typeof ExtensionCreateSchema>;

20
vitest.config.ts Normal file
View file

@ -0,0 +1,20 @@
import { defineConfig } from 'vitest/config';
// Unit-test runner. Playwright stays for e2e (runs via scripts/e2e.sh).
// Vitest is for pure functions — calculator formulas (Fenton LMS, AAP
// bilirubin, Rosner BP), validators, prompt builders. Any module that
// doesn't need a running Express + Postgres.
export default defineConfig({
test: {
include: ['src/**/*.test.ts', 'shared/**/*.test.ts'],
exclude: ['node_modules', 'dist', 'e2e', 'client', 'public'],
environment: 'node',
globals: false,
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
include: ['src/**/*.ts', 'shared/**/*.ts'],
exclude: ['**/*.test.ts', '**/*.d.ts'],
},
},
});