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:
yosaphatprs 2025-12-01 15:18:22 +07:00
parent fda1d5d92a
commit 74d5da7475
8 changed files with 1191 additions and 79 deletions

View File

@ -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();
});
});
}); });

View File

@ -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;
} }

View File

@ -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',
});
});
});
}); });

View File

@ -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();
});
});
}); });

View File

@ -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 {

View File

@ -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;
} }

View File

@ -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;

View File

@ -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"