Files
hrm-medpark/apps/api/scripts/verify-functionality.ts
Danil Suhomlinov 33800292aa chore: add Coolify deployment scaffolding (Dockerfiles, prod compose, git hygiene)
- apps/api/Dockerfile: build NestJS, run prisma migrate deploy on start
- apps/web/Dockerfile + nginx.conf: build Vite, serve static, proxy /api -> api
- docker-compose.coolify.yml: full prod stack (postgres, redis, minio, keycloak, api, web)
- .dockerignore / .gitignore / .gitattributes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:42:45 +03:00

447 lines
18 KiB
TypeScript

import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import ExcelJS = require('exceljs');
import PizZip from 'pizzip';
import {
AnexaType,
MedicalCheckupType,
MedicalVerdict,
PrismaClient,
RiskExposureType,
} from '@prisma/client';
import { DocxTemplateService } from '../src/modules/medical/services/docx-template.service';
type HttpResult = { status: number; body: unknown; text: string };
const prisma = new PrismaClient();
const warnings: string[] = [];
function ok(message: string) {
console.log(`OK ${message}`);
}
function warn(message: string) {
warnings.push(message);
console.warn(`WARN ${message}`);
}
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function dbNameFromUrl(url: string): string {
return decodeURIComponent(new URL(url).pathname.replace(/^\//, ''));
}
function requireTemporaryDatabase() {
const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is required for verification');
const dbName = dbNameFromUrl(url);
if (!dbName.startsWith('hrm_medpark_test_') && process.env.ALLOW_NON_TEST_DB !== 'true') {
throw new Error(`Refusing to verify non-test database "${dbName}". Expected hrm_medpark_test_*.`);
}
return dbName;
}
function sourcePath(fileName: string) {
return resolve(process.cwd(), '..', '..', '..', fileName);
}
function docxText(filePath: string): string {
const zip = new PizZip(readFileSync(filePath));
const xml = Object.keys(zip.files)
.filter((name) => /^word\/(document|header|footer).*\.xml$/.test(name))
.map((name) => zip.file(name)?.asText() ?? '')
.join('\n');
return xml
.replace(/<\/w:p>/g, '\n')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
async function verifySourceFiles() {
const controlDocx = process.env.CONTROL_MEDICAL_DOCX ?? sourcePath('Control medical (5).docx');
const rubriciXlsx = process.env.RUBRICI_NECESARE_XLSX ?? sourcePath('Rubrici necesare (6).xlsx');
assert(existsSync(controlDocx), `Missing source file: ${controlDocx}`);
assert(existsSync(rubriciXlsx), `Missing source file: ${rubriciXlsx}`);
const controlText = docxText(controlDocx);
for (const phrase of [
/Baza de date a angajatilor trebuie sa contina/i,
/Persoana expusa profesional la radiatii ionizante/i,
/Tipul controlului medical/i,
/Fisa de solicitare/i,
/aptitudine/i,
/Apt condi/i,
/Inapt temporar/i,
]) {
assert(phrase.test(controlText), `Control medical checklist phrase not found: ${phrase}`);
}
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(rubriciXlsx);
const rubrici = workbook.getWorksheet('Rubrici');
const performance = workbook.getWorksheet('Sheet1');
assert(rubrici, 'Rubrici necesare workbook must contain sheet "Rubrici"');
assert(performance, 'Rubrici necesare workbook must contain sheet "Sheet1"');
const fields = new Set<string>();
for (let rowIndex = 1; rowIndex <= rubrici.actualRowCount; rowIndex += 1) {
const value = excelCellText(rubrici.getRow(rowIndex).getCell(3)).trim();
if (value) fields.add(value);
}
for (const field of ['IDNP', 'Nume', 'Prenume', 'Data nasterii', 'Domiciliu', 'Nr de telefon personal', 'tipul actului de identitate', 'Nr CIM']) {
assert(fields.has(field), `Rubrici checklist field not found: ${field}`);
}
const performanceText = docxLikeSheetText(performance);
for (const phrase of ['Abilitati clinice nursing', 'Judecata clinica', 'Respectarea Dress Code', 'Membru unui comitet']) {
assert(performanceText.includes(phrase), `Performance checklist phrase not found: ${phrase}`);
}
ok('source DOCX/XLSX checklists are present and readable');
}
function docxLikeSheetText(worksheet: ExcelJS.Worksheet) {
const parts: string[] = [];
worksheet.eachRow((row) => {
row.eachCell((cell) => {
const text = excelCellText(cell).trim();
if (text) parts.push(text);
});
});
return parts.join(' ');
}
function excelCellText(cell: ExcelJS.Cell) {
const value = cell.value;
if (value == null) return '';
if (value instanceof Date) return value.toISOString();
if (typeof value !== 'object') return String(value);
if ('text' in value && typeof value.text === 'string') return value.text;
if ('result' in value && value.result != null) return String(value.result);
if ('richText' in value && Array.isArray(value.richText)) {
return value.richText.map((part) => part.text).join('');
}
if ('hyperlink' in value && 'text' in value && typeof value.text === 'string') return value.text;
return '';
}
async function verifyDatabaseCoverage() {
const [
employees,
identityDocuments,
familyMembers,
educations,
qualifications,
trainings,
sanctions,
benefits,
contracts,
riskCards,
profiles,
checkups,
evaluationForms,
] = await Promise.all([
prisma.employee.count(),
prisma.identityDocument.count(),
prisma.familyMember.count(),
prisma.education.count(),
prisma.qualification.count(),
prisma.training.count(),
prisma.disciplinarySanction.count(),
prisma.benefit.count(),
prisma.employmentContract.count(),
prisma.workplaceRiskCard.count(),
prisma.employeeMedicalProfile.count(),
prisma.medicalCheckup.count(),
prisma.evaluationForm.count(),
]);
assert(employees >= 6, `Expected at least 6 employees, got ${employees}`);
assert(identityDocuments >= employees, 'Every seeded employee should have an identity document');
assert(familyMembers >= 3, 'Expected family/contact records');
assert(educations >= employees, 'Expected education records for all employees');
assert(qualifications >= 4, 'Expected qualification records');
assert(trainings >= employees, 'Expected training records for all employees');
assert(sanctions >= 2, 'Expected disciplinary sanction scenarios');
assert(benefits >= 2, 'Expected benefit scenarios');
assert(contracts >= employees, 'Expected active CIM for all employees');
assert(riskCards >= 3, 'Expected STANDARD, radiation, and DISTANTA_DIGITAL risk cards');
assert(profiles >= employees, 'Expected medical profile for all employees');
assert(checkups >= 8, 'Expected pending and completed checkups');
assert(evaluationForms >= 3, 'Expected performance evaluation forms');
const exposureTypes = await prisma.workplaceRiskExposure.findMany({ select: { tip: true }, distinct: ['tip'] });
const seededExposureTypes = new Set(exposureTypes.map((row) => row.tip));
for (const type of Object.values(RiskExposureType)) {
assert(seededExposureTypes.has(type), `Missing risk exposure type in test seed: ${type}`);
}
const verdicts = await prisma.medicalCheckup.findMany({
where: { verdict: { not: null } },
select: { verdict: true },
distinct: ['verdict'],
});
const seededVerdicts = new Set(verdicts.map((row) => row.verdict));
for (const verdict of Object.values(MedicalVerdict)) {
assert(seededVerdicts.has(verdict), `Missing medical verdict scenario: ${verdict}`);
}
const radiationProfiles = await prisma.employeeMedicalProfile.count({ where: { expusRadiatiiIonizante: true } });
const overexposures = await prisma.radiationOverexposure.findMany({ select: { fel: true }, distinct: ['fel'] });
const remoteCards = await prisma.workplaceRiskCard.count({ where: { tipFisa: 'DISTANTA_DIGITAL' } });
const pending = await prisma.medicalCheckup.count({ where: { verdict: null } });
assert(radiationProfiles >= 2, 'Expected at least two radiation-exposed employees');
assert(overexposures.length >= 2, 'Expected exceptional and accidental overexposure rows');
assert(remoteCards >= 1, 'Expected Anexa 4A/DISTANTA_DIGITAL risk card');
assert(pending >= 3, 'Expected pending checkups for medic inbox');
ok('database seed covers HR, performance, and Control medical scenarios');
}
function extractTemplatePlaceholders(fileName: string) {
const fullPath = resolve(process.cwd(), 'templates', 'docx', fileName);
assert(existsSync(fullPath), `Missing DOCX template: ${fullPath}`);
const zip = new PizZip(readFileSync(fullPath));
const xml = Object.keys(zip.files)
.filter((name) => /^word\/(document|header|footer).*\.xml$/.test(name))
.map((name) => zip.file(name)?.asText() ?? '')
.join('\n');
const opens = (xml.match(/\{/g) ?? []).length;
const closes = (xml.match(/\}/g) ?? []).length;
assert(opens === closes, `${fileName} has unbalanced placeholders: opens=${opens}, closes=${closes}`);
const placeholders = Array.from(xml.matchAll(/\{([^{}]+)\}/g)).map((match) => match[1]);
return { placeholders, opens };
}
function valueForPlaceholder(name: string, index = 1) {
if (name.startsWith('cb')) return index % 2 === 0 ? '☐' : '☑';
if (/data|PanaLa/i.test(name)) return '27.05.2026';
if (/email/i.test(name)) return 'hr.test@medpark.md';
if (/telefon|fax/i.test(name)) return '+373 22 000 000';
if (/idnp/i.test(name)) return `19850615000${index}`;
if (/nr|numar|anNastere|ani/i.test(name)) return String(index);
if (/doza/i.test(name)) return (index + 0.25).toFixed(4);
return `Test ${name} ${index}`;
}
function sampleDocxData(placeholders: string[]) {
const data: Record<string, unknown> = {};
for (const raw of placeholders) {
const name = raw.replace(/^#|\//g, '');
if (!name || raw.startsWith('#') || raw.startsWith('/')) continue;
data[name] = valueForPlaceholder(name);
}
const row = (index: number) => {
const values: Record<string, string> = {};
for (const raw of placeholders) {
const name = raw.replace(/^#|\//g, '');
if (!name || raw.startsWith('#') || raw.startsWith('/')) continue;
values[name] = valueForPlaceholder(name, index);
}
values.nr = String(index);
values.numePrenume = index === 1 ? 'Popescu Alexandru' : 'Ionescu Maria';
values.denumire = index === 1 ? 'Glutaraldehidă' : 'HBV/HCV/HIV';
values.tipExpunere = index === 1 ? 'X externă' : 'gamma externă';
return values;
};
for (const loopName of ['angajati', 'chimici', 'pulberi', 'biologici', 'zgomot', 'vibratii', 'campEM', 'optice', 'supraexpExceptionale', 'supraexpAccidentale']) {
data[loopName] = [row(1), row(2)];
}
return data;
}
async function verifyDocxTemplates() {
const service = new DocxTemplateService();
const files: Record<AnexaType, string> = {
ANEXA_3: 'anexa-3.docx',
ANEXA_4: 'anexa-4.docx',
ANEXA_4A: 'anexa-4a.docx',
ANEXA_4B: 'anexa-4b.docx',
ANEXA_6: 'anexa-6.docx',
};
for (const [type, fileName] of Object.entries(files) as [AnexaType, string][]) {
const { placeholders, opens } = extractTemplatePlaceholders(fileName);
assert(opens > 0, `${fileName} should contain docxtemplater placeholders`);
const rendered = service.render(type, sampleDocxData(placeholders));
const zip = new PizZip(rendered);
const badParts: string[] = [];
for (const name of Object.keys(zip.files).filter((entry) => /^word\/(document|header|footer).*\.xml$/.test(entry))) {
const xml = zip.file(name)?.asText() ?? '';
if (/[{}]/.test(xml) || /\b(undefined|null)\b/i.test(xml)) badParts.push(name);
}
assert(badParts.length === 0, `${fileName} rendered XML still contains placeholders/nulls in ${badParts.join(', ')}`);
}
ok('all Anexa DOCX templates render cleanly through DocxTemplateService');
}
async function isMinioAvailable() {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1500);
try {
const response = await fetch('http://localhost:9000/minio/health/live', { signal: controller.signal });
return response.ok;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
async function apiRequest(baseUrl: string, method: string, path: string, body?: unknown, token?: string): Promise<HttpResult> {
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt += 1) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20_000);
try {
const headers: Record<string, string> = { Accept: 'application/json' };
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(`${baseUrl}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: controller.signal,
});
const text = await response.text();
let parsed: unknown = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
return { status: response.status, body: parsed, text };
} catch (error) {
lastError = error;
if (attempt < 3) await new Promise((resolvePromise) => setTimeout(resolvePromise, 1000 * attempt));
} finally {
clearTimeout(timeout);
}
}
throw new Error(`${method} ${path} failed after retries: ${String(lastError)}`);
}
function tokenFrom(body: unknown) {
if (typeof body === 'object' && body && 'token' in body && typeof (body as { token: unknown }).token === 'string') {
return (body as { token: string }).token;
}
throw new Error('dev-login response did not include token');
}
async function verifyHttpSmoke(minioAvailable: boolean) {
const baseUrl = process.env.API_BASE_URL;
if (!baseUrl) {
warn('API_BASE_URL is not set; skipped HTTP smoke tests. testdb:run starts a temporary API automatically.');
return;
}
const adminLogin = await apiRequest(baseUrl, 'POST', '/auth/dev-login', { username: 'test-admin', role: 'hr_admin' });
const specialistLogin = await apiRequest(baseUrl, 'POST', '/auth/dev-login', { username: 'test-specialist', role: 'hr_specialist' });
const medicLogin = await apiRequest(baseUrl, 'POST', '/auth/dev-login', { username: 'test-medic', role: 'medic_familie' });
assert(adminLogin.status === 201 || adminLogin.status === 200, `hr_admin dev-login failed: ${adminLogin.text}`);
assert(specialistLogin.status === 201 || specialistLogin.status === 200, `hr_specialist dev-login failed: ${specialistLogin.text}`);
assert(medicLogin.status === 201 || medicLogin.status === 200, `medic_familie dev-login failed: ${medicLogin.text}`);
const adminToken = tokenFrom(adminLogin.body);
const specialistToken = tokenFrom(specialistLogin.body);
const medicToken = tokenFrom(medicLogin.body);
for (const path of ['/dashboard/stats', '/medical/risk-cards', '/medical/upcoming-expirations']) {
const response = await apiRequest(baseUrl, 'GET', path, undefined, adminToken);
assert(response.status >= 200 && response.status < 300, `GET ${path} failed: ${response.status} ${response.text}`);
}
const employees = await prisma.employee.findMany({
where: { status: 'activ', medicalProfile: { workplaceRiskCardId: { not: null } } },
select: { id: true },
take: 5,
});
assert(employees.length >= 5, 'HTTP bulk smoke needs at least five eligible employees');
const bulkBody = {
employeeIds: employees.map((employee) => employee.id),
tip: MedicalCheckupType.periodic,
dataPlanificata: '2026-06-15',
documentContext: {
telefon: '+373 22 000 000',
fax: '+373 22 000 001',
email: 'hr.test@medpark.md',
solicitant: 'Test HR Admin',
functia: 'Specialist resurse umane',
},
};
const forbidden = await apiRequest(baseUrl, 'POST', '/medical/bulk/initiate', bulkBody, specialistToken);
assert(forbidden.status === 403, `hr_specialist bulk initiate should be 403, got ${forbidden.status}: ${forbidden.text}`);
if (!minioAvailable) {
warn('MinIO is not reachable on localhost:9000; skipped full upload-dependent bulk success and medic completion HTTP tests.');
ok('HTTP smoke passed for auth, read endpoints, and role-based 403');
return;
}
const bulk = await apiRequest(baseUrl, 'POST', '/medical/bulk/initiate', bulkBody, adminToken);
assert(bulk.status >= 200 && bulk.status < 300, `hr_admin bulk initiate failed: ${bulk.status} ${bulk.text}`);
assert(typeof bulk.body === 'object' && bulk.body && 'groupsCount' in bulk.body, 'bulk response should include groupsCount');
const pending = await prisma.medicalCheckup.findFirst({
where: { verdict: null },
orderBy: { createdAt: 'asc' },
});
assert(pending, 'No pending checkup found for medic completion smoke');
const complete = await apiRequest(
baseUrl,
'PATCH',
`/medical/checkups/${pending.id}/complete`,
{
verdict: MedicalVerdict.apt_conditionat,
dataEfectuata: '2026-06-16',
recomandari: 'Test: lucru cu dozimetru și reevaluare anuală.',
valabilPanaLa: '2027-06-16',
semnatDe: 'Dr. Verificare Test',
},
medicToken,
);
assert(complete.status >= 200 && complete.status < 300, `medic completion failed: ${complete.status} ${complete.text}`);
const completed = await prisma.medicalCheckup.findUnique({ where: { id: pending.id } });
assert(completed?.verdict === MedicalVerdict.apt_conditionat, 'Medic completion did not persist verdict');
assert(completed.semnatDe === 'Dr. Verificare Test', 'Medic completion did not persist semnatDe');
assert(Array.isArray(completed.documenteGenerate), 'Completed checkup should contain generated documents');
assert(JSON.stringify(completed.documenteGenerate).includes('Anexa_6_Final'), 'Completed checkup should include final Anexa 6 document');
ok('HTTP smoke passed, including MinIO-backed document generation');
}
async function main() {
const dbName = requireTemporaryDatabase();
console.log(`Verifying Medpark test database ${dbName}...`);
await verifySourceFiles();
await verifyDatabaseCoverage();
await verifyDocxTemplates();
const minioAvailable = await isMinioAvailable();
if (minioAvailable) ok('MinIO is reachable on localhost:9000');
else warn('MinIO is not reachable on localhost:9000; upload-dependent checks will be skipped or marked failed.');
await verifyHttpSmoke(minioAvailable);
if (warnings.length > 0) {
console.log('\nVerification completed with warnings:');
for (const message of warnings) console.log(`- ${message}`);
} else {
console.log('\nVerification completed without warnings.');
}
}
main()
.catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});