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
a559cdf388
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",
|
"build": "tsc",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"dev": "ts-node-dev --respawn --transpile-only server.ts",
|
"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",
|
"e2e": "./scripts/e2e.sh",
|
||||||
"lint:refs": "node scripts/lint-references.js",
|
"lint:refs": "node scripts/lint-references.js",
|
||||||
|
"lint:dead": "knip",
|
||||||
"maint:check": "node scripts/maintenance.js check",
|
"maint:check": "node scripts/maintenance.js check",
|
||||||
"maint:reindex": "node scripts/maintenance.js reindex",
|
"maint:reindex": "node scripts/maintenance.js reindex",
|
||||||
"migrate": "node-pg-migrate",
|
"migrate": "node-pg-migrate",
|
||||||
|
|
@ -70,7 +73,11 @@
|
||||||
"@types/ms": "^2.1.0",
|
"@types/ms": "^2.1.0",
|
||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
|
"@vitest/coverage-v8": "^4.1.5",
|
||||||
|
"knip": "^6.6.2",
|
||||||
"ts-node-dev": "^2.0.0",
|
"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