Версия цифровой рецепции с резализованным механизмом отслеживания трека пациента по зонам
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

229 lines
8.4 KiB

/**
* M6 acceptance test:
* создаём фикстуру (track + embedding) →
* POST /enrollment →
* POST /consents/.../revoke с CONSENT_REVOKE_DELAY_MS=300 →
* через ~600мс эмбеддинги удалены, трек ANONYMIZED, ФИО null,
* в biometry_access_log полный след.
*
* FaceClient и PolimedClient замоканы. Redis/Postgres — реальные.
*/
// CONSENT_REVOKE_DELAY_MS=300 устанавливается в test/setup-env.ts ДО imports.
import { Test, type TestingModule } from '@nestjs/testing';
import type { INestApplication } from '@nestjs/common';
import { ValidationPipe } from '@nestjs/common';
import { TrackStatus } from '@reception/db';
import cookieParser from 'cookie-parser';
import request from 'supertest';
import { randomUUID } from 'node:crypto';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/prisma/prisma.service';
import { FaceClient } from '../src/face/face.client';
import { PolimedClient } from '../src/polimed/polimed.client';
const SENIOR_EMAIL = 'senior@local';
const SENIOR_PASSWORD = process.env.SEED_PASSWORD_SENIOR ?? 'senior123';
class FaceClientMock {
enrolledTracks: string[] = [];
deletedPatients: string[] = [];
countCalls = 0;
async recognize() {
return null;
}
async enrollTrack(trackId: string, _patientId: string): Promise<number> {
this.enrolledTracks.push(trackId);
return 1;
}
async deletePatientEmbeddings(patientId: string): Promise<number> {
this.deletedPatients.push(patientId);
return 1;
}
async countPatientEmbeddings(_patientId: string): Promise<number> {
this.countCalls += 1;
return 0;
}
}
class PolimedClientMock {
async searchPatients(q: string) {
return [
{ id: q, fullName: 'Иванов Иван Иванович', birthDate: '1980-01-01', phone: '+7900', cardNumber: 'K-MOCK' },
];
}
async getAppointments() {
return [];
}
async getAppointment(id: string) {
return {
id,
patientId: 'pol-p-test',
patientFullName: 'Иванов Иван Иванович',
doctorFullName: 'Доктор Тест',
specialty: 'Терапевт',
scheduledFor: new Date().toISOString(),
status: 'scheduled' as const,
};
}
async pushVisitEvent() {
return;
}
}
describe('Enrollment + Consent revoke (e2e, M6)', () => {
let app: INestApplication;
let prisma: PrismaService;
let face: FaceClientMock;
let cameraId: string;
let zoneId: string;
let trackId: string;
let embeddingId: string;
const polimedPatientId = `pol-p-test-${Date.now()}`;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(FaceClient)
.useClass(FaceClientMock)
.overrideProvider(PolimedClient)
.useClass(PolimedClientMock)
.compile();
app = module.createNestApplication();
app.use(cookieParser());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
prisma = app.get(PrismaService);
face = app.get(FaceClient) as unknown as FaceClientMock;
// Готовим зону, камеру, трек, фейковый эмбеддинг.
const zone = await prisma.zone.findUniqueOrThrow({ where: { code: 'A' } });
zoneId = zone.id;
const camera = await prisma.camera.create({
data: { name: `test-cam-${Date.now()}`, zoneId },
});
cameraId = camera.id;
trackId = randomUUID();
embeddingId = randomUUID();
await prisma.track.create({
data: {
id: trackId,
firstSeenAt: new Date(Date.now() - 60_000),
lastSeenAt: new Date(),
},
});
// Прямой INSERT эмбеддинга через $executeRawUnsafe (синтетический 512-d).
const embeddingLiteral = '[' + Array.from({ length: 512 }, () => Math.random()).join(',') + ']';
await prisma.$executeRawUnsafe(
`INSERT INTO face_embeddings (id, embedding, track_id, camera_id, quality, captured_at, created_at)
VALUES ($1::uuid, $2::vector, $3::uuid, $4::uuid, $5, NOW(), NOW())`,
embeddingId,
embeddingLiteral,
trackId,
cameraId,
0.9,
);
});
afterAll(async () => {
await prisma.$executeRawUnsafe(`DELETE FROM face_embeddings WHERE track_id = $1::uuid`, trackId);
await prisma.visit.deleteMany({ where: { patient: { polimedPatientId } } });
await prisma.patientConsent.deleteMany({ where: { patient: { polimedPatientId } } });
await prisma.consentRevocationJob.deleteMany({ where: { patient: { polimedPatientId } } });
await prisma.biometryAccessLog.deleteMany({ where: { subjectPatientId: { not: null } } });
await prisma.track.deleteMany({ where: { id: trackId } });
await prisma.patient.deleteMany({ where: { polimedPatientId } });
await prisma.camera.deleteMany({ where: { id: cameraId } });
await app.close();
});
async function loginSenior(): Promise<string[]> {
const res = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: SENIOR_EMAIL, password: SENIOR_PASSWORD });
expect(res.status).toBe(200);
return (res.headers['set-cookie'] as unknown as string[]) ?? [];
}
it('full enrollment + revoke flow', async () => {
const cookies = await loginSenior();
// 1. Enrollment.
const enrollRes = await request(app.getHttpServer())
.post('/enrollment')
.set('Cookie', cookies)
.send({
trackId,
polimedPatientId,
polimedAppointmentId: 'pol-a-test',
paperConsentRef: 'paper-ref-001',
});
expect(enrollRes.status).toBe(201);
expect(enrollRes.body.patientId).toBeDefined();
expect(enrollRes.body.visitId).toBeDefined();
const patientId: string = enrollRes.body.patientId;
// FaceClient.enrollTrack был вызван.
expect(face.enrolledTracks).toContain(trackId);
// Проверяем DB-состояние после enrollment.
const patientAfterEnroll = await prisma.patient.findUniqueOrThrow({ where: { id: patientId } });
expect(patientAfterEnroll.polimedPatientId).toBe(polimedPatientId);
expect(patientAfterEnroll.consentReceivedAt).not.toBeNull();
const trackAfterEnroll = await prisma.track.findUniqueOrThrow({ where: { id: trackId } });
expect(trackAfterEnroll.patientId).toBe(patientId);
expect(trackAfterEnroll.status).toBe(TrackStatus.MATCHED);
const grantedConsent = await prisma.patientConsent.findFirst({
where: { patientId, action: 'GRANTED' },
});
expect(grantedConsent).not.toBeNull();
expect(grantedConsent?.paperRef).toBe('paper-ref-001');
// 2. Revoke consent.
const revokeRes = await request(app.getHttpServer())
.post(`/consents/${patientId}/revoke`)
.set('Cookie', cookies);
expect(revokeRes.status).toBe(202);
expect(revokeRes.body.jobId).toBeDefined();
// Сразу после revoke — consentRevokedAt и pendingDeletionAt установлены.
const patientAfterRevoke = await prisma.patient.findUniqueOrThrow({ where: { id: patientId } });
expect(patientAfterRevoke.consentRevokedAt).not.toBeNull();
expect(patientAfterRevoke.pendingDeletionAt).not.toBeNull();
// 3. Ждём срабатывания BullMQ (delay=300мс + обработка).
await new Promise((r) => setTimeout(r, 1500));
// 4. Проверяем результат отложенной задачи.
expect(face.deletedPatients).toContain(patientId);
const patientFinal = await prisma.patient.findUniqueOrThrow({ where: { id: patientId } });
expect(patientFinal.fullName).toBeNull();
expect(patientFinal.pendingDeletionAt).toBeNull();
const trackFinal = await prisma.track.findUniqueOrThrow({ where: { id: trackId } });
expect(trackFinal.status).toBe(TrackStatus.ANONYMIZED);
const job = await prisma.consentRevocationJob.findFirstOrThrow({ where: { patientId } });
expect(job.status).toBe('DONE');
expect(job.completedAt).not.toBeNull();
// 5. Полный аудит-след в biometry_access_log.
const logs = await prisma.biometryAccessLog.findMany({
where: { subjectPatientId: patientId },
orderBy: { occurredAt: 'asc' },
});
const actions = logs.map((l) => l.action);
expect(actions).toContain('enroll');
expect(actions).toContain('consent_revoke');
expect(actions).toContain('consent_revocation_completed');
}, 30_000);
});