diff --git a/backend/api/src/modules/audit/audit.controller.spec.ts b/backend/api/src/modules/audit/audit.controller.spec.ts index e3548b4..861ce03 100644 --- a/backend/api/src/modules/audit/audit.controller.spec.ts +++ b/backend/api/src/modules/audit/audit.controller.spec.ts @@ -1,18 +1,233 @@ import { Test, TestingModule } from '@nestjs/testing'; 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', () => { let controller: AuditController; + let auditService: jest.Mocked; + + const mockAuditService = { + getAuditTrails: jest.fn(), + storeAuditTrail: jest.fn(), + getCountAuditTamperedData: jest.fn(), + }; + + const mockJwtService = { + verifyAsync: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn(), + }; beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ 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); + auditService = module.get(AuditService); }); it('should be defined', () => { 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(); + }); + }); }); diff --git a/backend/api/src/modules/audit/audit.controller.ts b/backend/api/src/modules/audit/audit.controller.ts index a722be2..53977e4 100644 --- a/backend/api/src/modules/audit/audit.controller.ts +++ b/backend/api/src/modules/audit/audit.controller.ts @@ -22,6 +22,8 @@ export class AuditController { @Query('pageSize') pageSize: number, @Query('type') type: string, @Query('tampered') tampered: string, + @Query('orderBy') orderBy: string, + @Query('order') order: 'asc' | 'desc', ) { const result = await this.auditService.getAuditTrails( search, @@ -29,6 +31,8 @@ export class AuditController { pageSize, type, tampered, + orderBy, + order, ); return result; } diff --git a/backend/api/src/modules/audit/audit.gateway.spec.ts b/backend/api/src/modules/audit/audit.gateway.spec.ts index ae20edf..32b69ee 100644 --- a/backend/api/src/modules/audit/audit.gateway.spec.ts +++ b/backend/api/src/modules/audit/audit.gateway.spec.ts @@ -1,18 +1,198 @@ import { Test, TestingModule } from '@nestjs/testing'; 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', () => { let gateway: AuditGateway; + let mockServer: jest.Mocked; + + const mockJwtService = { + verifyAsync: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [AuditGateway], + providers: [ + AuditGateway, + WebsocketGuard, + { provide: JwtService, useValue: mockJwtService }, + { provide: ConfigService, useValue: mockConfigService }, + ], }).compile(); gateway = module.get(AuditGateway); + + // Mock the WebSocket server + mockServer = { + emit: jest.fn(), + } as unknown as jest.Mocked; + + // Inject mock server + gateway.server = mockServer; + }); + + afterEach(() => { + jest.clearAllMocks(); }); it('should be defined', () => { 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', + }); + }); + }); }); diff --git a/backend/api/src/modules/audit/audit.service.spec.ts b/backend/api/src/modules/audit/audit.service.spec.ts index fcd4965..0ed2e3a 100644 --- a/backend/api/src/modules/audit/audit.service.spec.ts +++ b/backend/api/src/modules/audit/audit.service.spec.ts @@ -1,18 +1,653 @@ import { Test, TestingModule } from '@nestjs/testing'; 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', () => { let service: AuditService; + let prisma: jest.Mocked; + let logService: jest.Mocked; + let obatService: jest.Mocked; + let rekamMedisService: jest.Mocked; + let tindakanService: jest.Mocked; + let auditGateway: jest.Mocked; + + 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 () => { + 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({ - 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(); service = module.get(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', () => { 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(); + }); + }); }); diff --git a/backend/api/src/modules/audit/audit.service.ts b/backend/api/src/modules/audit/audit.service.ts index 21aba93..3947117 100644 --- a/backend/api/src/modules/audit/audit.service.ts +++ b/backend/api/src/modules/audit/audit.service.ts @@ -1,4 +1,8 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { + Injectable, + Logger, + InternalServerErrorException, +} from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { LogService } from '../log/log.service'; import { ObatService } from '../obat/obat.service'; @@ -39,7 +43,13 @@ export class AuditService { pageSize: number, type?: 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') { type = undefined; } else if (type === 'rekam_medis') { @@ -54,29 +64,36 @@ export class AuditService { tampered = undefined; } - const auditLogs = await this.prisma.audit.findMany({ - take: pageSize, - skip: (page - 1) * pageSize, - orderBy: { timestamp: 'asc' }, - where: { - id: type && type !== 'all' ? { startsWith: type } : undefined, - result: tampered ? (tampered as ResultStatus) : undefined, - OR: search ? [{ id: { contains: search } }] : undefined, - }, - }); + try { + const auditLogs = await this.prisma.audit.findMany({ + take: pageSize, + skip: (page - 1) * pageSize, + orderBy: orderBy + ? { [orderBy]: order || 'asc' } + : { timestamp: 'desc' }, + 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({ - 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({ + where: { + id: type && type !== 'all' ? { startsWith: type } : undefined, + result: tampered ? (tampered as ResultStatus) : undefined, + OR: search ? [{ id: { contains: search } }] : undefined, + }, + }); - return { - ...auditLogs, - totalCount: count, - }; + return { + ...auditLogs, + totalCount: count, + }; + } catch (error) { + this.logger.error('Failed to fetch audit trails', error.stack); + throw new InternalServerErrorException('Failed to fetch audit trails'); + } } async storeAuditTrail() { @@ -113,7 +130,7 @@ export class AuditService { ).filter((record): record is AuditRecordPayload => record !== null); if (records.length > 0) { - console.log(records); + this.logger.debug(`Processing ${records.length} audit records`); await this.prisma.$transaction( records.map((record) => this.prisma.audit.upsert({ @@ -142,57 +159,52 @@ export class AuditService { bookmark = nextBookmark; } } catch (error) { - console.error('Error storing audit trail:', error); - throw error; + this.logger.error('Error storing audit trail', error.stack); + this.auditGateway.sendError({ + status: 'ERROR', + message: error.message || 'Failed to store audit trail', + }); + throw new InternalServerErrorException('Failed to store audit trail'); } } async getCountAuditTamperedData() { - const auditTamperedCount = await this.prisma.audit.count({ - where: { - result: 'tampered', - }, - }); + try { + const [ + 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({ - where: { - result: 'non_tampered', - }, - }); - - const rekamMedisTamperedCount = await this.prisma.audit.count({ - where: { - result: 'tampered', - id: { - 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, - }; + return { + auditTamperedCount, + auditNonTamperedCount, + rekamMedisTamperedCount, + tindakanDokterTamperedCount, + obatTamperedCount, + }; + } catch (error) { + this.logger.error('Failed to get audit tampered count', error.stack); + throw new InternalServerErrorException('Failed to get audit statistics'); + } } private async buildAuditRecord( @@ -270,7 +282,9 @@ export class AuditService { return null; } } 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; @@ -295,7 +309,6 @@ export class AuditService { progress_count: index ?? 0, }; - // this.logger.log('Mengirim progres via WebSocket:', progressData); this.auditGateway.sendProgress(progressData); return { diff --git a/frontend/hospital-log/src/constants/interfaces.ts b/frontend/hospital-log/src/constants/interfaces.ts index 4d78282..5a6cbb2 100644 --- a/frontend/hospital-log/src/constants/interfaces.ts +++ b/frontend/hospital-log/src/constants/interfaces.ts @@ -78,6 +78,7 @@ interface AuditLogEntry extends BlockchainLog { tamperedLabel: string; last_sync: string; isTampered: boolean; + timestamp: string; txId?: string; } diff --git a/frontend/hospital-log/src/constants/pagination.ts b/frontend/hospital-log/src/constants/pagination.ts index 8f906d4..be2de6a 100644 --- a/frontend/hospital-log/src/constants/pagination.ts +++ b/frontend/hospital-log/src/constants/pagination.ts @@ -102,6 +102,10 @@ export const SORT_OPTIONS = { created_at: "Waktu Dibuat", processed_at: "Waktu Diproses", }, + AUDIT_TRAIL: { + last_sync: "Last Sync", + timestamp: "Timestamp", + }, } as const; 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: "userId", label: "User ID", class: "text-dark" }, { key: "status", label: "Status Data", class: "text-dark" }, + { key: "timestamp", label: "Timestamp", class: "text-dark" }, ] satisfies Array<{ key: keyof AuditLogEntry; label: string; diff --git a/frontend/hospital-log/src/views/dashboard/audit-trail/AuditTrailView.vue b/frontend/hospital-log/src/views/dashboard/audit-trail/AuditTrailView.vue index e00edeb..5075629 100644 --- a/frontend/hospital-log/src/views/dashboard/audit-trail/AuditTrailView.vue +++ b/frontend/hospital-log/src/views/dashboard/audit-trail/AuditTrailView.vue @@ -14,6 +14,7 @@ import { DEBOUNCE_DELAY, ITEMS_PER_PAGE_OPTIONS, AUDIT_TABLE_COLUMNS, + SORT_OPTIONS, } from "../../../constants/pagination"; import ButtonDark from "../../../components/dashboard/ButtonDark.vue"; import DialogConfirm from "../../../components/DialogConfirm.vue"; @@ -23,6 +24,7 @@ import type { AuditLogType, } from "../../../constants/interfaces"; import { io, Socket } from "socket.io-client"; +import SortDropdown from "../../../components/dashboard/SortDropdown.vue"; interface AuditLogResponse { data: AuditLogEntry[]; @@ -32,6 +34,10 @@ interface AuditLogResponse { const router = useRouter(); const route = useRoute(); const api = useApi(); +const sortBy = ref("last_sync"); +const sortOrder = ref<"asc" | "desc">( + (route.query.order as "asc" | "desc") || "desc" +); const { debounce } = useDebounce(); const pagination = usePagination({ @@ -47,6 +53,17 @@ const filters = ref({ 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) => { if (!rawValue) { return "-"; @@ -184,10 +201,16 @@ const updateQueryParams = () => { query.search = searchId.value; } + if (sortBy.value !== "initial") { + query.sortBy = sortBy.value; + } + if (filters.value.type !== "all") { query.type = filters.value.type; } + query.order = sortOrder.value; + if (filters.value.tampered !== "all") { query.tampered = filters.value.tampered; } @@ -200,6 +223,8 @@ const fetchData = async () => { const params = new URLSearchParams({ page: pagination.page.value.toString(), pageSize: pageSize.value.toString(), + orderBy: sortBy.value, + order: sortOrder.value, }); if (searchId.value) { @@ -265,6 +290,11 @@ const handleResetFilters = () => { fetchData(); }; +watch(sortOrder, () => { + pagination.reset(); + fetchData(); +}); + watch( () => pagination.page.value, () => { @@ -415,11 +445,40 @@ onBeforeUnmount(() => {
- +
+ +
+ + +
+