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:
parent
6fa0d87da4
commit
6d30edf88f
6 changed files with 2563 additions and 11 deletions
27
knip.json
Normal file
27
knip.json
Normal 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
2327
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
|
@ -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
71
shared/schemas.test.ts
Normal 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
118
shared/schemas.ts
Normal 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
20
vitest.config.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue