tests: create unit tests for audit modules (controller, gateway, service). fix: add error handling with throwing error instead of logging only
This commit is contained in:
parent
fda1d5d92a
commit
74d5da7475
|
|
@ -1,18 +1,233 @@
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { AuditController } from './audit.controller';
|
import { AuditController } from './audit.controller';
|
||||||
|
import { AuditService } from './audit.service';
|
||||||
|
import { AuthGuard } from '../auth/guard/auth.guard';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
describe('AuditController', () => {
|
describe('AuditController', () => {
|
||||||
let controller: AuditController;
|
let controller: AuditController;
|
||||||
|
let auditService: jest.Mocked<AuditService>;
|
||||||
|
|
||||||
|
const mockAuditService = {
|
||||||
|
getAuditTrails: jest.fn(),
|
||||||
|
storeAuditTrail: jest.fn(),
|
||||||
|
getCountAuditTamperedData: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockJwtService = {
|
||||||
|
verifyAsync: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [AuditController],
|
controllers: [AuditController],
|
||||||
}).compile();
|
providers: [
|
||||||
|
{ provide: AuditService, useValue: mockAuditService },
|
||||||
|
{ provide: JwtService, useValue: mockJwtService },
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
controller = module.get<AuditController>(AuditController);
|
controller = module.get<AuditController>(AuditController);
|
||||||
|
auditService = module.get(AuditService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(controller).toBeDefined();
|
expect(controller).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getAuditTrail', () => {
|
||||||
|
const mockAuditLogs = {
|
||||||
|
0: {
|
||||||
|
id: 'REKAM_1',
|
||||||
|
event: 'rekam_medis_created',
|
||||||
|
result: 'non_tampered',
|
||||||
|
},
|
||||||
|
1: { id: 'OBAT_1', event: 'obat_created', result: 'tampered' },
|
||||||
|
totalCount: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should return audit trails with default parameters', async () => {
|
||||||
|
mockAuditService.getAuditTrails.mockResolvedValue(mockAuditLogs);
|
||||||
|
|
||||||
|
const result = await controller.getAuditTrail(
|
||||||
|
'',
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'desc',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockAuditLogs);
|
||||||
|
expect(mockAuditService.getAuditTrails).toHaveBeenCalledWith(
|
||||||
|
'',
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'desc',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass search parameter', async () => {
|
||||||
|
mockAuditService.getAuditTrails.mockResolvedValue(mockAuditLogs);
|
||||||
|
|
||||||
|
await controller.getAuditTrail('REKAM', 1, 10, '', '', '', 'desc');
|
||||||
|
|
||||||
|
expect(mockAuditService.getAuditTrails).toHaveBeenCalledWith(
|
||||||
|
'REKAM',
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'desc',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass type filter parameter', async () => {
|
||||||
|
mockAuditService.getAuditTrails.mockResolvedValue(mockAuditLogs);
|
||||||
|
|
||||||
|
await controller.getAuditTrail('', 1, 10, 'rekam_medis', '', '', 'desc');
|
||||||
|
|
||||||
|
expect(mockAuditService.getAuditTrails).toHaveBeenCalledWith(
|
||||||
|
'',
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
'rekam_medis',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'desc',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass tampered filter parameter', async () => {
|
||||||
|
mockAuditService.getAuditTrails.mockResolvedValue(mockAuditLogs);
|
||||||
|
|
||||||
|
await controller.getAuditTrail('', 1, 10, '', 'tampered', '', 'desc');
|
||||||
|
|
||||||
|
expect(mockAuditService.getAuditTrails).toHaveBeenCalledWith(
|
||||||
|
'',
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
'',
|
||||||
|
'tampered',
|
||||||
|
'',
|
||||||
|
'desc',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass orderBy and order parameters', async () => {
|
||||||
|
mockAuditService.getAuditTrails.mockResolvedValue(mockAuditLogs);
|
||||||
|
|
||||||
|
await controller.getAuditTrail('', 1, 10, '', '', 'last_sync', 'desc');
|
||||||
|
|
||||||
|
expect(mockAuditService.getAuditTrails).toHaveBeenCalledWith(
|
||||||
|
'',
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'last_sync',
|
||||||
|
'desc',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass all parameters together', async () => {
|
||||||
|
mockAuditService.getAuditTrails.mockResolvedValue(mockAuditLogs);
|
||||||
|
|
||||||
|
await controller.getAuditTrail(
|
||||||
|
'search',
|
||||||
|
2,
|
||||||
|
25,
|
||||||
|
'obat',
|
||||||
|
'non_tampered',
|
||||||
|
'timestamp',
|
||||||
|
'asc',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockAuditService.getAuditTrails).toHaveBeenCalledWith(
|
||||||
|
'search',
|
||||||
|
2,
|
||||||
|
25,
|
||||||
|
'obat',
|
||||||
|
'non_tampered',
|
||||||
|
'timestamp',
|
||||||
|
'asc',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty results', async () => {
|
||||||
|
mockAuditService.getAuditTrails.mockResolvedValue({ totalCount: 0 });
|
||||||
|
|
||||||
|
const result = await controller.getAuditTrail(
|
||||||
|
'',
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'desc',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ totalCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should propagate service errors', async () => {
|
||||||
|
mockAuditService.getAuditTrails.mockRejectedValue(
|
||||||
|
new Error('Database error'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.getAuditTrail('', 1, 10, '', '', '', 'desc'),
|
||||||
|
).rejects.toThrow('Database error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createAuditTrail', () => {
|
||||||
|
it('should start audit trail process and return status', () => {
|
||||||
|
mockAuditService.storeAuditTrail.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = controller.createAuditTrail();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
message: 'Proses audit trail dijalankan',
|
||||||
|
status: 'STARTED',
|
||||||
|
});
|
||||||
|
expect(mockAuditService.storeAuditTrail).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not wait for storeAuditTrail to complete', () => {
|
||||||
|
// storeAuditTrail is fire-and-forget (not awaited)
|
||||||
|
let resolved = false;
|
||||||
|
mockAuditService.storeAuditTrail.mockImplementation(async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
resolved = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = controller.createAuditTrail();
|
||||||
|
|
||||||
|
expect(result.status).toBe('STARTED');
|
||||||
|
expect(resolved).toBe(false); // Should return before async completes
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call storeAuditTrail without parameters', () => {
|
||||||
|
controller.createAuditTrail();
|
||||||
|
|
||||||
|
expect(mockAuditService.storeAuditTrail).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ export class AuditController {
|
||||||
@Query('pageSize') pageSize: number,
|
@Query('pageSize') pageSize: number,
|
||||||
@Query('type') type: string,
|
@Query('type') type: string,
|
||||||
@Query('tampered') tampered: string,
|
@Query('tampered') tampered: string,
|
||||||
|
@Query('orderBy') orderBy: string,
|
||||||
|
@Query('order') order: 'asc' | 'desc',
|
||||||
) {
|
) {
|
||||||
const result = await this.auditService.getAuditTrails(
|
const result = await this.auditService.getAuditTrails(
|
||||||
search,
|
search,
|
||||||
|
|
@ -29,6 +31,8 @@ export class AuditController {
|
||||||
pageSize,
|
pageSize,
|
||||||
type,
|
type,
|
||||||
tampered,
|
tampered,
|
||||||
|
orderBy,
|
||||||
|
order,
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,198 @@
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { AuditGateway } from './audit.gateway';
|
import { AuditGateway } from './audit.gateway';
|
||||||
|
import { Server } from 'socket.io';
|
||||||
|
import { WebsocketGuard } from '../auth/guard/websocket.guard';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
describe('AuditGateway', () => {
|
describe('AuditGateway', () => {
|
||||||
let gateway: AuditGateway;
|
let gateway: AuditGateway;
|
||||||
|
let mockServer: jest.Mocked<Server>;
|
||||||
|
|
||||||
|
const mockJwtService = {
|
||||||
|
verifyAsync: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [AuditGateway],
|
providers: [
|
||||||
|
AuditGateway,
|
||||||
|
WebsocketGuard,
|
||||||
|
{ provide: JwtService, useValue: mockJwtService },
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
gateway = module.get<AuditGateway>(AuditGateway);
|
gateway = module.get<AuditGateway>(AuditGateway);
|
||||||
|
|
||||||
|
// Mock the WebSocket server
|
||||||
|
mockServer = {
|
||||||
|
emit: jest.fn(),
|
||||||
|
} as unknown as jest.Mocked<Server>;
|
||||||
|
|
||||||
|
// Inject mock server
|
||||||
|
gateway.server = mockServer;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(gateway).toBeDefined();
|
expect(gateway).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('sendProgress', () => {
|
||||||
|
it('should emit audit.progress event with progress data', () => {
|
||||||
|
const progressData = { status: 'RUNNING', progress_count: 50 };
|
||||||
|
|
||||||
|
gateway.sendProgress(progressData);
|
||||||
|
|
||||||
|
expect(mockServer.emit).toHaveBeenCalledWith(
|
||||||
|
'audit.progress',
|
||||||
|
progressData,
|
||||||
|
);
|
||||||
|
expect(mockServer.emit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit progress with zero count', () => {
|
||||||
|
const progressData = { status: 'RUNNING', progress_count: 0 };
|
||||||
|
|
||||||
|
gateway.sendProgress(progressData);
|
||||||
|
|
||||||
|
expect(mockServer.emit).toHaveBeenCalledWith(
|
||||||
|
'audit.progress',
|
||||||
|
progressData,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit progress with large count', () => {
|
||||||
|
const progressData = { status: 'RUNNING', progress_count: 10000 };
|
||||||
|
|
||||||
|
gateway.sendProgress(progressData);
|
||||||
|
|
||||||
|
expect(mockServer.emit).toHaveBeenCalledWith(
|
||||||
|
'audit.progress',
|
||||||
|
progressData,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendComplete', () => {
|
||||||
|
it('should emit audit.complete event with complete data', () => {
|
||||||
|
const completeData = { status: 'COMPLETED' };
|
||||||
|
|
||||||
|
gateway.sendComplete(completeData);
|
||||||
|
|
||||||
|
expect(mockServer.emit).toHaveBeenCalledWith(
|
||||||
|
'audit.complete',
|
||||||
|
completeData,
|
||||||
|
);
|
||||||
|
expect(mockServer.emit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit complete with additional metadata', () => {
|
||||||
|
const completeData = {
|
||||||
|
status: 'COMPLETED',
|
||||||
|
total_processed: 100,
|
||||||
|
duration_ms: 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
gateway.sendComplete(completeData);
|
||||||
|
|
||||||
|
expect(mockServer.emit).toHaveBeenCalledWith(
|
||||||
|
'audit.complete',
|
||||||
|
completeData,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendError', () => {
|
||||||
|
it('should emit audit.error event with error data', () => {
|
||||||
|
const errorData = {
|
||||||
|
message: 'Database connection failed',
|
||||||
|
code: 'DB_ERROR',
|
||||||
|
};
|
||||||
|
|
||||||
|
gateway.sendError(errorData);
|
||||||
|
|
||||||
|
expect(mockServer.emit).toHaveBeenCalledWith('audit.error', errorData);
|
||||||
|
expect(mockServer.emit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit error with stack trace', () => {
|
||||||
|
const errorData = {
|
||||||
|
message: 'Unexpected error',
|
||||||
|
stack: 'Error: Unexpected error\n at AuditService...',
|
||||||
|
};
|
||||||
|
|
||||||
|
gateway.sendError(errorData);
|
||||||
|
|
||||||
|
expect(mockServer.emit).toHaveBeenCalledWith('audit.error', errorData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleConnection', () => {
|
||||||
|
it('should log client connection', () => {
|
||||||
|
const mockClient = { id: 'test-client-123' } as any;
|
||||||
|
const loggerSpy = jest.spyOn(gateway['logger'], 'log');
|
||||||
|
|
||||||
|
gateway.handleConnection(mockClient);
|
||||||
|
|
||||||
|
expect(loggerSpy).toHaveBeenCalledWith(
|
||||||
|
'Klien terhubung: test-client-123',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleDisconnect', () => {
|
||||||
|
it('should log client disconnection', () => {
|
||||||
|
const mockClient = { id: 'test-client-456' } as any;
|
||||||
|
const loggerSpy = jest.spyOn(gateway['logger'], 'log');
|
||||||
|
|
||||||
|
gateway.handleDisconnect(mockClient);
|
||||||
|
|
||||||
|
expect(loggerSpy).toHaveBeenCalledWith('Klien terputus: test-client-456');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('multiple emissions', () => {
|
||||||
|
it('should handle multiple progress emissions', () => {
|
||||||
|
gateway.sendProgress({ status: 'RUNNING', progress_count: 10 });
|
||||||
|
gateway.sendProgress({ status: 'RUNNING', progress_count: 20 });
|
||||||
|
gateway.sendProgress({ status: 'RUNNING', progress_count: 30 });
|
||||||
|
|
||||||
|
expect(mockServer.emit).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle progress followed by complete', () => {
|
||||||
|
gateway.sendProgress({ status: 'RUNNING', progress_count: 100 });
|
||||||
|
gateway.sendComplete({ status: 'COMPLETED' });
|
||||||
|
|
||||||
|
expect(mockServer.emit).toHaveBeenNthCalledWith(1, 'audit.progress', {
|
||||||
|
status: 'RUNNING',
|
||||||
|
progress_count: 100,
|
||||||
|
});
|
||||||
|
expect(mockServer.emit).toHaveBeenNthCalledWith(2, 'audit.complete', {
|
||||||
|
status: 'COMPLETED',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle progress followed by error', () => {
|
||||||
|
gateway.sendProgress({ status: 'RUNNING', progress_count: 50 });
|
||||||
|
gateway.sendError({ message: 'Process failed' });
|
||||||
|
|
||||||
|
expect(mockServer.emit).toHaveBeenNthCalledWith(1, 'audit.progress', {
|
||||||
|
status: 'RUNNING',
|
||||||
|
progress_count: 50,
|
||||||
|
});
|
||||||
|
expect(mockServer.emit).toHaveBeenNthCalledWith(2, 'audit.error', {
|
||||||
|
message: 'Process failed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,653 @@
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { AuditService } from './audit.service';
|
import { AuditService } from './audit.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { LogService } from '../log/log.service';
|
||||||
|
import { ObatService } from '../obat/obat.service';
|
||||||
|
import { RekammedisService } from '../rekammedis/rekammedis.service';
|
||||||
|
import { TindakanDokterService } from '../tindakandokter/tindakandokter.service';
|
||||||
|
import { AuditGateway } from './audit.gateway';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
describe('AuditService', () => {
|
describe('AuditService', () => {
|
||||||
let service: AuditService;
|
let service: AuditService;
|
||||||
|
let prisma: jest.Mocked<PrismaService>;
|
||||||
|
let logService: jest.Mocked<LogService>;
|
||||||
|
let obatService: jest.Mocked<ObatService>;
|
||||||
|
let rekamMedisService: jest.Mocked<RekammedisService>;
|
||||||
|
let tindakanService: jest.Mocked<TindakanDokterService>;
|
||||||
|
let auditGateway: jest.Mocked<AuditGateway>;
|
||||||
|
|
||||||
|
const mockPrisma = {
|
||||||
|
audit: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
|
upsert: jest.fn(),
|
||||||
|
},
|
||||||
|
$transaction: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockLogService = {
|
||||||
|
getLogsWithPagination: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockObatService = {
|
||||||
|
getObatById: jest.fn(),
|
||||||
|
createHashingPayload: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRekamMedisService = {
|
||||||
|
getRekamMedisById: jest.fn(),
|
||||||
|
createHashingPayload: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTindakanService = {
|
||||||
|
getTindakanDokterById: jest.fn(),
|
||||||
|
createHashingPayload: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAuditGateway = {
|
||||||
|
sendProgress: jest.fn(),
|
||||||
|
sendComplete: jest.fn(),
|
||||||
|
sendError: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
// Suppress logger output during tests
|
||||||
|
jest.spyOn(Logger.prototype, 'debug').mockImplementation();
|
||||||
|
jest.spyOn(Logger.prototype, 'error').mockImplementation();
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [AuditService],
|
providers: [
|
||||||
|
AuditService,
|
||||||
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
{ provide: LogService, useValue: mockLogService },
|
||||||
|
{ provide: ObatService, useValue: mockObatService },
|
||||||
|
{ provide: RekammedisService, useValue: mockRekamMedisService },
|
||||||
|
{ provide: TindakanDokterService, useValue: mockTindakanService },
|
||||||
|
{ provide: AuditGateway, useValue: mockAuditGateway },
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<AuditService>(AuditService);
|
service = module.get<AuditService>(AuditService);
|
||||||
|
prisma = module.get(PrismaService);
|
||||||
|
logService = module.get(LogService);
|
||||||
|
obatService = module.get(ObatService);
|
||||||
|
rekamMedisService = module.get(RekammedisService);
|
||||||
|
tindakanService = module.get(TindakanDokterService);
|
||||||
|
auditGateway = module.get(AuditGateway);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getAuditTrails', () => {
|
||||||
|
const mockAuditLogs = [
|
||||||
|
{ id: 'REKAM_1', event: 'rekam_medis_created', result: 'non_tampered' },
|
||||||
|
{ id: 'OBAT_1', event: 'obat_created', result: 'tampered' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('should return paginated audit logs', async () => {
|
||||||
|
mockPrisma.audit.findMany.mockResolvedValue(mockAuditLogs);
|
||||||
|
mockPrisma.audit.count.mockResolvedValue(2);
|
||||||
|
|
||||||
|
const result = await service.getAuditTrails('', 1, 10);
|
||||||
|
|
||||||
|
expect(result.totalCount).toBe(2);
|
||||||
|
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith({
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
orderBy: { timestamp: 'desc' },
|
||||||
|
where: {
|
||||||
|
id: undefined,
|
||||||
|
result: undefined,
|
||||||
|
OR: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by rekam_medis type', async () => {
|
||||||
|
mockPrisma.audit.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.audit.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
await service.getAuditTrails('', 1, 10, 'rekam_medis');
|
||||||
|
|
||||||
|
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
id: { startsWith: 'REKAM' },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by tindakan type', async () => {
|
||||||
|
mockPrisma.audit.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.audit.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
await service.getAuditTrails('', 1, 10, 'tindakan');
|
||||||
|
|
||||||
|
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
id: { startsWith: 'TINDAKAN' },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by obat type', async () => {
|
||||||
|
mockPrisma.audit.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.audit.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
await service.getAuditTrails('', 1, 10, 'obat');
|
||||||
|
|
||||||
|
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
id: { startsWith: 'OBAT' },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by tampered status', async () => {
|
||||||
|
mockPrisma.audit.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.audit.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
await service.getAuditTrails('', 1, 10, undefined, 'tampered');
|
||||||
|
|
||||||
|
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
result: 'tampered',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by non_tampered status', async () => {
|
||||||
|
mockPrisma.audit.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.audit.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
await service.getAuditTrails('', 1, 10, undefined, 'non_tampered');
|
||||||
|
|
||||||
|
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
result: 'non_tampered',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore "all" type filter', async () => {
|
||||||
|
mockPrisma.audit.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.audit.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
await service.getAuditTrails('', 1, 10, 'all');
|
||||||
|
|
||||||
|
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
id: undefined,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore "initial" type filter', async () => {
|
||||||
|
mockPrisma.audit.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.audit.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
await service.getAuditTrails('', 1, 10, 'initial');
|
||||||
|
|
||||||
|
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
id: undefined,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should search by id', async () => {
|
||||||
|
mockPrisma.audit.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.audit.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
await service.getAuditTrails('REKAM_123', 1, 10);
|
||||||
|
|
||||||
|
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
OR: [{ id: { contains: 'REKAM_123' } }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply custom orderBy and order', async () => {
|
||||||
|
mockPrisma.audit.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.audit.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
await service.getAuditTrails(
|
||||||
|
'',
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
'last_sync',
|
||||||
|
'asc',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
orderBy: { last_sync: 'asc' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate correct skip for pagination', async () => {
|
||||||
|
mockPrisma.audit.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.audit.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
await service.getAuditTrails('', 3, 10);
|
||||||
|
|
||||||
|
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
skip: 20, // (page - 1) * pageSize = (3 - 1) * 10
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCountAuditTamperedData', () => {
|
||||||
|
it('should return all tampered counts', async () => {
|
||||||
|
mockPrisma.audit.count
|
||||||
|
.mockResolvedValueOnce(10) // auditTamperedCount
|
||||||
|
.mockResolvedValueOnce(90) // auditNonTamperedCount
|
||||||
|
.mockResolvedValueOnce(3) // rekamMedisTamperedCount
|
||||||
|
.mockResolvedValueOnce(4) // tindakanDokterTamperedCount
|
||||||
|
.mockResolvedValueOnce(3); // obatTamperedCount
|
||||||
|
|
||||||
|
const result = await service.getCountAuditTamperedData();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
auditTamperedCount: 10,
|
||||||
|
auditNonTamperedCount: 90,
|
||||||
|
rekamMedisTamperedCount: 3,
|
||||||
|
tindakanDokterTamperedCount: 4,
|
||||||
|
obatTamperedCount: 3,
|
||||||
|
});
|
||||||
|
expect(mockPrisma.audit.count).toHaveBeenCalledTimes(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('compareData', () => {
|
||||||
|
it('should return true when hashes match', async () => {
|
||||||
|
const hash = 'abc123def456';
|
||||||
|
const result = await service.compareData(hash, hash);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when hashes differ', async () => {
|
||||||
|
const result = await service.compareData('hash1', 'hash2');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for empty strings comparison with non-empty', async () => {
|
||||||
|
const result = await service.compareData('', 'somehash');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('storeAuditTrail', () => {
|
||||||
|
it('should process logs and send complete when done', async () => {
|
||||||
|
mockLogService.getLogsWithPagination.mockResolvedValue({
|
||||||
|
logs: [],
|
||||||
|
bookmark: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.storeAuditTrail();
|
||||||
|
|
||||||
|
expect(mockLogService.getLogsWithPagination).toHaveBeenCalledWith(25, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should process rekam_medis logs correctly', async () => {
|
||||||
|
const mockLog = {
|
||||||
|
value: {
|
||||||
|
id: 'REKAM_123',
|
||||||
|
event: 'rekam_medis_created',
|
||||||
|
timestamp: '2024-01-01T00:00:00Z',
|
||||||
|
user_id: 1,
|
||||||
|
payload: 'blockchain_hash_123',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRekamMedis = {
|
||||||
|
id_visit: '123',
|
||||||
|
anamnese: 'test',
|
||||||
|
jenis_kasus: 'test',
|
||||||
|
tindak_lanjut: 'test',
|
||||||
|
deleted_status: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||||
|
logs: [mockLog],
|
||||||
|
bookmark: '',
|
||||||
|
});
|
||||||
|
mockRekamMedisService.getRekamMedisById.mockResolvedValue(mockRekamMedis);
|
||||||
|
mockRekamMedisService.createHashingPayload.mockReturnValue(
|
||||||
|
'blockchain_hash_123',
|
||||||
|
);
|
||||||
|
mockPrisma.$transaction.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.storeAuditTrail();
|
||||||
|
|
||||||
|
expect(mockRekamMedisService.getRekamMedisById).toHaveBeenCalledWith(
|
||||||
|
'123',
|
||||||
|
);
|
||||||
|
expect(mockAuditGateway.sendProgress).toHaveBeenCalled();
|
||||||
|
expect(mockAuditGateway.sendComplete).toHaveBeenCalledWith({
|
||||||
|
status: 'COMPLETED',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should process obat logs correctly', async () => {
|
||||||
|
const mockLog = {
|
||||||
|
value: {
|
||||||
|
id: 'OBAT_456',
|
||||||
|
event: 'obat_created',
|
||||||
|
timestamp: '2024-01-01T00:00:00Z',
|
||||||
|
user_id: 1,
|
||||||
|
payload: 'obat_hash',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockObat = {
|
||||||
|
id: 456,
|
||||||
|
obat: 'Paracetamol',
|
||||||
|
jumlah_obat: 10,
|
||||||
|
aturan_pakai: '3x1',
|
||||||
|
deleted_status: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||||
|
logs: [mockLog],
|
||||||
|
bookmark: '',
|
||||||
|
});
|
||||||
|
mockObatService.getObatById.mockResolvedValue(mockObat);
|
||||||
|
mockObatService.createHashingPayload.mockReturnValue('obat_hash');
|
||||||
|
mockPrisma.$transaction.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.storeAuditTrail();
|
||||||
|
|
||||||
|
expect(mockObatService.getObatById).toHaveBeenCalledWith(456);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should process tindakan logs correctly', async () => {
|
||||||
|
const mockLog = {
|
||||||
|
value: {
|
||||||
|
id: 'TINDAKAN_789',
|
||||||
|
event: 'tindakan_dokter_created',
|
||||||
|
timestamp: '2024-01-01T00:00:00Z',
|
||||||
|
user_id: 1,
|
||||||
|
payload: 'tindakan_hash',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTindakan = {
|
||||||
|
id: 789,
|
||||||
|
id_visit: '123',
|
||||||
|
tindakan: 'Pemeriksaan',
|
||||||
|
kategori_tindakan: 'Umum',
|
||||||
|
kelompok_tindakan: 'Poliklinik',
|
||||||
|
deleted_status: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||||
|
logs: [mockLog],
|
||||||
|
bookmark: '',
|
||||||
|
});
|
||||||
|
mockTindakanService.getTindakanDokterById.mockResolvedValue(mockTindakan);
|
||||||
|
mockTindakanService.createHashingPayload.mockReturnValue('tindakan_hash');
|
||||||
|
mockPrisma.$transaction.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.storeAuditTrail();
|
||||||
|
|
||||||
|
expect(mockTindakanService.getTindakanDokterById).toHaveBeenCalledWith(
|
||||||
|
789,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle pagination with bookmark', async () => {
|
||||||
|
mockLogService.getLogsWithPagination
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
logs: [{ value: null }],
|
||||||
|
bookmark: 'next_page',
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ logs: [], bookmark: '' });
|
||||||
|
|
||||||
|
await service.storeAuditTrail();
|
||||||
|
|
||||||
|
expect(mockLogService.getLogsWithPagination).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockLogService.getLogsWithPagination).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
25,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
expect(mockLogService.getLogsWithPagination).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
25,
|
||||||
|
'next_page',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when blockchain service fails', async () => {
|
||||||
|
mockLogService.getLogsWithPagination.mockRejectedValue(
|
||||||
|
new Error('Blockchain error'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.storeAuditTrail()).rejects.toThrow(
|
||||||
|
'Failed to store audit trail',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify error was sent via WebSocket
|
||||||
|
expect(mockAuditGateway.sendError).toHaveBeenCalledWith({
|
||||||
|
status: 'ERROR',
|
||||||
|
message: 'Blockchain error',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tamper detection logic', () => {
|
||||||
|
it('should mark as non_tampered when delete event and no DB row', async () => {
|
||||||
|
const mockLog = {
|
||||||
|
value: {
|
||||||
|
id: 'REKAM_999',
|
||||||
|
event: 'rekam_medis_deleted',
|
||||||
|
timestamp: '2024-01-01T00:00:00Z',
|
||||||
|
user_id: 1,
|
||||||
|
payload: 'hash',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||||
|
logs: [mockLog],
|
||||||
|
bookmark: '',
|
||||||
|
});
|
||||||
|
mockRekamMedisService.getRekamMedisById.mockResolvedValue(null);
|
||||||
|
mockPrisma.$transaction.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.storeAuditTrail();
|
||||||
|
|
||||||
|
// When delete event and no DB row, should be non_tampered
|
||||||
|
expect(mockPrisma.$transaction).toHaveBeenCalled();
|
||||||
|
const transactionCall = mockPrisma.$transaction.mock.calls[0][0];
|
||||||
|
// Transaction is called with array of upsert promises
|
||||||
|
expect(transactionCall).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark as tampered when no DB row and not delete event', async () => {
|
||||||
|
const mockLog = {
|
||||||
|
value: {
|
||||||
|
id: 'REKAM_999',
|
||||||
|
event: 'rekam_medis_created',
|
||||||
|
timestamp: '2024-01-01T00:00:00Z',
|
||||||
|
user_id: 1,
|
||||||
|
payload: 'hash',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||||
|
logs: [mockLog],
|
||||||
|
bookmark: '',
|
||||||
|
});
|
||||||
|
mockRekamMedisService.getRekamMedisById.mockResolvedValue(null);
|
||||||
|
mockPrisma.$transaction.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.storeAuditTrail();
|
||||||
|
|
||||||
|
// Transaction should be called with tampered result
|
||||||
|
expect(mockPrisma.$transaction).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark as non_tampered when delete event and deleted_status is DELETED', async () => {
|
||||||
|
const mockLog = {
|
||||||
|
value: {
|
||||||
|
id: 'REKAM_123',
|
||||||
|
event: 'rekam_medis_deleted',
|
||||||
|
timestamp: '2024-01-01T00:00:00Z',
|
||||||
|
user_id: 1,
|
||||||
|
payload: 'hash',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRekamMedis = {
|
||||||
|
id_visit: '123',
|
||||||
|
deleted_status: 'DELETED',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||||
|
logs: [mockLog],
|
||||||
|
bookmark: '',
|
||||||
|
});
|
||||||
|
mockRekamMedisService.getRekamMedisById.mockResolvedValue(mockRekamMedis);
|
||||||
|
mockPrisma.$transaction.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.storeAuditTrail();
|
||||||
|
|
||||||
|
expect(mockPrisma.$transaction).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark as tampered when hashes do not match', async () => {
|
||||||
|
const mockLog = {
|
||||||
|
value: {
|
||||||
|
id: 'REKAM_123',
|
||||||
|
event: 'rekam_medis_created',
|
||||||
|
timestamp: '2024-01-01T00:00:00Z',
|
||||||
|
user_id: 1,
|
||||||
|
payload: 'blockchain_hash',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRekamMedis = {
|
||||||
|
id_visit: '123',
|
||||||
|
anamnese: 'modified',
|
||||||
|
jenis_kasus: 'test',
|
||||||
|
tindak_lanjut: 'test',
|
||||||
|
deleted_status: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||||
|
logs: [mockLog],
|
||||||
|
bookmark: '',
|
||||||
|
});
|
||||||
|
mockRekamMedisService.getRekamMedisById.mockResolvedValue(mockRekamMedis);
|
||||||
|
mockRekamMedisService.createHashingPayload.mockReturnValue(
|
||||||
|
'different_hash',
|
||||||
|
);
|
||||||
|
mockPrisma.$transaction.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.storeAuditTrail();
|
||||||
|
|
||||||
|
expect(mockPrisma.$transaction).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should skip log entries without value', async () => {
|
||||||
|
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||||
|
logs: [{ noValue: true }],
|
||||||
|
bookmark: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.storeAuditTrail();
|
||||||
|
|
||||||
|
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
expect(mockAuditGateway.sendComplete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip log entries without id', async () => {
|
||||||
|
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||||
|
logs: [{ value: { event: 'test' } }],
|
||||||
|
bookmark: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.storeAuditTrail();
|
||||||
|
|
||||||
|
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip log entries without payload', async () => {
|
||||||
|
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||||
|
logs: [{ value: { id: 'REKAM_1', event: 'test' } }],
|
||||||
|
bookmark: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.storeAuditTrail();
|
||||||
|
|
||||||
|
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip log entries with unknown prefix', async () => {
|
||||||
|
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||||
|
logs: [{ value: { id: 'UNKNOWN_1', event: 'test', payload: 'hash' } }],
|
||||||
|
bookmark: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.storeAuditTrail();
|
||||||
|
|
||||||
|
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle invalid timestamp gracefully', async () => {
|
||||||
|
const mockLog = {
|
||||||
|
value: {
|
||||||
|
id: 'REKAM_123',
|
||||||
|
event: 'rekam_medis_created',
|
||||||
|
timestamp: 'invalid-date',
|
||||||
|
user_id: 1,
|
||||||
|
payload: 'hash',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||||
|
logs: [mockLog],
|
||||||
|
bookmark: '',
|
||||||
|
});
|
||||||
|
mockRekamMedisService.getRekamMedisById.mockResolvedValue({
|
||||||
|
id_visit: '123',
|
||||||
|
deleted_status: null,
|
||||||
|
});
|
||||||
|
mockRekamMedisService.createHashingPayload.mockReturnValue('hash');
|
||||||
|
mockPrisma.$transaction.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.storeAuditTrail();
|
||||||
|
|
||||||
|
expect(mockPrisma.$transaction).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
InternalServerErrorException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { LogService } from '../log/log.service';
|
import { LogService } from '../log/log.service';
|
||||||
import { ObatService } from '../obat/obat.service';
|
import { ObatService } from '../obat/obat.service';
|
||||||
|
|
@ -39,7 +43,13 @@ export class AuditService {
|
||||||
pageSize: number,
|
pageSize: number,
|
||||||
type?: string,
|
type?: string,
|
||||||
tampered?: string,
|
tampered?: string,
|
||||||
|
orderBy?: string,
|
||||||
|
order?: 'asc' | 'desc',
|
||||||
) {
|
) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Fetching audit trails: page=${page}, pageSize=${pageSize}, type=${type}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (type === 'all' || type === 'initial') {
|
if (type === 'all' || type === 'initial') {
|
||||||
type = undefined;
|
type = undefined;
|
||||||
} else if (type === 'rekam_medis') {
|
} else if (type === 'rekam_medis') {
|
||||||
|
|
@ -54,29 +64,36 @@ export class AuditService {
|
||||||
tampered = undefined;
|
tampered = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const auditLogs = await this.prisma.audit.findMany({
|
try {
|
||||||
take: pageSize,
|
const auditLogs = await this.prisma.audit.findMany({
|
||||||
skip: (page - 1) * pageSize,
|
take: pageSize,
|
||||||
orderBy: { timestamp: 'asc' },
|
skip: (page - 1) * pageSize,
|
||||||
where: {
|
orderBy: orderBy
|
||||||
id: type && type !== 'all' ? { startsWith: type } : undefined,
|
? { [orderBy]: order || 'asc' }
|
||||||
result: tampered ? (tampered as ResultStatus) : undefined,
|
: { timestamp: 'desc' },
|
||||||
OR: search ? [{ id: { contains: search } }] : undefined,
|
where: {
|
||||||
},
|
id: type && type !== 'all' ? { startsWith: type } : undefined,
|
||||||
});
|
result: tampered ? (tampered as ResultStatus) : undefined,
|
||||||
|
OR: search ? [{ id: { contains: search } }] : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const count = await this.prisma.audit.count({
|
const count = await this.prisma.audit.count({
|
||||||
where: {
|
where: {
|
||||||
id: type && type !== 'all' ? { startsWith: type } : undefined,
|
id: type && type !== 'all' ? { startsWith: type } : undefined,
|
||||||
result: tampered ? (tampered as ResultStatus) : undefined,
|
result: tampered ? (tampered as ResultStatus) : undefined,
|
||||||
OR: search ? [{ id: { contains: search } }] : undefined,
|
OR: search ? [{ id: { contains: search } }] : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...auditLogs,
|
...auditLogs,
|
||||||
totalCount: count,
|
totalCount: count,
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to fetch audit trails', error.stack);
|
||||||
|
throw new InternalServerErrorException('Failed to fetch audit trails');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async storeAuditTrail() {
|
async storeAuditTrail() {
|
||||||
|
|
@ -113,7 +130,7 @@ export class AuditService {
|
||||||
).filter((record): record is AuditRecordPayload => record !== null);
|
).filter((record): record is AuditRecordPayload => record !== null);
|
||||||
|
|
||||||
if (records.length > 0) {
|
if (records.length > 0) {
|
||||||
console.log(records);
|
this.logger.debug(`Processing ${records.length} audit records`);
|
||||||
await this.prisma.$transaction(
|
await this.prisma.$transaction(
|
||||||
records.map((record) =>
|
records.map((record) =>
|
||||||
this.prisma.audit.upsert({
|
this.prisma.audit.upsert({
|
||||||
|
|
@ -142,57 +159,52 @@ export class AuditService {
|
||||||
bookmark = nextBookmark;
|
bookmark = nextBookmark;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error storing audit trail:', error);
|
this.logger.error('Error storing audit trail', error.stack);
|
||||||
throw error;
|
this.auditGateway.sendError({
|
||||||
|
status: 'ERROR',
|
||||||
|
message: error.message || 'Failed to store audit trail',
|
||||||
|
});
|
||||||
|
throw new InternalServerErrorException('Failed to store audit trail');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCountAuditTamperedData() {
|
async getCountAuditTamperedData() {
|
||||||
const auditTamperedCount = await this.prisma.audit.count({
|
try {
|
||||||
where: {
|
const [
|
||||||
result: 'tampered',
|
auditTamperedCount,
|
||||||
},
|
auditNonTamperedCount,
|
||||||
});
|
rekamMedisTamperedCount,
|
||||||
|
tindakanDokterTamperedCount,
|
||||||
|
obatTamperedCount,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.prisma.audit.count({
|
||||||
|
where: { result: 'tampered' },
|
||||||
|
}),
|
||||||
|
this.prisma.audit.count({
|
||||||
|
where: { result: 'non_tampered' },
|
||||||
|
}),
|
||||||
|
this.prisma.audit.count({
|
||||||
|
where: { result: 'tampered', id: { startsWith: 'REKAM' } },
|
||||||
|
}),
|
||||||
|
this.prisma.audit.count({
|
||||||
|
where: { result: 'tampered', id: { startsWith: 'TINDAKAN' } },
|
||||||
|
}),
|
||||||
|
this.prisma.audit.count({
|
||||||
|
where: { result: 'tampered', id: { startsWith: 'OBAT' } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const auditNonTamperedCount = await this.prisma.audit.count({
|
return {
|
||||||
where: {
|
auditTamperedCount,
|
||||||
result: 'non_tampered',
|
auditNonTamperedCount,
|
||||||
},
|
rekamMedisTamperedCount,
|
||||||
});
|
tindakanDokterTamperedCount,
|
||||||
|
obatTamperedCount,
|
||||||
const rekamMedisTamperedCount = await this.prisma.audit.count({
|
};
|
||||||
where: {
|
} catch (error) {
|
||||||
result: 'tampered',
|
this.logger.error('Failed to get audit tampered count', error.stack);
|
||||||
id: {
|
throw new InternalServerErrorException('Failed to get audit statistics');
|
||||||
startsWith: 'REKAM',
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const tindakanDokterTamperedCount = await this.prisma.audit.count({
|
|
||||||
where: {
|
|
||||||
result: 'tampered',
|
|
||||||
id: {
|
|
||||||
startsWith: 'TINDAKAN',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const obatTamperedCount = await this.prisma.audit.count({
|
|
||||||
where: {
|
|
||||||
result: 'tampered',
|
|
||||||
id: {
|
|
||||||
startsWith: 'OBAT',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
auditTamperedCount,
|
|
||||||
auditNonTamperedCount,
|
|
||||||
rekamMedisTamperedCount,
|
|
||||||
tindakanDokterTamperedCount,
|
|
||||||
obatTamperedCount,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildAuditRecord(
|
private async buildAuditRecord(
|
||||||
|
|
@ -270,7 +282,9 @@ export class AuditService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`Failed to resolve related data for log ${logId}:`, err);
|
this.logger.warn(
|
||||||
|
`Failed to resolve related data for log ${logId}: ${err.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let isNotTampered = false;
|
let isNotTampered = false;
|
||||||
|
|
@ -295,7 +309,6 @@ export class AuditService {
|
||||||
progress_count: index ?? 0,
|
progress_count: index ?? 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// this.logger.log('Mengirim progres via WebSocket:', progressData);
|
|
||||||
this.auditGateway.sendProgress(progressData);
|
this.auditGateway.sendProgress(progressData);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ interface AuditLogEntry extends BlockchainLog {
|
||||||
tamperedLabel: string;
|
tamperedLabel: string;
|
||||||
last_sync: string;
|
last_sync: string;
|
||||||
isTampered: boolean;
|
isTampered: boolean;
|
||||||
|
timestamp: string;
|
||||||
txId?: string;
|
txId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,10 @@ export const SORT_OPTIONS = {
|
||||||
created_at: "Waktu Dibuat",
|
created_at: "Waktu Dibuat",
|
||||||
processed_at: "Waktu Diproses",
|
processed_at: "Waktu Diproses",
|
||||||
},
|
},
|
||||||
|
AUDIT_TRAIL: {
|
||||||
|
last_sync: "Last Sync",
|
||||||
|
timestamp: "Timestamp",
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const REKAM_MEDIS_TABLE_COLUMNS = [
|
export const REKAM_MEDIS_TABLE_COLUMNS = [
|
||||||
|
|
@ -302,6 +306,7 @@ export const AUDIT_TABLE_COLUMNS = [
|
||||||
{ key: "last_sync", label: "Last Sync", class: "text-dark" },
|
{ key: "last_sync", label: "Last Sync", class: "text-dark" },
|
||||||
{ key: "userId", label: "User ID", class: "text-dark" },
|
{ key: "userId", label: "User ID", class: "text-dark" },
|
||||||
{ key: "status", label: "Status Data", class: "text-dark" },
|
{ key: "status", label: "Status Data", class: "text-dark" },
|
||||||
|
{ key: "timestamp", label: "Timestamp", class: "text-dark" },
|
||||||
] satisfies Array<{
|
] satisfies Array<{
|
||||||
key: keyof AuditLogEntry;
|
key: keyof AuditLogEntry;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
DEBOUNCE_DELAY,
|
DEBOUNCE_DELAY,
|
||||||
ITEMS_PER_PAGE_OPTIONS,
|
ITEMS_PER_PAGE_OPTIONS,
|
||||||
AUDIT_TABLE_COLUMNS,
|
AUDIT_TABLE_COLUMNS,
|
||||||
|
SORT_OPTIONS,
|
||||||
} from "../../../constants/pagination";
|
} from "../../../constants/pagination";
|
||||||
import ButtonDark from "../../../components/dashboard/ButtonDark.vue";
|
import ButtonDark from "../../../components/dashboard/ButtonDark.vue";
|
||||||
import DialogConfirm from "../../../components/DialogConfirm.vue";
|
import DialogConfirm from "../../../components/DialogConfirm.vue";
|
||||||
|
|
@ -23,6 +24,7 @@ import type {
|
||||||
AuditLogType,
|
AuditLogType,
|
||||||
} from "../../../constants/interfaces";
|
} from "../../../constants/interfaces";
|
||||||
import { io, Socket } from "socket.io-client";
|
import { io, Socket } from "socket.io-client";
|
||||||
|
import SortDropdown from "../../../components/dashboard/SortDropdown.vue";
|
||||||
|
|
||||||
interface AuditLogResponse {
|
interface AuditLogResponse {
|
||||||
data: AuditLogEntry[];
|
data: AuditLogEntry[];
|
||||||
|
|
@ -32,6 +34,10 @@ interface AuditLogResponse {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
const sortBy = ref("last_sync");
|
||||||
|
const sortOrder = ref<"asc" | "desc">(
|
||||||
|
(route.query.order as "asc" | "desc") || "desc"
|
||||||
|
);
|
||||||
const { debounce } = useDebounce();
|
const { debounce } = useDebounce();
|
||||||
|
|
||||||
const pagination = usePagination({
|
const pagination = usePagination({
|
||||||
|
|
@ -47,6 +53,17 @@ const filters = ref({
|
||||||
tampered: (route.query.tampered as string) || "initial",
|
tampered: (route.query.tampered as string) || "initial",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleSortChange = (newSortBy: string) => {
|
||||||
|
sortBy.value = newSortBy;
|
||||||
|
pagination.reset();
|
||||||
|
fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSortOrder = () => {
|
||||||
|
console.log("Toggling sort order from", sortOrder.value);
|
||||||
|
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
|
||||||
|
};
|
||||||
|
|
||||||
const formatTimestamp = (rawValue?: string) => {
|
const formatTimestamp = (rawValue?: string) => {
|
||||||
if (!rawValue) {
|
if (!rawValue) {
|
||||||
return "-";
|
return "-";
|
||||||
|
|
@ -184,10 +201,16 @@ const updateQueryParams = () => {
|
||||||
query.search = searchId.value;
|
query.search = searchId.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sortBy.value !== "initial") {
|
||||||
|
query.sortBy = sortBy.value;
|
||||||
|
}
|
||||||
|
|
||||||
if (filters.value.type !== "all") {
|
if (filters.value.type !== "all") {
|
||||||
query.type = filters.value.type;
|
query.type = filters.value.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query.order = sortOrder.value;
|
||||||
|
|
||||||
if (filters.value.tampered !== "all") {
|
if (filters.value.tampered !== "all") {
|
||||||
query.tampered = filters.value.tampered;
|
query.tampered = filters.value.tampered;
|
||||||
}
|
}
|
||||||
|
|
@ -200,6 +223,8 @@ const fetchData = async () => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: pagination.page.value.toString(),
|
page: pagination.page.value.toString(),
|
||||||
pageSize: pageSize.value.toString(),
|
pageSize: pageSize.value.toString(),
|
||||||
|
orderBy: sortBy.value,
|
||||||
|
order: sortOrder.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (searchId.value) {
|
if (searchId.value) {
|
||||||
|
|
@ -265,6 +290,11 @@ const handleResetFilters = () => {
|
||||||
fetchData();
|
fetchData();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
watch(sortOrder, () => {
|
||||||
|
pagination.reset();
|
||||||
|
fetchData();
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => pagination.page.value,
|
() => pagination.page.value,
|
||||||
() => {
|
() => {
|
||||||
|
|
@ -415,11 +445,40 @@ onBeforeUnmount(() => {
|
||||||
<div
|
<div
|
||||||
class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 px-4 pt-4 pb-2"
|
class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 px-4 pt-4 pb-2"
|
||||||
>
|
>
|
||||||
<SearchInput
|
<div class="flex flex-col w-full gap-2 md:flex-row md:items-center">
|
||||||
v-model="searchId"
|
<SearchInput
|
||||||
placeholder="Cari berdasarkan ID Log"
|
v-model="searchId"
|
||||||
@search="handleSearch"
|
placeholder="Cari berdasarkan ID Log"
|
||||||
/>
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-2 md:ml-4">
|
||||||
|
<SortDropdown
|
||||||
|
v-model="sortBy"
|
||||||
|
:options="SORT_OPTIONS.AUDIT_TRAIL"
|
||||||
|
label="Urut berdasarkan:"
|
||||||
|
@change="handleSortChange"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm bg-dark text-light hover:bg-light hover:text-dark active:inset-shadow-sm active:inset-shadow-black/50"
|
||||||
|
@click="toggleSortOrder"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
class="inline-block size-4"
|
||||||
|
>
|
||||||
|
<path d="M7 7l3 -3l3 3"></path>
|
||||||
|
<path d="M10 4v16"></path>
|
||||||
|
<path d="M17 17l-3 3l-3 -3"></path>
|
||||||
|
<path d="M14 20v-16"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="ml-2 uppercase">{{ sortOrder }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<DialogConfirm
|
<DialogConfirm
|
||||||
ref="auditDialog"
|
ref="auditDialog"
|
||||||
title="Konfirmasi"
|
title="Konfirmasi"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user