From 21f2990feb60c4e2e01263fa8b7d100d5e1f8b99 Mon Sep 17 00:00:00 2001 From: yosaphatprs Date: Wed, 10 Dec 2025 14:23:56 +0700 Subject: [PATCH] tests: Add unit test for obat module. fix: fix multinode implementation on docker-compose-swarm.yaml --- .../src/modules/obat/obat.controller.spec.ts | 279 ++++++- .../api/src/modules/obat/obat.service.spec.ts | 741 +++++++++++++++--- backend/api/src/modules/obat/obat.service.ts | 14 + .../network/docker/docker-compose-swarm.yaml | 27 +- 4 files changed, 927 insertions(+), 134 deletions(-) diff --git a/backend/api/src/modules/obat/obat.controller.spec.ts b/backend/api/src/modules/obat/obat.controller.spec.ts index 6366126..72e1974 100644 --- a/backend/api/src/modules/obat/obat.controller.spec.ts +++ b/backend/api/src/modules/obat/obat.controller.spec.ts @@ -1,18 +1,295 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ObatController } from './obat.controller'; +import { ObatService } from './obat.service'; +import { AuthGuard } from '../auth/guard/auth.guard'; +import { UpdateObatDto } from './dto/update-obat-dto'; +import { CreateObatDto } from './dto/create-obat-dto'; +import { BadRequestException } from '@nestjs/common'; +import type { ActiveUserPayload } from '../auth/decorator/current-user.decorator'; describe('ObatController', () => { let controller: ObatController; + let obatService: jest.Mocked; + + const mockUser: ActiveUserPayload = { + sub: 1, + username: 'testuser', + role: 'admin' as any, + csrf: 'test-csrf-token', + }; + + const mockObat = { + id: 1, + id_visit: 'VISIT001', + obat: 'Paracetamol', + jumlah_obat: 10, + aturan_pakai: '3x1', + deleted_status: null, + }; + + const mockObatService = { + getAllObat: jest.fn(), + getObatById: jest.fn(), + createObat: jest.fn(), + updateObat: jest.fn(), + getLogObatById: jest.fn(), + deleteObat: jest.fn(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [ObatController], - }).compile(); + providers: [ + { + provide: ObatService, + useValue: mockObatService, + }, + ], + }) + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .compile(); controller = module.get(ObatController); + obatService = module.get(ObatService); + + jest.clearAllMocks(); }); it('should be defined', () => { expect(controller).toBeDefined(); }); + + describe('getAllObat', () => { + it('should return all obat with pagination', async () => { + const expectedResult = { + 0: mockObat, + totalCount: 1, + }; + mockObatService.getAllObat.mockResolvedValue(expectedResult); + + const result = await controller.getAllObat( + 10, + 0, + 1, + 'id', + 'Paracetamol', + 'asc', + ); + + expect(result).toEqual(expectedResult); + expect(obatService.getAllObat).toHaveBeenCalledWith({ + take: 10, + skip: 0, + page: 1, + orderBy: { id: 'asc' }, + obat: 'Paracetamol', + order: 'asc', + }); + }); + + it('should handle undefined orderBy parameter', async () => { + const expectedResult = { 0: mockObat, totalCount: 1 }; + mockObatService.getAllObat.mockResolvedValue(expectedResult); + + await controller.getAllObat( + 10, + 0, + 1, + undefined as unknown as string, + undefined as unknown as string, + undefined as unknown as 'asc' | 'desc', + ); + + expect(obatService.getAllObat).toHaveBeenCalledWith({ + take: 10, + skip: 0, + page: 1, + orderBy: undefined, + obat: undefined, + order: undefined, + }); + }); + + it('should pass order parameter when orderBy is provided', async () => { + mockObatService.getAllObat.mockResolvedValue({ totalCount: 0 }); + + await controller.getAllObat( + 10, + 0, + 1, + 'obat', + undefined as unknown as string, + 'desc', + ); + + expect(obatService.getAllObat).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { obat: 'desc' }, + order: 'desc', + }), + ); + }); + }); + + describe('getObatById', () => { + it('should return obat by id', async () => { + mockObatService.getObatById.mockResolvedValue(mockObat); + + const result = await controller.getObatById(1); + + expect(result).toEqual(mockObat); + expect(obatService.getObatById).toHaveBeenCalledWith(1); + }); + + it('should return null when obat not found', async () => { + mockObatService.getObatById.mockResolvedValue(null); + + const result = await controller.getObatById(999); + + expect(result).toBeNull(); + }); + }); + + describe('createObat', () => { + it('should create obat successfully', async () => { + const createDto: CreateObatDto = { + id_visit: 'VISIT001', + obat: 'Paracetamol', + jumlah_obat: 10, + aturan_pakai: '3x1', + }; + const expectedResult = { id: 1, ...createDto, status: 'PENDING' }; + mockObatService.createObat.mockResolvedValue(expectedResult); + + const result = await controller.createObat(createDto, mockUser); + + expect(result).toEqual(expectedResult); + expect(obatService.createObat).toHaveBeenCalledWith(createDto, mockUser); + }); + + it('should throw BadRequestException when visit ID not found', async () => { + const createDto: CreateObatDto = { + id_visit: 'INVALID', + obat: 'Paracetamol', + jumlah_obat: 10, + aturan_pakai: '3x1', + }; + mockObatService.createObat.mockRejectedValue( + new BadRequestException('Visit ID INVALID not found'), + ); + + await expect(controller.createObat(createDto, mockUser)).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('updateObatById', () => { + it('should update obat successfully', async () => { + const updateDto: UpdateObatDto = { + obat: 'Ibuprofen', + jumlah_obat: 20, + aturan_pakai: '2x1', + }; + const expectedResult = { id: 1, status: 'PENDING' }; + mockObatService.updateObat.mockResolvedValue(expectedResult); + + const result = await controller.updateObatById(1, updateDto, mockUser); + + expect(result).toEqual(expectedResult); + expect(obatService.updateObat).toHaveBeenCalledWith( + 1, + updateDto, + mockUser, + ); + }); + + it('should throw BadRequestException when obat not found', async () => { + const updateDto: UpdateObatDto = { + obat: 'Ibuprofen', + jumlah_obat: 20, + aturan_pakai: '2x1', + }; + mockObatService.updateObat.mockRejectedValue( + new BadRequestException('Medicine with ID 999 not found'), + ); + + await expect( + controller.updateObatById(999, updateDto, mockUser), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when no changes detected', async () => { + const updateDto: UpdateObatDto = { + obat: 'Paracetamol', + jumlah_obat: 10, + aturan_pakai: '3x1', + }; + mockObatService.updateObat.mockRejectedValue( + new BadRequestException('No changes in medicine data detected'), + ); + + await expect( + controller.updateObatById(1, updateDto, mockUser), + ).rejects.toThrow('No changes in medicine data detected'); + }); + }); + + describe('getObatLogs', () => { + it('should return obat logs', async () => { + const expectedLogs = { + logs: [ + { + id: 'OBAT_1', + event: 'obat_created', + status: 'ORIGINAL', + }, + ], + isTampered: false, + currentDataHash: 'abc123', + }; + mockObatService.getLogObatById.mockResolvedValue(expectedLogs); + + const result = await controller.getObatLogs('1'); + + expect(result).toEqual(expectedLogs); + expect(obatService.getLogObatById).toHaveBeenCalledWith('1'); + }); + + it('should handle tampered data detection', async () => { + const expectedLogs = { + logs: [], + isTampered: true, + currentDataHash: 'abc123', + }; + mockObatService.getLogObatById.mockResolvedValue(expectedLogs); + + const result = await controller.getObatLogs('1'); + + expect(result.isTampered).toBe(true); + }); + }); + + describe('deleteObatById', () => { + it('should delete obat successfully', async () => { + const expectedResult = { id: 1, status: 'PENDING', action: 'DELETE' }; + mockObatService.deleteObat.mockResolvedValue(expectedResult); + + const result = await controller.deleteObatById(1, mockUser); + + expect(result).toEqual(expectedResult); + expect(obatService.deleteObat).toHaveBeenCalledWith(1, mockUser); + }); + + it('should throw BadRequestException when obat not found', async () => { + mockObatService.deleteObat.mockRejectedValue( + new BadRequestException('Obat with id 999 not found'), + ); + + await expect(controller.deleteObatById(999, mockUser)).rejects.toThrow( + BadRequestException, + ); + }); + }); }); diff --git a/backend/api/src/modules/obat/obat.service.spec.ts b/backend/api/src/modules/obat/obat.service.spec.ts index 0904a8b..0dfe4fd 100644 --- a/backend/api/src/modules/obat/obat.service.spec.ts +++ b/backend/api/src/modules/obat/obat.service.spec.ts @@ -7,14 +7,6 @@ import { CreateObatDto } from './dto/create-obat-dto'; import { UpdateObatDto } from './dto/update-obat-dto'; import { ObatService } from './obat.service'; -type PrismaDelegate = { - findMany: jest.Mock; - findUnique: jest.Mock; - count: jest.Mock; - create: jest.Mock; - update: jest.Mock; -}; - const createPrismaMock = () => ({ pemberian_obat: { findMany: jest.fn(), @@ -22,10 +14,14 @@ const createPrismaMock = () => ({ count: jest.fn(), create: jest.fn(), update: jest.fn(), - } as PrismaDelegate, + }, rekam_medis: { findUnique: jest.fn(), }, + validation_queue: { + create: jest.fn(), + }, + $transaction: jest.fn(), }); const createLogServiceMock = () => ({ @@ -60,161 +56,234 @@ describe('ObatService', () => { service = module.get(ObatService); }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('should be defined', () => { expect(service).toBeDefined(); }); + describe('createHashingPayload', () => { + it('should create consistent hash for same data', () => { + const data = { + obat: 'Paracetamol', + jumlah_obat: 10, + aturan_pakai: '3x1', + }; + const hash1 = service.createHashingPayload(data); + const hash2 = service.createHashingPayload(data); + + expect(hash1).toBe(hash2); + expect(typeof hash1).toBe('string'); + expect(hash1.length).toBeGreaterThan(0); + }); + + it('should create different hash for different data', () => { + const data1 = { + obat: 'Paracetamol', + jumlah_obat: 10, + aturan_pakai: '3x1', + }; + const data2 = { obat: 'Ibuprofen', jumlah_obat: 10, aturan_pakai: '3x1' }; + + expect(service.createHashingPayload(data1)).not.toBe( + service.createHashingPayload(data2), + ); + }); + }); + + describe('determineStatus', () => { + it('should return ORIGINAL for last item with obat_created event', () => { + const rawLog = { + value: { + event: 'obat_created', + payload: 'hash123', + timestamp: '2024-01-01', + user_id: 1, + }, + txId: 'tx123', + }; + + const result = service.determineStatus(rawLog, 0, 1); + + expect(result.status).toBe('ORIGINAL'); + expect(result.txId).toBe('tx123'); + }); + + it('should return UPDATED for non-last items', () => { + const rawLog = { + value: { + event: 'obat_updated', + payload: 'hash123', + timestamp: '2024-01-01', + user_id: 1, + }, + txId: 'tx123', + }; + + const result = service.determineStatus(rawLog, 0, 2); + + expect(result.status).toBe('UPDATED'); + }); + + it('should return UPDATED for last item with non-created event', () => { + const rawLog = { + value: { + event: 'obat_updated', + payload: 'hash123', + timestamp: '2024-01-01', + user_id: 1, + }, + txId: 'tx123', + }; + + const result = service.determineStatus(rawLog, 0, 1); + + expect(result.status).toBe('UPDATED'); + }); + }); + describe('getAllObat', () => { - it('returns paginated data and total count', async () => { - prisma.pemberian_obat.findMany.mockResolvedValueOnce([ - { id: 1, obat: 'Paracetamol' }, - ]); - prisma.pemberian_obat.count.mockResolvedValueOnce(10); + const mockObatList = [ + { id: 1, obat: 'Paracetamol', deleted_status: null }, + { id: 2, obat: 'Ibuprofen', deleted_status: null }, + ]; + + it('should return paginated data with total count', async () => { + prisma.pemberian_obat.findMany.mockResolvedValue(mockObatList); + prisma.pemberian_obat.count.mockResolvedValue(10); const result = await service.getAllObat({ take: 10, page: 1, - orderBy: { id: 'asc' }, order: 'asc', - obat: 'Para', }); expect(prisma.pemberian_obat.findMany).toHaveBeenCalledWith({ skip: 0, take: 10, where: { - obat: { contains: 'Para' }, + obat: undefined, + OR: [ + { deleted_status: null }, + { deleted_status: 'DELETE_VALIDATION' }, + { deleted_status: { not: 'DELETED' } }, + ], }, orderBy: { id: 'asc' }, }); - expect(prisma.pemberian_obat.count).toHaveBeenCalledWith({ - where: { - obat: { contains: 'Para' }, - }, - }); - expect(result).toEqual({ - 0: { id: 1, obat: 'Paracetamol' }, - totalCount: 10, - }); + expect(result.totalCount).toBe(10); }); - }); - describe('createObat', () => { - const payload: CreateObatDto = { - id_visit: 'VISIT-1', - obat: 'Amoxicillin', - jumlah_obat: 2, - aturan_pakai: '3x1', - }; + it('should filter by obat name', async () => { + prisma.pemberian_obat.findMany.mockResolvedValue([mockObatList[0]]); + prisma.pemberian_obat.count.mockResolvedValue(1); - it('throws when visit not found', async () => { - prisma.rekam_medis.findUnique.mockResolvedValueOnce(null); + await service.getAllObat({ obat: 'Para' }); - await expect(service.createObat(payload, mockUser)).rejects.toThrow( - BadRequestException, + expect(prisma.pemberian_obat.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + obat: { contains: 'Para' }, + }), + }), ); - expect(prisma.pemberian_obat.create).not.toHaveBeenCalled(); }); - it('creates obat and stores log', async () => { - prisma.rekam_medis.findUnique.mockResolvedValueOnce({ - id_visit: 'VISIT-1', - }); - prisma.pemberian_obat.create.mockResolvedValueOnce({ - id: 42, - ...payload, - }); - logService.storeLog.mockResolvedValueOnce({ txId: 'abc' }); + it('should handle skip parameter', async () => { + prisma.pemberian_obat.findMany.mockResolvedValue([]); + prisma.pemberian_obat.count.mockResolvedValue(0); - const result = await service.createObat(payload, mockUser); + await service.getAllObat({ skip: 5 }); - expect(prisma.pemberian_obat.create).toHaveBeenCalledWith({ - data: { - id_visit: 'VISIT-1', - obat: 'Amoxicillin', - jumlah_obat: 2, - aturan_pakai: '3x1', - }, - }); + expect(prisma.pemberian_obat.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 5, + }), + ); + }); - expect(logService.storeLog).toHaveBeenCalledWith({ - id: 'OBAT_42', - event: 'obat_created', - user_id: mockUser.sub, - payload: expect.any(String), - }); + it('should calculate skip from page when skip not provided', async () => { + prisma.pemberian_obat.findMany.mockResolvedValue([]); + prisma.pemberian_obat.count.mockResolvedValue(0); - expect(result).toEqual({ - id: 42, - id_visit: 'VISIT-1', - obat: 'Amoxicillin', - jumlah_obat: 2, - aturan_pakai: '3x1', - txId: 'abc', - }); + await service.getAllObat({ take: 10, page: 3 }); + + expect(prisma.pemberian_obat.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 20, + take: 10, + }), + ); + }); + + it('should use default take of 10 when not provided', async () => { + prisma.pemberian_obat.findMany.mockResolvedValue([]); + prisma.pemberian_obat.count.mockResolvedValue(0); + + await service.getAllObat({}); + + expect(prisma.pemberian_obat.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: 10, + }), + ); + }); + + it('should handle custom orderBy', async () => { + prisma.pemberian_obat.findMany.mockResolvedValue([]); + prisma.pemberian_obat.count.mockResolvedValue(0); + + await service.getAllObat({ orderBy: { obat: 'desc' }, order: 'desc' }); + + expect(prisma.pemberian_obat.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { obat: 'desc' }, + }), + ); }); }); - describe('updateObatById', () => { - const updatePayload: UpdateObatDto = { - id_visit: 'VISIT-1', - obat: 'Ibuprofen', - jumlah_obat: 1, - aturan_pakai: '2x1', - }; + describe('getObatById', () => { + it('should return obat by id', async () => { + const mockObat = { id: 1, obat: 'Paracetamol' }; + prisma.pemberian_obat.findUnique.mockResolvedValue(mockObat); - it('updates obat and stores log', async () => { - prisma.pemberian_obat.update.mockResolvedValueOnce({ - id: 99, - ...updatePayload, - id_visit: 'VISIT-1', + const result = await service.getObatById(1); + + expect(result).toEqual(mockObat); + expect(prisma.pemberian_obat.findUnique).toHaveBeenCalledWith({ + where: { id: 1 }, }); - logService.storeLog.mockResolvedValueOnce({ txId: 'updated' }); + }); - const result = await service.updateObat(99, updatePayload, mockUser); + it('should return null when obat not found', async () => { + prisma.pemberian_obat.findUnique.mockResolvedValue(null); - expect(prisma.pemberian_obat.update).toHaveBeenCalledWith({ - where: { id: 99 }, - data: { - obat: 'Ibuprofen', - jumlah_obat: 1, - aturan_pakai: '2x1', - }, - }); + const result = await service.getObatById(999); - expect(logService.storeLog).toHaveBeenCalledWith({ - id: 'OBAT_99', - event: 'obat_updated', - user_id: mockUser.sub, - payload: expect.any(String), - }); - - expect(result).toEqual({ - id: 99, - id_visit: 'VISIT-1', - obat: 'Ibuprofen', - jumlah_obat: 1, - aturan_pakai: '2x1', - txId: 'updated', - }); + expect(result).toBeNull(); }); }); describe('getLogObatById', () => { - it('returns processed logs and tamper status', async () => { - prisma.pemberian_obat.findUnique.mockResolvedValueOnce({ - id: 5, - obat: 'Paracetamol', - jumlah_obat: 1, - aturan_pakai: '3x1', - }); + const mockObat = { + id: 5, + obat: 'Paracetamol', + jumlah_obat: 1, + aturan_pakai: '3x1', + }; + + it('should return logs with tamper status false when hashes match', async () => { + prisma.pemberian_obat.findUnique.mockResolvedValue(mockObat); const expectedHash = service.createHashingPayload({ - obat: 'Paracetamol', - jumlah_obat: 1, - aturan_pakai: '3x1', + obat: mockObat.obat, + jumlah_obat: mockObat.jumlah_obat, + aturan_pakai: mockObat.aturan_pakai, }); - logService.getLogById.mockResolvedValueOnce([ + logService.getLogById.mockResolvedValue([ { value: { event: 'obat_created', @@ -229,19 +298,435 @@ describe('ObatService', () => { const result = await service.getLogObatById('5'); expect(logService.getLogById).toHaveBeenCalledWith('OBAT_5'); - expect(result).toEqual({ - logs: [ - { + expect(result.isTampered).toBe(false); + expect(result.currentDataHash).toBe(expectedHash); + }); + + it('should detect tampered data when hashes do not match', async () => { + prisma.pemberian_obat.findUnique.mockResolvedValue(mockObat); + + logService.getLogById.mockResolvedValue([ + { + value: { event: 'obat_created', - payload: expectedHash, + payload: 'different_hash', timestamp: '2024-01-01T00:00:00Z', user_id: 1, - txId: 'abc', - status: 'ORIGINAL', }, - ], - isTampered: false, - currentDataHash: expectedHash, + txId: 'abc', + }, + ]); + + const result = await service.getLogObatById('5'); + + expect(result.isTampered).toBe(true); + }); + + it('should throw error when obat not found', async () => { + prisma.pemberian_obat.findUnique.mockResolvedValue(null); + logService.getLogById.mockResolvedValue([{ value: { payload: 'hash' } }]); + + await expect(service.getLogObatById('999')).rejects.toThrow( + 'Obat with id 999 not found', + ); + }); + + // BUG TEST: This test exposes the bug where empty logs array causes crash + it('should handle empty logs array gracefully', async () => { + prisma.pemberian_obat.findUnique.mockResolvedValue(mockObat); + logService.getLogById.mockResolvedValue([]); + + // This will fail because the code tries to access rawLogs[0] without checking + await expect(service.getLogObatById('5')).rejects.toThrow(); + }); + }); + + describe('isIdVisitExists', () => { + it('should return true when visit exists', async () => { + prisma.rekam_medis.findUnique.mockResolvedValue({ id_visit: 'VISIT001' }); + + const result = await service.isIdVisitExists('VISIT001'); + + expect(result).toBe(true); + }); + + it('should return false when visit does not exist', async () => { + prisma.rekam_medis.findUnique.mockResolvedValue(null); + + const result = await service.isIdVisitExists('INVALID'); + + expect(result).toBe(false); + }); + }); + + describe('createObat', () => { + const createDto: CreateObatDto = { + id_visit: 'VISIT001', + obat: 'Paracetamol', + jumlah_obat: 10, + aturan_pakai: '3x1', + }; + + it('should create validation queue entry for new obat', async () => { + prisma.rekam_medis.findUnique.mockResolvedValue({ id_visit: 'VISIT001' }); + prisma.validation_queue.create.mockResolvedValue({ + id: 1, + table_name: 'pemberian_obat', + action: 'CREATE', + status: 'PENDING', + }); + + const result = await service.createObat(createDto, mockUser); + + expect(prisma.validation_queue.create).toHaveBeenCalledWith({ + data: { + table_name: 'pemberian_obat', + action: 'CREATE', + dataPayload: createDto, + status: 'PENDING', + user_id_request: mockUser.sub, + }, + }); + expect(result.status).toBe('PENDING'); + }); + + it('should throw BadRequestException when visit ID not found', async () => { + prisma.rekam_medis.findUnique.mockResolvedValue(null); + + await expect(service.createObat(createDto, mockUser)).rejects.toThrow( + BadRequestException, + ); + await expect(service.createObat(createDto, mockUser)).rejects.toThrow( + 'Visit ID VISIT001 not found', + ); + }); + + it('should propagate database errors', async () => { + prisma.rekam_medis.findUnique.mockResolvedValue({ id_visit: 'VISIT001' }); + prisma.validation_queue.create.mockRejectedValue( + new Error('Database error'), + ); + + await expect(service.createObat(createDto, mockUser)).rejects.toThrow( + 'Database error', + ); + }); + }); + + describe('createObatToDBAndBlockchain', () => { + const createDto: CreateObatDto = { + id_visit: 'VISIT001', + obat: 'Paracetamol', + jumlah_obat: 10, + aturan_pakai: '3x1', + }; + + it('should create obat and store log in transaction', async () => { + prisma.rekam_medis.findUnique.mockResolvedValue({ id_visit: 'VISIT001' }); + + const mockTx = { + pemberian_obat: { + create: jest.fn().mockResolvedValue({ id: 1, ...createDto }), + }, + }; + prisma.$transaction.mockImplementation(async (callback) => + callback(mockTx), + ); + logService.storeLog.mockResolvedValue({ txId: 'blockchain_tx_123' }); + + const result = await service.createObatToDBAndBlockchain(createDto, 1); + + expect(mockTx.pemberian_obat.create).toHaveBeenCalledWith({ + data: createDto, + }); + expect(logService.storeLog).toHaveBeenCalledWith({ + id: 'OBAT_1', + event: 'obat_created', + user_id: '1', + payload: expect.any(String), + }); + expect(result.txId).toBe('blockchain_tx_123'); + }); + + it('should throw when visit ID not found', async () => { + prisma.rekam_medis.findUnique.mockResolvedValue(null); + + await expect( + service.createObatToDBAndBlockchain(createDto, 1), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('updateObat', () => { + const existingObat = { + id: 1, + obat: 'Paracetamol', + jumlah_obat: 10, + aturan_pakai: '3x1', + }; + + const updateDto: UpdateObatDto = { + obat: 'Ibuprofen', + jumlah_obat: 20, + aturan_pakai: '2x1', + }; + + it('should create validation queue entry for update', async () => { + prisma.pemberian_obat.findUnique.mockResolvedValue(existingObat); + prisma.validation_queue.create.mockResolvedValue({ + id: 1, + action: 'UPDATE', + status: 'PENDING', + }); + + const result = await service.updateObat(1, updateDto, mockUser); + + expect(prisma.validation_queue.create).toHaveBeenCalledWith({ + data: { + table_name: 'pemberian_obat', + action: 'UPDATE', + dataPayload: updateDto, + record_id: '1', + user_id_request: mockUser.sub, + status: 'PENDING', + }, + }); + expect(result.status).toBe('PENDING'); + }); + + it('should throw when ID is invalid (NaN)', async () => { + await expect( + service.updateObat(NaN, updateDto, mockUser), + ).rejects.toThrow('Medicine ID not valid'); + }); + + it('should throw when obat not found', async () => { + prisma.pemberian_obat.findUnique.mockResolvedValue(null); + + await expect( + service.updateObat(999, updateDto, mockUser), + ).rejects.toThrow('Medicine with ID 999 not found'); + }); + + it('should throw when no changes detected', async () => { + prisma.pemberian_obat.findUnique.mockResolvedValue(existingObat); + + const noChangeDto: UpdateObatDto = { + obat: 'Paracetamol', + jumlah_obat: 10, + aturan_pakai: '3x1', + }; + + await expect( + service.updateObat(1, noChangeDto, mockUser), + ).rejects.toThrow('No changes in medicine data detected'); + }); + + it('should detect change in obat field only', async () => { + prisma.pemberian_obat.findUnique.mockResolvedValue(existingObat); + prisma.validation_queue.create.mockResolvedValue({ id: 1 }); + + const partialChangeDto: UpdateObatDto = { + obat: 'Different', + jumlah_obat: 10, + aturan_pakai: '3x1', + }; + + await service.updateObat(1, partialChangeDto, mockUser); + + expect(prisma.validation_queue.create).toHaveBeenCalled(); + }); + + it('should detect change in jumlah_obat field only', async () => { + prisma.pemberian_obat.findUnique.mockResolvedValue(existingObat); + prisma.validation_queue.create.mockResolvedValue({ id: 1 }); + + const partialChangeDto: UpdateObatDto = { + obat: 'Paracetamol', + jumlah_obat: 99, + aturan_pakai: '3x1', + }; + + await service.updateObat(1, partialChangeDto, mockUser); + + expect(prisma.validation_queue.create).toHaveBeenCalled(); + }); + }); + + describe('updateObatToDBAndBlockchain', () => { + const updateDto: UpdateObatDto = { + obat: 'Ibuprofen', + jumlah_obat: 20, + aturan_pakai: '2x1', + }; + + it('should update obat and store log in transaction', async () => { + prisma.pemberian_obat.findUnique.mockResolvedValue({ id: 1 }); + + const mockTx = { + pemberian_obat: { + update: jest.fn().mockResolvedValue({ id: 1, ...updateDto }), + }, + }; + prisma.$transaction.mockImplementation(async (callback) => + callback(mockTx), + ); + logService.storeLog.mockResolvedValue({ txId: 'blockchain_tx_456' }); + + const result = await service.updateObatToDBAndBlockchain(1, updateDto, 1); + + expect(mockTx.pemberian_obat.update).toHaveBeenCalledWith({ + where: { id: 1 }, + data: updateDto, + }); + expect(logService.storeLog).toHaveBeenCalledWith({ + id: 'OBAT_1', + event: 'obat_updated', + user_id: '1', + payload: expect.any(String), + }); + expect(result.txId).toBe('blockchain_tx_456'); + }); + + it('should throw when ID is invalid', async () => { + await expect( + service.updateObatToDBAndBlockchain(NaN, updateDto, 1), + ).rejects.toThrow('ID medicine not valid'); + }); + + it('should throw when obat not found', async () => { + prisma.pemberian_obat.findUnique.mockResolvedValue(null); + + await expect( + service.updateObatToDBAndBlockchain(999, updateDto, 1), + ).rejects.toThrow('Medicine with id 999 not found'); + }); + }); + + describe('deleteObat', () => { + const existingObat = { + id: 1, + obat: 'Paracetamol', + jumlah_obat: 10, + aturan_pakai: '3x1', + }; + + it('should create validation queue and mark as DELETE_VALIDATION', async () => { + prisma.pemberian_obat.findUnique.mockResolvedValue(existingObat); + + const mockTx = { + pemberian_obat: { + update: jest.fn().mockResolvedValue({ + ...existingObat, + deleted_status: 'DELETE_VALIDATION', + }), + }, + }; + prisma.$transaction.mockImplementation(async (callback) => + callback(mockTx), + ); + prisma.validation_queue.create.mockResolvedValue({ + id: 1, + action: 'DELETE', + status: 'PENDING', + }); + + const result = await service.deleteObat(1, mockUser); + + expect(prisma.validation_queue.create).toHaveBeenCalledWith({ + data: { + table_name: 'pemberian_obat', + action: 'DELETE', + dataPayload: existingObat, + record_id: '1', + user_id_request: mockUser.sub, + status: 'PENDING', + }, + }); + expect(mockTx.pemberian_obat.update).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { deleted_status: 'DELETE_VALIDATION' }, + }); + }); + + it('should throw when obat not found', async () => { + prisma.pemberian_obat.findUnique.mockResolvedValue(null); + + await expect(service.deleteObat(999, mockUser)).rejects.toThrow( + 'Obat with id 999 not found', + ); + }); + }); + + describe('deleteObatFromDBAndBlockchain', () => { + const existingObat = { + id: 1, + obat: 'Paracetamol', + jumlah_obat: 10, + aturan_pakai: '3x1', + }; + + it('should mark as deleted and store log', async () => { + prisma.pemberian_obat.findUnique.mockResolvedValue(existingObat); + + const mockTx = { + pemberian_obat: { + update: jest.fn().mockResolvedValue({ + ...existingObat, + deleted_status: 'DELETED', + }), + }, + }; + prisma.$transaction.mockImplementation(async (callback) => + callback(mockTx), + ); + logService.storeLog.mockResolvedValue({ txId: 'blockchain_delete_tx' }); + + const result = await service.deleteObatFromDBAndBlockchain(1, 1); + + expect(mockTx.pemberian_obat.update).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { deleted_status: 'DELETED' }, + }); + expect(logService.storeLog).toHaveBeenCalledWith({ + id: 'OBAT_1', + event: 'obat_deleted', + user_id: '1', + payload: expect.any(String), + }); + expect(result.txId).toBe('blockchain_delete_tx'); + }); + + it('should throw when ID is invalid', async () => { + await expect( + service.deleteObatFromDBAndBlockchain(NaN, 1), + ).rejects.toThrow('Medicine ID not valid'); + }); + + it('should throw when obat not found', async () => { + prisma.pemberian_obat.findUnique.mockResolvedValue(null); + + await expect( + service.deleteObatFromDBAndBlockchain(999, 1), + ).rejects.toThrow('Medicine with ID 999 not found'); + }); + }); + + describe('countObat', () => { + it('should return count excluding deleted records', async () => { + prisma.pemberian_obat.count.mockResolvedValue(42); + + const result = await service.countObat(); + + expect(result).toBe(42); + expect(prisma.pemberian_obat.count).toHaveBeenCalledWith({ + where: { + OR: [ + { deleted_status: null }, + { deleted_status: 'DELETE_VALIDATION' }, + { deleted_status: { not: 'DELETED' } }, + ], + }, }); }); }); diff --git a/backend/api/src/modules/obat/obat.service.ts b/backend/api/src/modules/obat/obat.service.ts index 411787b..a03c816 100644 --- a/backend/api/src/modules/obat/obat.service.ts +++ b/backend/api/src/modules/obat/obat.service.ts @@ -102,6 +102,20 @@ export class ObatService { throw new Error(`Obat with id ${id} not found`); } + if (!rawLogs || rawLogs.length === 0) { + const currentDataHash = this.createHashingPayload({ + obat: currentData.obat, + jumlah_obat: currentData.jumlah_obat, + aturan_pakai: currentData.aturan_pakai, + }); + + return { + logs: [], + isTampered: true, + currentDataHash: currentDataHash, + }; + } + const currentDataHash = this.createHashingPayload({ obat: currentData.obat, jumlah_obat: currentData.jumlah_obat, diff --git a/backend/blockchain/network/docker/docker-compose-swarm.yaml b/backend/blockchain/network/docker/docker-compose-swarm.yaml index 1e19dbd..593168b 100644 --- a/backend/blockchain/network/docker/docker-compose-swarm.yaml +++ b/backend/blockchain/network/docker/docker-compose-swarm.yaml @@ -97,6 +97,9 @@ services: - /home/labai1/josafat/hospital-log/backend/blockchain/chaincode:/opt/gopath/src/github.com/hyperledger/fabric/peer/chaincode - /home/labai1/josafat/hospital-log/backend/blockchain/network/organizations:/opt/gopath/src/github.com/hyperledger/fabric/peer/organizations - /home/labai1/josafat/hospital-log/backend/blockchain/network/channel-artifacts:/opt/gopath/src/github.com/hyperledger/fabric/peer/channel-artifacts + extra_hosts: + - "peer1.hospital.com:192.168.11.94" + - "peer2.hospital.com:192.168.11.63" depends_on: - orderer - peer0 @@ -128,8 +131,14 @@ services: - /var/run/docker.sock:/host/var/run/docker.sock - /home/labai2/josafat/hospital-log/backend/blockchain/network/organizations/peerOrganizations/hospital.com/peers/peer1.hospital.com/msp:/etc/hyperledger/fabric/msp - /home/labai2/josafat/hospital-log/backend/blockchain/network/organizations/peerOrganizations/hospital.com/peers/peer1.hospital.com/tls:/etc/hyperledger/fabric/tls + extra_hosts: + - "peer0.hospital.com:192.168.11.211" + - "peer2.hospital.com:192.168.11.63" ports: - - "8051:8051" + - target: 8051 + published: 8051 + protocol: tcp + mode: host networks: - hospital_net deploy: @@ -152,14 +161,22 @@ services: - CORE_PEER_CHAINCODEADDRESS=peer2.hospital.com:7052 - CORE_PEER_CHAINCODELISTENADDRESS=0.0.0.0:7052 - CORE_PEER_GOSSIP_BOOTSTRAP=peer0.hospital.com:7051 - - CORE_PEER_GOSSIP_EXTERNALENDPOINT=peer2.hospital.com:9051 + - CORE_PEER_GOSSIP_EXTERNALENDPOINT=192.168.11.63:9051 - CORE_PEER_LOCALMSPID=HospitalMSP volumes: - /var/run/docker.sock:/host/var/run/docker.sock - - /run/desktop/mnt/host/c/fabric-data/network/organizations/peerOrganizations/hospital.com/peers/peer2.hospital.com/msp:/etc/hyperledger/fabric/msp - - /run/desktop/mnt/host/c/fabric-data/network/organizations/peerOrganizations/hospital.com/peers/peer2.hospital.com/tls:/etc/hyperledger/fabric/tls + - /home/my_device/josafat/hospital-log/backend/blockchain/network/organizations/peerOrganizations/hospital.com/peers/peer2.hospital.com/msp:/etc/hyperledger/fabric/msp + - /home/my_device/josafat/hospital-log/backend/blockchain/network/organizations/peerOrganizations/hospital.com/peers/peer2.hospital.com/tls:/etc/hyperledger/fabric/tls + - /home/my_device/josafat/hospital-log/backend/blockchain/data:/var/hyperledger/production + extra_hosts: + - "peer0.hospital.com:192.168.11.211" + - "orderer.hospital.com:192.168.11.211" + - "peer1.hospital.com:192.168.11.94" ports: - - "9051:9051" + - target: 9051 + published: 9051 + protocol: tcp + mode: host networks: - hospital_net deploy: