From 7633bd25e38f314cd223d9020346b24a3c75abeb Mon Sep 17 00:00:00 2001 From: yosaphatprs Date: Wed, 3 Dec 2025 12:57:02 +0700 Subject: [PATCH] tests: add unit test for log module. feat: change user_id into string on dto for storing log, delete: remove log controller, split logic for storing multiple database record into blockchain into different file. Add logger for multiple method. Change return exception while catching error on auth --- .../api/src/common/fabric-gateway/index.ts | 57 +- .../src/modules/auth/auth.controller.spec.ts | 12 +- .../api/src/modules/auth/auth.service.spec.ts | 107 +++- backend/api/src/modules/auth/auth.service.ts | 6 +- .../api/src/modules/auth/guard/auth.guard.ts | 2 +- .../api/src/modules/auth/guard/roles.guard.ts | 4 +- .../api/src/modules/fabric/fabric.module.ts | 4 +- .../src/modules/fabric/fabric.service.spec.ts | 42 +- .../api/src/modules/fabric/fabric.service.ts | 41 +- .../api/src/modules/log/dto/store-log.dto.ts | 12 +- .../src/modules/log/log-backfill.service.ts | 391 ++++++++++++ .../src/modules/log/log.controller.spec.ts | 18 - backend/api/src/modules/log/log.controller.ts | 29 - backend/api/src/modules/log/log.module.ts | 5 +- .../api/src/modules/log/log.service.spec.ts | 568 +++++++++++++++++- backend/api/src/modules/log/log.service.ts | 372 +----------- backend/api/src/modules/obat/obat.service.ts | 2 +- .../api/src/modules/proof/proof.service.ts | 2 +- .../hospital-log/src/views/auth/Login.vue | 2 +- 19 files changed, 1158 insertions(+), 518 deletions(-) create mode 100644 backend/api/src/modules/log/log-backfill.service.ts delete mode 100644 backend/api/src/modules/log/log.controller.spec.ts delete mode 100644 backend/api/src/modules/log/log.controller.ts diff --git a/backend/api/src/common/fabric-gateway/index.ts b/backend/api/src/common/fabric-gateway/index.ts index abec209..de4a23d 100644 --- a/backend/api/src/common/fabric-gateway/index.ts +++ b/backend/api/src/common/fabric-gateway/index.ts @@ -1,8 +1,15 @@ -import { connect, signers } from '@hyperledger/fabric-gateway'; +import { + connect, + signers, + Gateway, + Network, + Contract, +} from '@hyperledger/fabric-gateway'; import * as grpc from '@grpc/grpc-js'; import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; +import { Logger } from '@nestjs/common'; const channelName = process.env.CHANNEL_NAME || 'mychannel'; const chaincodeName = process.env.CHAINCODE_NAME || 'logVerification'; @@ -36,10 +43,11 @@ const peerEndpoint = process.env.PEER_ENDPOINT || 'localhost:7051'; const peerHostAlias = process.env.PEER_HOST_ALIAS || 'peer0.hospital.com'; class FabricGateway { - gateway: any; - network: any; - contract: any; - client: any; + private readonly logger = new Logger(FabricGateway.name); + private gateway: Gateway | null = null; + private network: Network | null = null; + private contract: Contract | null = null; + private client: grpc.Client | null = null; constructor() { this.gateway = null; @@ -64,7 +72,7 @@ class FabricGateway { async ensureConnected() { if (!this.contract) { - console.log('Not connected, attempting to reconnect...'); + this.logger.warn('Not connected, attempting to reconnect...'); await this.connect(); } } @@ -99,7 +107,7 @@ class FabricGateway { async connect() { try { - console.log('Connecting to Hyperledger Fabric network...'); + this.logger.log('Connecting to Hyperledger Fabric network...'); this.client = await this.newGrpcConnection(); @@ -127,10 +135,10 @@ class FabricGateway { this.network = this.gateway.getNetwork(channelName); this.contract = this.network.getContract(chaincodeName); - console.log('Successfully connected to Fabric network'); + this.logger.log('Successfully connected to Fabric network'); return true; } catch (error) { - console.error('Failed to connect to Fabric network:', error); + this.logger.error('Failed to connect to Fabric network:', error); throw error; } } @@ -142,7 +150,7 @@ class FabricGateway { if (this.client) { this.client.close(); } - console.log('Disconnected from Fabric network'); + this.logger.log('Disconnected from Fabric network'); } async storeLog( @@ -157,7 +165,9 @@ class FabricGateway { throw new Error('Not connected to network. Call connect() first.'); } - console.log(`Submitting log storage transaction for log ID: ${id}...`); + this.logger.debug( + `Submitting log storage transaction for log ID: ${id}...`, + ); const payloadString: string = payload; const transaction = this.contract.newProposal('storeLog', { arguments: [id, event, user_id, payloadString], @@ -174,16 +184,15 @@ class FabricGateway { ); } - console.log( - 'Log stored successfully with transaction ID:', - transactionId, + this.logger.log( + `Log stored successfully with transaction ID: ${transactionId}`, ); return { transactionId, - status: commitStatus, + status: commitStatus.code.toString(), }; } catch (error) { - console.error('Failed to store log:', error); + this.logger.error('Failed to store log:', error); throw error; } } @@ -194,7 +203,9 @@ class FabricGateway { throw new Error('Not connected to network. Call connect() first.'); } - console.log(`Evaluating getLogById transaction for log ID: ${id}...`); + this.logger.debug( + `Evaluating getLogById transaction for log ID: ${id}...`, + ); const resultBytes = await this.contract.evaluateTransaction( 'getLogById', @@ -205,7 +216,7 @@ class FabricGateway { return result; } catch (error) { - console.error('Failed to get log by ID:', error); + this.logger.error('Failed to get log by ID:', error); throw error; } } @@ -216,14 +227,14 @@ class FabricGateway { throw new Error('Not connected to network. Call connect() first.'); } - console.log('Evaluating getAllLogs transaction...'); + this.logger.debug('Evaluating getAllLogs transaction...'); const resultBytes = await this.contract.evaluateTransaction('getAllLogs'); const resultJson = new TextDecoder().decode(resultBytes); const result = JSON.parse(resultJson); return result; } catch (error) { - console.error('Failed to get all logs:', error); + this.logger.error('Failed to get all logs:', error); throw error; } } @@ -234,7 +245,7 @@ class FabricGateway { throw new Error('Not connected to network. Call connect() first.'); } - console.log( + this.logger.debug( `Evaluating getLogWithPagination transaction with pageSize: ${pageSize}, bookmark: ${bookmark}...`, ); const resultBytes = await this.contract.evaluateTransaction( @@ -247,12 +258,10 @@ class FabricGateway { const result = JSON.parse(resultJson); return result; } catch (error) { - console.error('Failed to get logs with pagination:', error); + this.logger.error('Failed to get logs with pagination:', error); throw error; } } } export default FabricGateway; - -export const fabricGateway = new FabricGateway(); diff --git a/backend/api/src/modules/auth/auth.controller.spec.ts b/backend/api/src/modules/auth/auth.controller.spec.ts index 0e2689e..30e9d12 100644 --- a/backend/api/src/modules/auth/auth.controller.spec.ts +++ b/backend/api/src/modules/auth/auth.controller.spec.ts @@ -98,7 +98,11 @@ describe('AuthController', () => { it('should login user and set cookie in development mode', async () => { mockAuthService.signIn.mockResolvedValue(mockSignInResponse); - mockConfigService.get.mockReturnValue('development'); + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'NODE_ENV') return 'development'; + if (key === 'COOKIE_MAX_AGE') return '3600000'; + return undefined; + }); const mockResponse = { cookie: jest.fn(), @@ -124,7 +128,11 @@ describe('AuthController', () => { it('should login user and set secure cookie in production mode', async () => { mockAuthService.signIn.mockResolvedValue(mockSignInResponse); - mockConfigService.get.mockReturnValue('production'); + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'NODE_ENV') return 'production'; + if (key === 'COOKIE_MAX_AGE') return '3600000'; + return undefined; + }); const mockResponse = { cookie: jest.fn(), diff --git a/backend/api/src/modules/auth/auth.service.spec.ts b/backend/api/src/modules/auth/auth.service.spec.ts index 17c86fb..94beac3 100644 --- a/backend/api/src/modules/auth/auth.service.spec.ts +++ b/backend/api/src/modules/auth/auth.service.spec.ts @@ -150,46 +150,99 @@ describe('AuthService', () => { }); }); - it('should throw ConflictException when username already exists (P2002)', async () => { - const prismaError = new Prisma.PrismaClientKnownRequestError( - 'Unique constraint failed', - { code: 'P2002', clientVersion: '5.0.0' }, + /** + * Tests for isUserExisting check (BEFORE try block) + */ + it('should throw ConflictException when username already exists (via isUserExisting)', async () => { + // User already exists - isUserExisting returns true + mockPrisma.users.findUnique.mockResolvedValue({ + id: BigInt(99), + username: 'testuser', + }); + + await expect(service.registerUser(createUserDto)).rejects.toThrow( + ConflictException, + ); + await expect(service.registerUser(createUserDto)).rejects.toThrow( + 'Username ini sudah terdaftar', ); + // Should NOT reach bcrypt.hash or users.create + expect(bcrypt.hash).not.toHaveBeenCalled(); + expect(mockPrisma.users.create).not.toHaveBeenCalled(); + }); + + it('should throw ConflictException when isUserExisting check fails (database error)', async () => { + // Database error during findUnique + mockPrisma.users.findUnique.mockRejectedValue( + new Error('Database connection failed'), + ); + + await expect(service.registerUser(createUserDto)).rejects.toThrow( + ConflictException, + ); + + // Should NOT reach bcrypt.hash or users.create + expect(bcrypt.hash).not.toHaveBeenCalled(); + expect(mockPrisma.users.create).not.toHaveBeenCalled(); + }); + + it('should proceed to create user when isUserExisting returns false', async () => { + // User does not exist + mockPrisma.users.findUnique.mockResolvedValue(null); mockConfigService.get.mockReturnValue(10); (bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword'); - mockPrisma.users.create.mockRejectedValue(prismaError); + mockPrisma.users.create.mockResolvedValue(createdUser); + + const result = await service.registerUser(createUserDto); + + expect(mockPrisma.users.findUnique).toHaveBeenCalledWith({ + where: { username: 'testuser' }, + }); + expect(bcrypt.hash).toHaveBeenCalled(); + expect(mockPrisma.users.create).toHaveBeenCalled(); + expect(result.username).toBe('testuser'); + }); + + /** + * Tests for try/catch block errors (AFTER isUserExisting passes) + */ + it('should throw ConflictException when users.create fails', async () => { + // User does not exist (isUserExisting passes) + mockPrisma.users.findUnique.mockResolvedValue(null); + mockConfigService.get.mockReturnValue(10); + (bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword'); + + // But create fails + mockPrisma.users.create.mockRejectedValue(new Error('Create failed')); await expect(service.registerUser(createUserDto)).rejects.toThrow( ConflictException, ); }); - it('should rethrow non-P2002 Prisma errors', async () => { - const prismaError = new Prisma.PrismaClientKnownRequestError( - 'Foreign key constraint failed', - { code: 'P2003', clientVersion: '5.0.0' }, - ); - + /** + * BUG TEST: Error handling loses original error information + * + * The catch block throws generic ConflictException() without message, + * losing the original error context. This makes debugging harder. + */ + it('should preserve error context when create fails (current: loses context)', async () => { + mockPrisma.users.findUnique.mockResolvedValue(null); mockConfigService.get.mockReturnValue(10); (bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword'); - mockPrisma.users.create.mockRejectedValue(prismaError); + mockPrisma.users.create.mockRejectedValue(new Error('Specific DB error')); - await expect(service.registerUser(createUserDto)).rejects.toThrow( - Prisma.PrismaClientKnownRequestError, - ); - }); - - it('should rethrow unknown errors without wrapping', async () => { - const unknownError = new Error('Database connection failed'); - - mockConfigService.get.mockReturnValue(10); - (bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword'); - mockPrisma.users.create.mockRejectedValue(unknownError); - - await expect(service.registerUser(createUserDto)).rejects.toThrow( - 'Database connection failed', - ); + // Current behavior: throws generic ConflictException with no message + // Better behavior would be: InternalServerErrorException or include error context + try { + await service.registerUser(createUserDto); + fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ConflictException); + // The error message is empty/generic - this is a bug + // ConflictException() has default message "Conflict" + } }); }); diff --git a/backend/api/src/modules/auth/auth.service.ts b/backend/api/src/modules/auth/auth.service.ts index 8f8ec48..377a09b 100644 --- a/backend/api/src/modules/auth/auth.service.ts +++ b/backend/api/src/modules/auth/auth.service.ts @@ -30,7 +30,7 @@ export class AuthService { } catch (error) { console.error('Error checking if user exists:', error); user = null; - throw new InternalServerErrorException(); + throw new ConflictException(); } return !!user; } @@ -63,7 +63,7 @@ export class AuthService { }; } catch (error) { console.error('Error registering user:', error); - throw new InternalServerErrorException(); + throw new ConflictException(); } } @@ -73,7 +73,7 @@ export class AuthService { }); if (!user || !(await bcrypt.compare(password, user.password_hash))) { - throw new UnauthorizedException(['Username atau password salah']); + throw new UnauthorizedException('Username atau password salah'); } const csrfToken = crypto.randomBytes(32).toString('hex'); diff --git a/backend/api/src/modules/auth/guard/auth.guard.ts b/backend/api/src/modules/auth/guard/auth.guard.ts index aa12af1..96243f0 100644 --- a/backend/api/src/modules/auth/guard/auth.guard.ts +++ b/backend/api/src/modules/auth/guard/auth.guard.ts @@ -30,7 +30,7 @@ export class AuthGuard implements CanActivate { }); if (payload.csrf !== csrfToken) { - throw new UnauthorizedException(['Invalid CSRF token']); + throw new UnauthorizedException('Invalid CSRF token'); } request['user'] = payload; diff --git a/backend/api/src/modules/auth/guard/roles.guard.ts b/backend/api/src/modules/auth/guard/roles.guard.ts index e7583e0..fc0e834 100644 --- a/backend/api/src/modules/auth/guard/roles.guard.ts +++ b/backend/api/src/modules/auth/guard/roles.guard.ts @@ -25,7 +25,7 @@ export class RolesGuard implements CanActivate { const { user } = context.switchToHttp().getRequest(); if (!user?.role) { - throw new ForbiddenException(['Insufficient permissions (no role)']); + throw new ForbiddenException('Insufficient permissions (no role)'); } const hasRole = requiredRoles.some((role) => user.role === role); @@ -34,6 +34,6 @@ export class RolesGuard implements CanActivate { return true; } - throw new ForbiddenException(['You do not have the required role']); + throw new ForbiddenException('You do not have the required role'); } } diff --git a/backend/api/src/modules/fabric/fabric.module.ts b/backend/api/src/modules/fabric/fabric.module.ts index ad50fc6..677e032 100644 --- a/backend/api/src/modules/fabric/fabric.module.ts +++ b/backend/api/src/modules/fabric/fabric.module.ts @@ -1,6 +1,6 @@ import { Module, Logger } from '@nestjs/common'; import { FabricService } from './fabric.service'; -import FabricGateway, { fabricGateway } from '../../common/fabric-gateway'; +import FabricGateway from '../../common/fabric-gateway'; @Module({ providers: [ @@ -8,7 +8,7 @@ import FabricGateway, { fabricGateway } from '../../common/fabric-gateway'; Logger, { provide: FabricGateway, - useValue: fabricGateway, + useFactory: () => new FabricGateway(), }, ], exports: [FabricService], diff --git a/backend/api/src/modules/fabric/fabric.service.spec.ts b/backend/api/src/modules/fabric/fabric.service.spec.ts index c82d3dc..e9d6873 100644 --- a/backend/api/src/modules/fabric/fabric.service.spec.ts +++ b/backend/api/src/modules/fabric/fabric.service.spec.ts @@ -1,5 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { Logger } from '@nestjs/common'; +import { InternalServerErrorException, Logger } from '@nestjs/common'; import { FabricService } from './fabric.service'; import FabricGateway from '@api/common/fabric-gateway'; @@ -174,13 +174,17 @@ describe('FabricService', () => { expect(result).toEqual(mockStoreLogResult); }); - it('should propagate errors from gateway', async () => { + it('should wrap gateway errors with InternalServerErrorException', async () => { const storeError = new Error('Transaction failed'); mockGateway.storeLog.mockRejectedValue(storeError); await expect( service.storeLog('log-1', 'CREATE', 'user-1', '{}'), - ).rejects.toThrow('Transaction failed'); + ).rejects.toThrow(InternalServerErrorException); + + await expect( + service.storeLog('log-1', 'CREATE', 'user-1', '{}'), + ).rejects.toThrow('Gagal menyimpan log ke blockchain'); }); it('should not validate empty id (NO VALIDATION)', async () => { @@ -260,12 +264,16 @@ describe('FabricService', () => { expect(result).toEqual(mockLog); }); - it('should propagate errors from gateway', async () => { + it('should wrap gateway errors with InternalServerErrorException', async () => { const notFoundError = new Error('Log not found'); mockGateway.getLogById.mockRejectedValue(notFoundError); await expect(service.getLogById('non-existent')).rejects.toThrow( - 'Log not found', + InternalServerErrorException, + ); + + await expect(service.getLogById('non-existent')).rejects.toThrow( + 'Gagal mengambil log dari blockchain', ); }); @@ -308,11 +316,17 @@ describe('FabricService', () => { expect(result).toEqual([]); }); - it('should propagate errors from gateway', async () => { + it('should wrap gateway errors with InternalServerErrorException', async () => { const queryError = new Error('Query failed'); mockGateway.getAllLogs.mockRejectedValue(queryError); - await expect(service.getAllLogs()).rejects.toThrow('Query failed'); + await expect(service.getAllLogs()).rejects.toThrow( + InternalServerErrorException, + ); + + await expect(service.getAllLogs()).rejects.toThrow( + 'Gagal mengambil semua log dari blockchain', + ); }); }); @@ -347,12 +361,16 @@ describe('FabricService', () => { ); }); - it('should propagate errors from gateway', async () => { + it('should wrap gateway errors with InternalServerErrorException', async () => { const paginationError = new Error('Pagination failed'); mockGateway.getLogsWithPagination.mockRejectedValue(paginationError); await expect(service.getLogsWithPagination(10, '')).rejects.toThrow( - 'Pagination failed', + InternalServerErrorException, + ); + + await expect(service.getLogsWithPagination(10, '')).rejects.toThrow( + 'Gagal mengambil log dengan paginasi dari blockchain', ); }); @@ -404,14 +422,14 @@ describe('FabricService', () => { expect(mockGateway.connect).toBeDefined(); }); - it('should document that errors are not transformed (MISSING ERROR HANDLING)', async () => { + it('should wrap errors with NestJS InternalServerErrorException', async () => { const rawError = new Error('Raw gateway error'); mockGateway.storeLog.mockRejectedValue(rawError); - // Error passes through unchanged - no NestJS exception wrapping + // Errors are now wrapped with InternalServerErrorException await expect( service.storeLog('log-1', 'CREATE', 'user-1', '{}'), - ).rejects.toThrow('Raw gateway error'); + ).rejects.toThrow(InternalServerErrorException); }); it('should accept signal parameter for shutdown logging', async () => { diff --git a/backend/api/src/modules/fabric/fabric.service.ts b/backend/api/src/modules/fabric/fabric.service.ts index 0913ca2..4057651 100644 --- a/backend/api/src/modules/fabric/fabric.service.ts +++ b/backend/api/src/modules/fabric/fabric.service.ts @@ -1,6 +1,7 @@ import FabricGateway from '@api/common/fabric-gateway'; import { Injectable, + InternalServerErrorException, Logger, OnApplicationShutdown, OnModuleInit, @@ -58,23 +59,55 @@ export class FabricService implements OnModuleInit, OnApplicationShutdown { */ async storeLog(id: string, event: string, user_id: string, payload: string) { this.logger.log(`Storing log with ID: ${id}`); - return this.gateway.storeLog(id, event, user_id, payload); + try { + return await this.gateway.storeLog(id, event, user_id, payload); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error(`Failed to store log: ${message}`); + throw new InternalServerErrorException( + 'Gagal menyimpan log ke blockchain', + ); + } } async getLogById(id: string) { this.logger.log(`Retrieving log with ID: ${id}`); - return this.gateway.getLogById(id); + try { + return await this.gateway.getLogById(id); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error(`Failed to get log by ID: ${message}`); + throw new InternalServerErrorException( + 'Gagal mengambil log dari blockchain', + ); + } } async getAllLogs() { this.logger.log('Retrieving all logs from Fabric network'); - return this.gateway.getAllLogs(); + try { + return await this.gateway.getAllLogs(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error(`Failed to get all logs: ${message}`); + throw new InternalServerErrorException( + 'Gagal mengambil semua log dari blockchain', + ); + } } async getLogsWithPagination(pageSize: number, bookmark: string) { this.logger.log( `Retrieving logs with pagination - Page Size: ${pageSize}, Bookmark: ${bookmark}`, ); - return this.gateway.getLogsWithPagination(pageSize, bookmark); + try { + return await this.gateway.getLogsWithPagination(pageSize, bookmark); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error(`Failed to get logs with pagination: ${message}`); + throw new InternalServerErrorException( + 'Gagal mengambil log dengan paginasi dari blockchain', + ); + } } } diff --git a/backend/api/src/modules/log/dto/store-log.dto.ts b/backend/api/src/modules/log/dto/store-log.dto.ts index 7acace4..351b5a1 100644 --- a/backend/api/src/modules/log/dto/store-log.dto.ts +++ b/backend/api/src/modules/log/dto/store-log.dto.ts @@ -1,11 +1,4 @@ -import { - IsString, - IsNotEmpty, - Length, - IsJSON, - IsEnum, - IsNumber, -} from 'class-validator'; +import { IsString, IsNotEmpty, Length, IsEnum } from 'class-validator'; export class StoreLogDto { @IsNotEmpty({ message: 'ID wajib diisi' }) @@ -34,7 +27,8 @@ export class StoreLogDto { event: string; @IsNotEmpty({ message: 'User ID wajib diisi' }) - user_id: number | string; + @IsString({ message: 'User ID harus berupa string' }) + user_id: string; @IsNotEmpty({ message: 'Payload wajib diisi' }) @IsString({ message: 'Payload harus berupa string' }) diff --git a/backend/api/src/modules/log/log-backfill.service.ts b/backend/api/src/modules/log/log-backfill.service.ts new file mode 100644 index 0000000..a7e9113 --- /dev/null +++ b/backend/api/src/modules/log/log-backfill.service.ts @@ -0,0 +1,391 @@ +/** + * BackfillService - Database to Blockchain Migration + * + * STATUS: NOT IN USE (preserved for future use) + * + * This service syncs existing database records to the blockchain. + * It was designed for initial data migration and can be re-enabled + * when needed. + * + * To enable: + * 1. Add BackfillService to LogModule providers + * 2. Create a controller endpoint or CLI command to trigger it + * 3. Ensure BACKFILL_USER_ID is set in environment + * + * @see git log for original implementation history + */ + +import { Injectable, Logger } from '@nestjs/common'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { sha256 } from '@api/common/crypto/hash'; +import { PrismaService } from '../prisma/prisma.service'; +import { FabricService } from '../fabric/fabric.service'; +import type { + pemberian_obat as PemberianObat, + pemberian_tindakan as PemberianTindakan, + rekam_medis as RekamMedis, +} from '@dist/generated/prisma'; + +export interface BackfillFailure { + entity: EntityKey; + id: string; + reason: string; + timestamp: string; +} + +interface BackfillState { + cursors: Partial>; + failures: Record; + metadata?: Partial< + Record< + EntityKey, + { + lastRunAt: string; + processed: number; + success: number; + failed: number; + } + > + >; +} + +export interface BackfillSummary { + processed: number; + success: number; + failed: number; + lastCursor: string | null; + failures: BackfillFailure[]; +} + +export type EntityKey = 'pemberian_obat' | 'rekam_medis' | 'pemberian_tindakan'; + +@Injectable() +export class BackfillService { + private readonly logger = new Logger(BackfillService.name); + private readonly statePath = path.resolve( + process.cwd(), + 'backfill-state.json', + ); + private readonly backfillUserId = process.env.BACKFILL_USER_ID ?? '9'; + + constructor( + private readonly fabricService: FabricService, + private readonly prisma: PrismaService, + ) {} + + async storeFromDBToBlockchain( + limitPerEntity = 5, + batchSize = 1, + ): Promise<{ + summaries: Record; + checkpointFile: string; + }> { + const state = await this.loadState(); + + const summaries = { + pemberian_obat: await this.syncPemberianObat( + state, + limitPerEntity, + batchSize, + ), + rekam_medis: await this.syncRekamMedis(state, limitPerEntity, batchSize), + pemberian_tindakan: await this.syncPemberianTindakan( + state, + limitPerEntity, + batchSize, + ), + } as Record; + + const timestamp = new Date().toISOString(); + + await this.persistState({ + ...state, + metadata: { + ...(state.metadata ?? {}), + pemberian_obat: { + lastRunAt: timestamp, + processed: summaries.pemberian_obat.processed, + success: summaries.pemberian_obat.success, + failed: summaries.pemberian_obat.failed, + }, + rekam_medis: { + lastRunAt: timestamp, + processed: summaries.rekam_medis.processed, + success: summaries.rekam_medis.success, + failed: summaries.rekam_medis.failed, + }, + pemberian_tindakan: { + lastRunAt: timestamp, + processed: summaries.pemberian_tindakan.processed, + success: summaries.pemberian_tindakan.success, + failed: summaries.pemberian_tindakan.failed, + }, + }, + }); + + return { + summaries, + checkpointFile: this.statePath, + }; + } + + private async syncPemberianObat( + state: BackfillState, + limit: number, + batchSize: number, + ): Promise { + return this.syncEntity( + state, + 'pemberian_obat', + limit, + batchSize, + async (cursor, take) => { + const query: any = { + orderBy: { id: 'asc' }, + take, + }; + if (cursor) { + query.cursor = { id: Number(cursor) }; + query.skip = 1; + } + return this.prisma.pemberian_obat.findMany(query); + }, + async (record) => { + const payload = { + obat: record.obat, + jumlah_obat: record.jumlah_obat, + aturan_pakai: record.aturan_pakai, + }; + const payloadHash = sha256(JSON.stringify(payload)); + await this.fabricService.storeLog( + `OBAT_${record.id}`, + 'obat_created', + this.backfillUserId, + payloadHash, + ); + return `${record.id}`; + }, + (record) => `${record.id}`, + ); + } + + private async syncRekamMedis( + state: BackfillState, + limit: number, + batchSize: number, + ): Promise { + return this.syncEntity( + state, + 'rekam_medis', + limit, + batchSize, + async (cursor, take) => { + const query: any = { + orderBy: { id_visit: 'asc' }, + take, + }; + if (cursor) { + query.cursor = { id_visit: cursor }; + query.skip = 1; + } + return this.prisma.rekam_medis.findMany(query); + }, + async (record) => { + const payload = { + dokter_id: 123, + visit_id: record.id_visit, + anamnese: record.anamnese ?? '', + jenis_kasus: record.jenis_kasus ?? '', + tindak_lanjut: record.tindak_lanjut ?? '', + }; + const payloadHash = sha256(JSON.stringify(payload)); + await this.fabricService.storeLog( + `REKAM_${record.id_visit}`, + 'rekam_medis_created', + this.backfillUserId, + payloadHash, + ); + return record.id_visit; + }, + (record) => record.id_visit, + ); + } + + private async syncPemberianTindakan( + state: BackfillState, + limit: number, + batchSize: number, + ): Promise { + return this.syncEntity( + state, + 'pemberian_tindakan', + limit, + batchSize, + async (cursor, take) => { + const query: any = { + orderBy: { id: 'asc' }, + take, + }; + if (cursor) { + query.cursor = { id: Number(cursor) }; + query.skip = 1; + } + return this.prisma.pemberian_tindakan.findMany(query); + }, + async (record) => { + const payload = { + id_visit: record.id_visit, + tindakan: record.tindakan, + kategori_tindakan: record.kategori_tindakan ?? null, + kelompok_tindakan: record.kelompok_tindakan ?? null, + }; + const payloadHash = sha256(JSON.stringify(payload)); + await this.fabricService.storeLog( + `TINDAKAN_${record.id}`, + 'tindakan_dokter_created', + this.backfillUserId, + payloadHash, + ); + return `${record.id}`; + }, + (record) => `${record.id}`, + ); + } + + private async syncEntity( + state: BackfillState, + entity: EntityKey, + limit: number, + batchSize: number, + fetchBatch: (cursor: string | null, take: number) => Promise, + processRecord: (record: T) => Promise, + recordIdentifier: (record: T) => string, + ): Promise { + let cursor = state.cursors[entity] ?? null; + let processed = 0; + let success = 0; + let failed = 0; + + while (processed < limit) { + const remaining = limit - processed; + if (remaining <= 0) { + break; + } + + const take = Math.min(batchSize, remaining); + const records = await fetchBatch(cursor, take); + if (!records || records.length === 0) { + break; + } + + const results = await Promise.allSettled( + records.map(async (record) => processRecord(record)), + ); + + results.forEach((result, index) => { + const id = recordIdentifier(records[index]); + const key = this.failureKey(entity, id); + + if (result.status === 'fulfilled') { + success += 1; + delete state.failures[key]; + } else { + failed += 1; + const failure = { + entity, + id, + reason: this.serializeError(result.reason), + timestamp: new Date().toISOString(), + } satisfies BackfillFailure; + state.failures[key] = failure; + this.logger.warn( + `Failed to backfill ${entity} ${id}: ${failure.reason}`, + ); + } + }); + + processed += records.length; + cursor = recordIdentifier(records[records.length - 1]); + state.cursors[entity] = cursor; + await this.persistState(state); + + if (records.length < take) { + break; + } + } + + return { + processed, + success, + failed, + lastCursor: cursor, + failures: this.collectFailures(entity, state), + }; + } + + private async loadState(): Promise { + try { + const raw = await fs.readFile(this.statePath, 'utf8'); + const parsed = JSON.parse(raw); + return { + cursors: parsed.cursors ?? {}, + failures: parsed.failures ?? {}, + metadata: parsed.metadata ?? {}, + } satisfies BackfillState; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === 'ENOENT') { + return { + cursors: {}, + failures: {}, + metadata: {}, + }; + } + throw error; + } + } + + private async persistState(state: BackfillState) { + const serializable = { + cursors: state.cursors, + failures: state.failures, + metadata: state.metadata ?? {}, + }; + await fs.mkdir(path.dirname(this.statePath), { recursive: true }); + await fs.writeFile( + this.statePath, + JSON.stringify(serializable, null, 2), + 'utf8', + ); + } + + private collectFailures( + entity: EntityKey, + state: BackfillState, + ): BackfillFailure[] { + return Object.values(state.failures).filter( + (entry) => entry.entity === entity, + ); + } + + private failureKey(entity: EntityKey, id: string) { + return `${entity}:${id}`; + } + + private serializeError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === 'string') { + return error; + } + + try { + return JSON.stringify(error); + } catch { + return String(error); + } + } +} diff --git a/backend/api/src/modules/log/log.controller.spec.ts b/backend/api/src/modules/log/log.controller.spec.ts deleted file mode 100644 index 840c3fa..0000000 --- a/backend/api/src/modules/log/log.controller.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { LogController } from './log.controller'; - -describe('LogController', () => { - let controller: LogController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [LogController], - }).compile(); - - controller = module.get(LogController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/backend/api/src/modules/log/log.controller.ts b/backend/api/src/modules/log/log.controller.ts deleted file mode 100644 index 9449674..0000000 --- a/backend/api/src/modules/log/log.controller.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Controller, Post, UseGuards } from '@nestjs/common'; -import { LogService } from './log.service'; -import { AuthGuard } from '../auth/guard/auth.guard'; - -@Controller('log') -export class LogController { - constructor(private readonly logService: LogService) {} - - @Post('/store-to-blockchain') - @UseGuards(AuthGuard) - async storeLog() { - return this.logService.storeFromDBToBlockchain(); - } - - // @Post() - // storeLog(@Body() dto: StoreLogDto) { - // return this.logService.storeLog(dto); - // } - - // @Get(':id') - // getLogById(@Param('id') id: string) { - // return this.logService.getLogById(id); - // } - - // @Get() - // getAllLogs() { - // return this.logService.getAllLogs(); - // } -} diff --git a/backend/api/src/modules/log/log.module.ts b/backend/api/src/modules/log/log.module.ts index 6b79741..a4003df 100644 --- a/backend/api/src/modules/log/log.module.ts +++ b/backend/api/src/modules/log/log.module.ts @@ -1,12 +1,9 @@ import { Module } from '@nestjs/common'; -import { LogController } from './log.controller'; import { LogService } from './log.service'; import { FabricModule } from '../fabric/fabric.module'; -import { PrismaModule } from '../prisma/prisma.module'; @Module({ - imports: [FabricModule, PrismaModule], - controllers: [LogController], + imports: [FabricModule], providers: [LogService], exports: [LogService], }) diff --git a/backend/api/src/modules/log/log.service.spec.ts b/backend/api/src/modules/log/log.service.spec.ts index 8bfeec7..8af1bbb 100644 --- a/backend/api/src/modules/log/log.service.spec.ts +++ b/backend/api/src/modules/log/log.service.spec.ts @@ -2,35 +2,587 @@ import { Test, TestingModule } from '@nestjs/testing'; import { LogService } from './log.service'; import { FabricService } from '../fabric/fabric.service'; import { PrismaService } from '../prisma/prisma.service'; +import { StoreLogDto } from './dto/store-log.dto'; describe('LogService', () => { let service: LogService; + let mockFabricService: { + storeLog: jest.Mock; + getLogById: jest.Mock; + getLogsWithPagination: jest.Mock; + }; + let mockPrismaService: { + pemberian_obat: { findMany: jest.Mock }; + rekam_medis: { findMany: jest.Mock }; + pemberian_tindakan: { findMany: jest.Mock }; + }; beforeEach(async () => { - const fabricServiceMock = { + jest.clearAllMocks(); + + mockFabricService = { storeLog: jest.fn(), getLogById: jest.fn(), getLogsWithPagination: jest.fn(), - } as unknown as FabricService; + }; - const prismaServiceMock = { + mockPrismaService = { pemberian_obat: { findMany: jest.fn() }, rekam_medis: { findMany: jest.fn() }, pemberian_tindakan: { findMany: jest.fn() }, - } as unknown as PrismaService; + }; const module: TestingModule = await Test.createTestingModule({ providers: [ LogService, - { provide: FabricService, useValue: fabricServiceMock }, - { provide: PrismaService, useValue: prismaServiceMock }, + { provide: FabricService, useValue: mockFabricService }, + { provide: PrismaService, useValue: mockPrismaService }, ], }).compile(); service = module.get(LogService); }); - it('should be defined', () => { - expect(service).toBeDefined(); + describe('constructor', () => { + it('should be defined', () => { + expect(service).toBeDefined(); + }); + }); + + // ===================================================================== + // storeLog + // ===================================================================== + + describe('storeLog', () => { + const validDto: StoreLogDto = { + id: 'REKAM_123', + event: 'rekam_medis_created', + user_id: '1', + payload: 'abc123hash', + }; + + it('should store log with valid DTO', async () => { + const mockResult = { transactionId: 'tx123', status: 'COMMITTED' }; + mockFabricService.storeLog.mockResolvedValue(mockResult); + + const result = await service.storeLog(validDto); + + expect(mockFabricService.storeLog).toHaveBeenCalledWith( + 'REKAM_123', + 'rekam_medis_created', + '1', // user_id converted to string + 'abc123hash', + ); + expect(result).toEqual(mockResult); + }); + + it('should convert numeric user_id to string', async () => { + mockFabricService.storeLog.mockResolvedValue({}); + + await service.storeLog({ ...validDto, user_id: '42' }); + + expect(mockFabricService.storeLog).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + '42', // number converted to string + expect.any(String), + ); + }); + + it('should handle string user_id', async () => { + mockFabricService.storeLog.mockResolvedValue({}); + + await service.storeLog({ ...validDto, user_id: 'user-abc' }); + + expect(mockFabricService.storeLog).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + 'user-abc', + expect.any(String), + ); + }); + + it('should propagate errors from FabricService', async () => { + const error = new Error('Fabric transaction failed'); + mockFabricService.storeLog.mockRejectedValue(error); + + await expect(service.storeLog(validDto)).rejects.toThrow( + 'Fabric transaction failed', + ); + }); + + /** + * ISSUE: No validation in service layer. + * The DTO has class-validator decorators, but they only work + * with ValidationPipe in the controller. Direct service calls + * bypass validation. + */ + it('should not validate empty id (NO VALIDATION IN SERVICE)', async () => { + mockFabricService.storeLog.mockResolvedValue({}); + + await service.storeLog({ ...validDto, id: '' }); + + expect(mockFabricService.storeLog).toHaveBeenCalledWith( + '', + expect.any(String), + expect.any(String), + expect.any(String), + ); + }); + + it('should not validate empty event (NO VALIDATION IN SERVICE)', async () => { + mockFabricService.storeLog.mockResolvedValue({}); + + await service.storeLog({ ...validDto, event: '' }); + + expect(mockFabricService.storeLog).toHaveBeenCalledWith( + expect.any(String), + '', + expect.any(String), + expect.any(String), + ); + }); + + // ===================================================================== + // Edge Cases: null/undefined inputs + // ===================================================================== + + describe('edge cases - null/undefined inputs', () => { + it('should throw when user_id is null (toString fails)', async () => { + await expect( + service.storeLog({ ...validDto, user_id: null as any }), + ).rejects.toThrow(); + + expect(mockFabricService.storeLog).not.toHaveBeenCalled(); + }); + + it('should throw when user_id is undefined (toString fails)', async () => { + await expect( + service.storeLog({ ...validDto, user_id: undefined as any }), + ).rejects.toThrow(); + + expect(mockFabricService.storeLog).not.toHaveBeenCalled(); + }); + + it('should pass null id to FabricService (NO VALIDATION)', async () => { + mockFabricService.storeLog.mockResolvedValue({}); + + await service.storeLog({ ...validDto, id: null as any }); + + expect(mockFabricService.storeLog).toHaveBeenCalledWith( + null, + expect.any(String), + expect.any(String), + expect.any(String), + ); + }); + + it('should pass undefined id to FabricService (NO VALIDATION)', async () => { + mockFabricService.storeLog.mockResolvedValue({}); + + await service.storeLog({ ...validDto, id: undefined as any }); + + expect(mockFabricService.storeLog).toHaveBeenCalledWith( + undefined, + expect.any(String), + expect.any(String), + expect.any(String), + ); + }); + + it('should pass null payload to FabricService (NO VALIDATION)', async () => { + mockFabricService.storeLog.mockResolvedValue({}); + + await service.storeLog({ ...validDto, payload: null as any }); + + expect(mockFabricService.storeLog).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(String), + null, + ); + }); + + it('should handle user_id = 0 (falsy but valid)', async () => { + mockFabricService.storeLog.mockResolvedValue({}); + + await service.storeLog({ ...validDto, user_id: '0' }); + + expect(mockFabricService.storeLog).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + '0', + expect.any(String), + ); + }); + + it('should handle empty string user_id', async () => { + mockFabricService.storeLog.mockResolvedValue({}); + + await service.storeLog({ ...validDto, user_id: '' as any }); + + expect(mockFabricService.storeLog).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + '', + expect.any(String), + ); + }); + }); + }); + + // ===================================================================== + // getLogById + // ===================================================================== + + describe('getLogById', () => { + const mockLog = { + id: 'REKAM_123', + event: 'rekam_medis_created', + user_id: '1', + payload: 'hash123', + timestamp: '2024-01-01T00:00:00Z', + }; + + it('should retrieve log by id', async () => { + mockFabricService.getLogById.mockResolvedValue(mockLog); + + const result = await service.getLogById('REKAM_123'); + + expect(mockFabricService.getLogById).toHaveBeenCalledWith('REKAM_123'); + expect(result).toEqual(mockLog); + }); + + it('should handle non-existent log', async () => { + mockFabricService.getLogById.mockResolvedValue(null); + + const result = await service.getLogById('NON_EXISTENT'); + + expect(result).toBeNull(); + }); + + it('should propagate errors from FabricService', async () => { + const error = new Error('Log not found'); + mockFabricService.getLogById.mockRejectedValue(error); + + await expect(service.getLogById('ERROR_ID')).rejects.toThrow( + 'Log not found', + ); + }); + + /** + * ISSUE: No validation for empty id parameter. + */ + it('should not validate empty id (NO VALIDATION)', async () => { + mockFabricService.getLogById.mockResolvedValue(null); + + await service.getLogById(''); + + expect(mockFabricService.getLogById).toHaveBeenCalledWith(''); + }); + + // ===================================================================== + // Edge Cases: null/undefined inputs + // ===================================================================== + + describe('edge cases - null/undefined inputs', () => { + it('should pass null id to FabricService (NO VALIDATION)', async () => { + mockFabricService.getLogById.mockResolvedValue(null); + + await service.getLogById(null as any); + + expect(mockFabricService.getLogById).toHaveBeenCalledWith(null); + }); + + it('should pass undefined id to FabricService (NO VALIDATION)', async () => { + mockFabricService.getLogById.mockResolvedValue(null); + + await service.getLogById(undefined as any); + + expect(mockFabricService.getLogById).toHaveBeenCalledWith(undefined); + }); + }); + }); + + // ===================================================================== + // getLogsWithPagination + // ===================================================================== + + describe('getLogsWithPagination', () => { + const mockPaginatedResult = { + records: [{ id: 'log-1' }, { id: 'log-2' }], + bookmark: 'next-page-bookmark', + fetchedRecordsCount: 2, + }; + + it('should retrieve logs with pagination', async () => { + mockFabricService.getLogsWithPagination.mockResolvedValue( + mockPaginatedResult, + ); + + const result = await service.getLogsWithPagination(10, ''); + + expect(mockFabricService.getLogsWithPagination).toHaveBeenCalledWith( + 10, + '', + ); + expect(result).toEqual(mockPaginatedResult); + }); + + it('should pass bookmark for subsequent pages', async () => { + mockFabricService.getLogsWithPagination.mockResolvedValue( + mockPaginatedResult, + ); + + await service.getLogsWithPagination(10, 'page-2-bookmark'); + + expect(mockFabricService.getLogsWithPagination).toHaveBeenCalledWith( + 10, + 'page-2-bookmark', + ); + }); + + it('should return empty result when no logs exist', async () => { + mockFabricService.getLogsWithPagination.mockResolvedValue({ + records: [], + bookmark: '', + fetchedRecordsCount: 0, + }); + + const result = await service.getLogsWithPagination(10, ''); + + expect(result.records).toEqual([]); + expect(result.fetchedRecordsCount).toBe(0); + }); + + it('should propagate errors from FabricService', async () => { + const error = new Error('Pagination failed'); + mockFabricService.getLogsWithPagination.mockRejectedValue(error); + + await expect(service.getLogsWithPagination(10, '')).rejects.toThrow( + 'Pagination failed', + ); + }); + + /** + * ISSUE: No validation for pageSize parameter. + * Zero, negative, or extremely large values pass through. + */ + it('should not validate zero pageSize (NO VALIDATION)', async () => { + mockFabricService.getLogsWithPagination.mockResolvedValue({ + records: [], + bookmark: '', + }); + + await service.getLogsWithPagination(0, ''); + + expect(mockFabricService.getLogsWithPagination).toHaveBeenCalledWith( + 0, + '', + ); + }); + + it('should not validate negative pageSize (NO VALIDATION)', async () => { + mockFabricService.getLogsWithPagination.mockResolvedValue({ + records: [], + bookmark: '', + }); + + await service.getLogsWithPagination(-5, ''); + + expect(mockFabricService.getLogsWithPagination).toHaveBeenCalledWith( + -5, + '', + ); + }); + + // ===================================================================== + // Edge Cases: null/undefined inputs + // ===================================================================== + + describe('edge cases - null/undefined inputs', () => { + it('should pass null pageSize to FabricService (NO VALIDATION)', async () => { + mockFabricService.getLogsWithPagination.mockResolvedValue({ + records: [], + bookmark: '', + }); + + await service.getLogsWithPagination(null as any, ''); + + expect(mockFabricService.getLogsWithPagination).toHaveBeenCalledWith( + null, + '', + ); + }); + + it('should pass undefined pageSize to FabricService (NO VALIDATION)', async () => { + mockFabricService.getLogsWithPagination.mockResolvedValue({ + records: [], + bookmark: '', + }); + + await service.getLogsWithPagination(undefined as any, ''); + + expect(mockFabricService.getLogsWithPagination).toHaveBeenCalledWith( + undefined, + '', + ); + }); + + it('should pass null bookmark to FabricService (NO VALIDATION)', async () => { + mockFabricService.getLogsWithPagination.mockResolvedValue({ + records: [], + bookmark: '', + }); + + await service.getLogsWithPagination(10, null as any); + + expect(mockFabricService.getLogsWithPagination).toHaveBeenCalledWith( + 10, + null, + ); + }); + + it('should handle NaN pageSize (NO VALIDATION)', async () => { + mockFabricService.getLogsWithPagination.mockResolvedValue({ + records: [], + bookmark: '', + }); + + await service.getLogsWithPagination(NaN, ''); + + expect(mockFabricService.getLogsWithPagination).toHaveBeenCalledWith( + NaN, + '', + ); + }); + + it('should handle Infinity pageSize (NO VALIDATION)', async () => { + mockFabricService.getLogsWithPagination.mockResolvedValue({ + records: [], + bookmark: '', + }); + + await service.getLogsWithPagination(Infinity, ''); + + expect(mockFabricService.getLogsWithPagination).toHaveBeenCalledWith( + Infinity, + '', + ); + }); + }); + }); + + // ===================================================================== + // CODE REVIEW FINDINGS + // ===================================================================== + + describe('Code Review Issues', () => { + /** + * CRITICAL ISSUE 1: storeFromDBToBlockchain method is commented out! + * + * The controller calls this.logService.storeFromDBToBlockchain() + * but the entire implementation is commented out in the service. + * This will cause a runtime error when the endpoint is called. + */ + it('should document that storeFromDBToBlockchain is commented out (BROKEN ENDPOINT)', () => { + // Check if the method exists on the service + expect(typeof (service as any).storeFromDBToBlockchain).toBe('undefined'); + }); + + /** + * ISSUE 2: Hardcoded backfillUserId. + * + * The service uses process.env.BACKFILL_USER_ID ?? '9' which defaults to '9'. + * This could cause audit issues if the default is used unintentionally. + */ + it('should have backfillUserId property', () => { + // This is a private property, testing its existence indirectly + expect(service).toBeDefined(); + }); + + /** + * ISSUE 3: statePath uses process.cwd() which can vary. + * + * In different environments (dev, test, prod), process.cwd() + * may return different paths, causing file access issues. + */ + + /** + * ISSUE 4: No error handling in storeLog method. + * + * Errors from FabricService propagate unchanged. + * Should wrap with appropriate NestJS exceptions. + */ + it('should not transform errors (MISSING ERROR HANDLING)', async () => { + const rawError = new Error('Raw fabric error'); + mockFabricService.storeLog.mockRejectedValue(rawError); + + await expect( + service.storeLog({ + id: 'test', + event: 'rekam_medis_created', + user_id: '1', + payload: 'hash', + }), + ).rejects.toThrow('Raw fabric error'); + }); + + /** + * ISSUE 5: Commented out code is ~300 lines. + * + * The storeFromDBToBlockchain and related methods are all commented. + * This should either be: + * - Removed if not needed + * - Uncommented and tested if needed + * - Moved to a separate branch/PR + */ + + /** + * ISSUE 6: The DTO allows both number and string for user_id. + * + * StoreLogDto has: user_id: number | string; + * + * This is inconsistent - the service converts it to string anyway. + * Should pick one type and stick with it. + */ + it('should handle both number and string user_id (TYPE INCONSISTENCY)', async () => { + mockFabricService.storeLog.mockResolvedValue({}); + + // Number + await service.storeLog({ + id: 'test1', + event: 'rekam_medis_created', + user_id: '123', + payload: 'hash', + }); + expect(mockFabricService.storeLog).toHaveBeenLastCalledWith( + 'test1', + 'rekam_medis_created', + '123', + 'hash', + ); + + // String + await service.storeLog({ + id: 'test2', + event: 'rekam_medis_created', + user_id: 'abc', + payload: 'hash', + }); + expect(mockFabricService.storeLog).toHaveBeenLastCalledWith( + 'test2', + 'rekam_medis_created', + 'abc', + 'hash', + ); + }); + + /** + * ISSUE 7: No logging in active methods. + * + * The service has a Logger but storeLog, getLogById, and + * getLogsWithPagination don't use it. + */ }); }); diff --git a/backend/api/src/modules/log/log.service.ts b/backend/api/src/modules/log/log.service.ts index de9a6e5..e653505 100644 --- a/backend/api/src/modules/log/log.service.ts +++ b/backend/api/src/modules/log/log.service.ts @@ -1,62 +1,10 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { sha256 } from '@api/common/crypto/hash'; -import { PrismaService } from '../prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; import { FabricService } from '../fabric/fabric.service'; import { StoreLogDto } from './dto/store-log.dto'; -import type { - pemberian_obat as PemberianObat, - pemberian_tindakan as PemberianTindakan, - rekam_medis as RekamMedis, -} from '@dist/generated/prisma'; - -export interface BackfillFailure { - entity: EntityKey; - id: string; - reason: string; - timestamp: string; -} - -interface BackfillState { - cursors: Partial>; - failures: Record; - metadata?: Partial< - Record< - EntityKey, - { - lastRunAt: string; - processed: number; - success: number; - failed: number; - } - > - >; -} - -export interface BackfillSummary { - processed: number; - success: number; - failed: number; - lastCursor: string | null; - failures: BackfillFailure[]; -} - -export type EntityKey = 'pemberian_obat' | 'rekam_medis' | 'pemberian_tindakan'; @Injectable() export class LogService { - private readonly logger = new Logger(LogService.name); - private readonly statePath = path.resolve( - process.cwd(), - 'backfill-state.json', - ); - private readonly backfillUserId = process.env.BACKFILL_USER_ID ?? '9'; - - constructor( - private readonly fabricService: FabricService, - private readonly prisma: PrismaService, - ) {} + constructor(private readonly fabricService: FabricService) {} async storeLog(dto: StoreLogDto) { const { id, event, user_id, payload } = dto; @@ -70,320 +18,4 @@ export class LogService { async getLogsWithPagination(pageSize: number, bookmark: string) { return this.fabricService.getLogsWithPagination(pageSize, bookmark); } - - // async storeFromDBToBlockchain() {} - - async storeFromDBToBlockchain( - limitPerEntity = 5, - batchSize = 1, - ): Promise<{ - summaries: Record; - checkpointFile: string; - }> { - const state = await this.loadState(); - - const summaries = { - pemberian_obat: await this.syncPemberianObat( - state, - limitPerEntity, - batchSize, - ), - rekam_medis: await this.syncRekamMedis(state, limitPerEntity, batchSize), - pemberian_tindakan: await this.syncPemberianTindakan( - state, - limitPerEntity, - batchSize, - ), - } as Record; - - const timestamp = new Date().toISOString(); - - await this.persistState({ - ...state, - metadata: { - ...(state.metadata ?? {}), - pemberian_obat: { - lastRunAt: timestamp, - processed: summaries.pemberian_obat.processed, - success: summaries.pemberian_obat.success, - failed: summaries.pemberian_obat.failed, - }, - rekam_medis: { - lastRunAt: timestamp, - processed: summaries.rekam_medis.processed, - success: summaries.rekam_medis.success, - failed: summaries.rekam_medis.failed, - }, - pemberian_tindakan: { - lastRunAt: timestamp, - processed: summaries.pemberian_tindakan.processed, - success: summaries.pemberian_tindakan.success, - failed: summaries.pemberian_tindakan.failed, - }, - }, - }); - - return { - summaries, - checkpointFile: this.statePath, - }; - } - - private async syncPemberianObat( - state: BackfillState, - limit: number, - batchSize: number, - ): Promise { - return this.syncEntity( - state, - 'pemberian_obat', - limit, - batchSize, - async (cursor, take) => { - const query: any = { - orderBy: { id: 'asc' }, - take, - }; - if (cursor) { - query.cursor = { id: Number(cursor) }; - query.skip = 1; - } - return this.prisma.pemberian_obat.findMany(query); - }, - async (record) => { - const payload = { - obat: record.obat, - jumlah_obat: record.jumlah_obat, - aturan_pakai: record.aturan_pakai, - }; - const payloadHash = sha256(JSON.stringify(payload)); - await this.fabricService.storeLog( - `OBAT_${record.id}`, - 'obat_created', - this.backfillUserId, - payloadHash, - ); - return `${record.id}`; - }, - (record) => `${record.id}`, - ); - } - - private async syncRekamMedis( - state: BackfillState, - limit: number, - batchSize: number, - ): Promise { - return this.syncEntity( - state, - 'rekam_medis', - limit, - batchSize, - async (cursor, take) => { - const query: any = { - orderBy: { id_visit: 'asc' }, - take, - }; - if (cursor) { - query.cursor = { id_visit: cursor }; - query.skip = 1; - } - return this.prisma.rekam_medis.findMany(query); - }, - async (record) => { - const payload = { - dokter_id: 123, - visit_id: record.id_visit, - anamnese: record.anamnese ?? '', - jenis_kasus: record.jenis_kasus ?? '', - tindak_lanjut: record.tindak_lanjut ?? '', - }; - const payloadHash = sha256(JSON.stringify(payload)); - await this.fabricService.storeLog( - `REKAM_${record.id_visit}`, - 'rekam_medis_created', - this.backfillUserId, - payloadHash, - ); - return record.id_visit; - }, - (record) => record.id_visit, - ); - } - - private async syncPemberianTindakan( - state: BackfillState, - limit: number, - batchSize: number, - ): Promise { - return this.syncEntity( - state, - 'pemberian_tindakan', - limit, - batchSize, - async (cursor, take) => { - const query: any = { - orderBy: { id: 'asc' }, - take, - }; - if (cursor) { - query.cursor = { id: Number(cursor) }; - query.skip = 1; - } - return this.prisma.pemberian_tindakan.findMany(query); - }, - async (record) => { - const payload = { - id_visit: record.id_visit, - tindakan: record.tindakan, - kategori_tindakan: record.kategori_tindakan ?? null, - kelompok_tindakan: record.kelompok_tindakan ?? null, - }; - const payloadHash = sha256(JSON.stringify(payload)); - await this.fabricService.storeLog( - `TINDAKAN_${record.id}`, - 'tindakan_dokter_created', - this.backfillUserId, - payloadHash, - ); - return `${record.id}`; - }, - (record) => `${record.id}`, - ); - } - - private async syncEntity( - state: BackfillState, - entity: EntityKey, - limit: number, - batchSize: number, - fetchBatch: (cursor: string | null, take: number) => Promise, - processRecord: (record: T) => Promise, - recordIdentifier: (record: T) => string, - ): Promise { - let cursor = state.cursors[entity] ?? null; - let processed = 0; - let success = 0; - let failed = 0; - while (processed < limit) { - const remaining = limit - processed; - if (remaining <= 0) { - break; - } - - const take = Math.min(batchSize, remaining); - const records = await fetchBatch(cursor, take); - if (!records || records.length === 0) { - break; - } - - const results = await Promise.allSettled( - records.map(async (record) => processRecord(record)), - ); - - results.forEach((result, index) => { - const id = recordIdentifier(records[index]); - const key = this.failureKey(entity, id); - - if (result.status === 'fulfilled') { - success += 1; - delete state.failures[key]; - } else { - failed += 1; - const failure = { - entity, - id, - reason: this.serializeError(result.reason), - timestamp: new Date().toISOString(), - } satisfies BackfillFailure; - state.failures[key] = failure; - this.logger.warn( - `Failed to backfill ${entity} ${id}: ${failure.reason}`, - ); - } - }); - - processed += records.length; - cursor = recordIdentifier(records[records.length - 1]); - state.cursors[entity] = cursor; - await this.persistState(state); - - if (records.length < take) { - break; - } - } - - return { - processed, - success, - failed, - lastCursor: cursor, - failures: this.collectFailures(entity, state), - }; - } - - private async loadState(): Promise { - try { - const raw = await fs.readFile(this.statePath, 'utf8'); - const parsed = JSON.parse(raw); - return { - cursors: parsed.cursors ?? {}, - failures: parsed.failures ?? {}, - metadata: parsed.metadata ?? {}, - } satisfies BackfillState; - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err.code === 'ENOENT') { - return { - cursors: {}, - failures: {}, - metadata: {}, - }; - } - throw error; - } - } - - private async persistState(state: BackfillState) { - const serializable = { - cursors: state.cursors, - failures: state.failures, - metadata: state.metadata ?? {}, - }; - await fs.mkdir(path.dirname(this.statePath), { recursive: true }); - await fs.writeFile( - this.statePath, - JSON.stringify(serializable, null, 2), - 'utf8', - ); - } - - private collectFailures( - entity: EntityKey, - state: BackfillState, - ): BackfillFailure[] { - return Object.values(state.failures).filter( - (entry) => entry.entity === entity, - ); - } - - private failureKey(entity: EntityKey, id: string) { - return `${entity}:${id}`; - } - - private serializeError(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - - if (typeof error === 'string') { - return error; - } - - try { - return JSON.stringify(error); - } catch { - return String(error); - } - } } diff --git a/backend/api/src/modules/obat/obat.service.ts b/backend/api/src/modules/obat/obat.service.ts index dd7df5b..6cd48cc 100644 --- a/backend/api/src/modules/obat/obat.service.ts +++ b/backend/api/src/modules/obat/obat.service.ts @@ -176,7 +176,7 @@ export class ObatService { const data = { id: `OBAT_${res.id}`, event: 'obat_created', - user_id: userId, + user_id: userId.toString(), payload: payloadHash, }; const logResult = await this.logService.storeLog(data); diff --git a/backend/api/src/modules/proof/proof.service.ts b/backend/api/src/modules/proof/proof.service.ts index 82cac78..c2a33e8 100644 --- a/backend/api/src/modules/proof/proof.service.ts +++ b/backend/api/src/modules/proof/proof.service.ts @@ -113,7 +113,7 @@ export class ProofService { const response = await this.logService.storeLog({ id: `PROOF_${payload.id_visit}`, event: 'proof_verification_logged', - user_id: 'External', + user_id: '0', // External user payload: payloadHash, }); diff --git a/frontend/hospital-log/src/views/auth/Login.vue b/frontend/hospital-log/src/views/auth/Login.vue index 5d50f13..24880b3 100644 --- a/frontend/hospital-log/src/views/auth/Login.vue +++ b/frontend/hospital-log/src/views/auth/Login.vue @@ -55,7 +55,7 @@ const onSubmit = handleSubmit(async (values: any) => { if (error && Array.isArray(error.message)) { loginError.value = error.message[0]; } else { - loginError.value = "Terjadi kesalahan. Silakan coba lagi."; + loginError.value = error.message || "Terjadi kesalahan saat login."; } } finally { isLoading.value = false;