690605:2335 ADR-035-135 #1
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
// File: src/modules/correspondence/correspondence-workflow.service.spec.ts
|
||||
// Change Log:
|
||||
// - 2026-06-05: สร้าง unit test สำหรับ CorrespondenceWorkflowService เพื่อทดสอบการเรียกใช้ RAG prepare job เมื่อสถานะเปลี่ยนจาก DRAFT (T017)
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CorrespondenceWorkflowService } from './correspondence-workflow.service';
|
||||
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
|
||||
import { Correspondence } from './entities/correspondence.entity';
|
||||
import { CorrespondenceRevision } from './entities/correspondence-revision.entity';
|
||||
import { CorrespondenceStatus } from './entities/correspondence-status.entity';
|
||||
import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AiQueueService } from '../ai/ai-queue.service';
|
||||
|
||||
describe('CorrespondenceWorkflowService', () => {
|
||||
let service: CorrespondenceWorkflowService;
|
||||
let aiQueueService: AiQueueService;
|
||||
const mockWorkflowEngine = {
|
||||
createInstance: jest.fn(),
|
||||
processTransition: jest.fn(),
|
||||
getInstanceById: jest.fn(),
|
||||
};
|
||||
const mockCorrespondenceRepo = {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
const mockRevisionRepo = {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
manager: {
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
},
|
||||
};
|
||||
const mockStatusRepo = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
const mockRecipientRepo = {
|
||||
find: jest.fn(),
|
||||
};
|
||||
const mockDataSource = {
|
||||
createQueryRunner: jest.fn().mockReturnValue({
|
||||
connect: jest.fn(),
|
||||
startTransaction: jest.fn(),
|
||||
commitTransaction: jest.fn(),
|
||||
rollbackTransaction: jest.fn(),
|
||||
release: jest.fn(),
|
||||
manager: mockRevisionRepo.manager,
|
||||
}),
|
||||
};
|
||||
const mockNotificationService = {
|
||||
send: jest.fn(),
|
||||
};
|
||||
const mockUserService = {
|
||||
findDocControlIdByOrg: jest.fn(),
|
||||
};
|
||||
const mockAiQueueService = {
|
||||
enqueueRagPrepare: jest.fn().mockResolvedValue('job-id-123'),
|
||||
};
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CorrespondenceWorkflowService,
|
||||
{ provide: WorkflowEngineService, useValue: mockWorkflowEngine },
|
||||
{
|
||||
provide: getRepositoryToken(Correspondence),
|
||||
useValue: mockCorrespondenceRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(CorrespondenceRevision),
|
||||
useValue: mockRevisionRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(CorrespondenceStatus),
|
||||
useValue: mockStatusRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(CorrespondenceRecipient),
|
||||
useValue: mockRecipientRepo,
|
||||
},
|
||||
{ provide: DataSource, useValue: mockDataSource },
|
||||
{ provide: NotificationService, useValue: mockNotificationService },
|
||||
{ provide: UserService, useValue: mockUserService },
|
||||
{ provide: AiQueueService, useValue: mockAiQueueService },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<CorrespondenceWorkflowService>(
|
||||
CorrespondenceWorkflowService
|
||||
);
|
||||
aiQueueService = module.get<AiQueueService>(AiQueueService);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('syncStatus RAG trigger', () => {
|
||||
it('ควรเรียก enqueueRagPrepare เมื่อสถานะเอกสารถูกเปลี่ยนจาก DRAFT เป็นอย่างอื่น', async () => {
|
||||
const mockStatus = { id: 2, statusCode: 'SUBOWN' };
|
||||
mockStatusRepo.findOne.mockResolvedValueOnce(mockStatus);
|
||||
const mockProject = { id: 10, publicId: 'proj-uuid-123' };
|
||||
const mockCorrespondence = {
|
||||
id: 100,
|
||||
publicId: 'doc-uuid-999',
|
||||
correspondenceNumber: 'CORR-001',
|
||||
projectId: 10,
|
||||
project: mockProject,
|
||||
type: { correspondenceTypeCode: 'LETTER' },
|
||||
};
|
||||
const mockRevision = {
|
||||
id: 50,
|
||||
correspondenceId: 100,
|
||||
revisionNumber: 0,
|
||||
subject: 'Test Subject',
|
||||
documentDate: new Date('2026-06-05'),
|
||||
correspondence: mockCorrespondence,
|
||||
statusId: 1,
|
||||
};
|
||||
mockRevisionRepo.manager.save.mockResolvedValueOnce(mockRevision);
|
||||
mockRevisionRepo.manager.find.mockResolvedValueOnce([
|
||||
{
|
||||
correspondenceRevisionId: 50,
|
||||
attachmentId: 88,
|
||||
isMainDocument: true,
|
||||
attachment: { filePath: '/files/doc.pdf', fileExtension: 'pdf' },
|
||||
},
|
||||
]);
|
||||
await (
|
||||
service as unknown as {
|
||||
syncStatus: (
|
||||
revision: CorrespondenceRevision,
|
||||
workflowState: string
|
||||
) => Promise<void>;
|
||||
}
|
||||
).syncStatus(
|
||||
mockRevision as unknown as CorrespondenceRevision,
|
||||
'IN_REVIEW'
|
||||
);
|
||||
expect(mockRevisionRepo.manager.save).toHaveBeenCalledWith(mockRevision);
|
||||
expect(aiQueueService.enqueueRagPrepare).toHaveBeenCalledWith({
|
||||
documentPublicId: 'doc-uuid-999',
|
||||
projectPublicId: 'proj-uuid-123',
|
||||
correspondenceNumber: 'CORR-001',
|
||||
docType: 'LETTER',
|
||||
statusCode: 'SUBOWN',
|
||||
revisionNumber: 0,
|
||||
subject: 'Test Subject',
|
||||
documentDate: '2026-06-05',
|
||||
attachmentPath: '/files/doc.pdf',
|
||||
});
|
||||
});
|
||||
it('ไม่ควรเรียก enqueueRagPrepare เมื่อเอกสารยังคงอยู่ในสถานะ DRAFT', async () => {
|
||||
const mockStatus = { id: 1, statusCode: 'DRAFT' };
|
||||
mockStatusRepo.findOne.mockResolvedValueOnce(mockStatus);
|
||||
const mockRevision = {
|
||||
id: 50,
|
||||
correspondenceId: 100,
|
||||
revisionNumber: 0,
|
||||
subject: 'Test Subject',
|
||||
statusId: 1,
|
||||
};
|
||||
mockRevisionRepo.manager.save.mockResolvedValueOnce(mockRevision);
|
||||
await (
|
||||
service as unknown as {
|
||||
syncStatus: (
|
||||
revision: CorrespondenceRevision,
|
||||
workflowState: string
|
||||
) => Promise<void>;
|
||||
}
|
||||
).syncStatus(mockRevision as unknown as CorrespondenceRevision, 'DRAFT');
|
||||
expect(mockRevisionRepo.manager.save).toHaveBeenCalledWith(mockRevision);
|
||||
expect(aiQueueService.enqueueRagPrepare).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,11 @@ import { CorrespondenceRevision } from './entities/correspondence-revision.entit
|
||||
import { CorrespondenceStatus } from './entities/correspondence-status.entity';
|
||||
import { Correspondence } from './entities/correspondence.entity';
|
||||
import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity';
|
||||
import { CorrespondenceRevisionAttachment } from './entities/correspondence-revision-attachment.entity';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AiQueueService } from '../ai/ai-queue.service';
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
|
||||
@Injectable()
|
||||
export class CorrespondenceWorkflowService {
|
||||
@@ -30,7 +33,8 @@ export class CorrespondenceWorkflowService {
|
||||
private readonly recipientRepo: Repository<CorrespondenceRecipient>,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly userService: UserService
|
||||
private readonly userService: UserService,
|
||||
private readonly aiQueueService: AiQueueService
|
||||
) {}
|
||||
|
||||
async submitWorkflow(
|
||||
@@ -85,41 +89,67 @@ export class CorrespondenceWorkflowService {
|
||||
{ roles: userRoles } // [FIX] Pass roles for DSL requirements check
|
||||
);
|
||||
|
||||
await this.syncStatus(revision, transitionResult.nextState, queryRunner);
|
||||
await this.syncStatus(
|
||||
revision,
|
||||
transitionResult.nextState,
|
||||
queryRunner,
|
||||
true
|
||||
);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
// After-commit: RAG preparation (fire-and-forget)
|
||||
// ย้ายมาหลัง commit เพื่อป้องกัน job ถูก enqueue แต่ transaction rollback
|
||||
try {
|
||||
if (transitionResult.nextState !== 'DRAFT') {
|
||||
await this.triggerRagPrepare(revision, transitionResult.nextState);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`After-commit RAG preparation failed (non-critical): ${errMsg}`
|
||||
);
|
||||
}
|
||||
|
||||
// Notify TO recipient org users (fire-and-forget)
|
||||
const corrForNotify = revision.correspondence;
|
||||
if (corrForNotify) {
|
||||
void this.recipientRepo
|
||||
.find({
|
||||
where: {
|
||||
correspondenceId: corrForNotify.id,
|
||||
recipientType: 'TO',
|
||||
},
|
||||
})
|
||||
.then(async (recipients) => {
|
||||
for (const r of recipients) {
|
||||
const targetUserId = await this.userService.findDocControlIdByOrg(
|
||||
r.recipientOrganizationId
|
||||
);
|
||||
if (targetUserId) {
|
||||
await this.notificationService.send({
|
||||
userId: targetUserId,
|
||||
title: 'New Correspondence Received',
|
||||
message: `${corrForNotify.correspondenceNumber} has been submitted to your organization.`,
|
||||
type: 'EMAIL',
|
||||
entityType: 'correspondence',
|
||||
entityId: revision.correspondenceId,
|
||||
link: `/correspondences/${corrForNotify.publicId}`,
|
||||
});
|
||||
try {
|
||||
const corrForNotify = revision.correspondence;
|
||||
if (corrForNotify) {
|
||||
void this.recipientRepo
|
||||
.find({
|
||||
where: {
|
||||
correspondenceId: corrForNotify.id,
|
||||
recipientType: 'TO',
|
||||
},
|
||||
})
|
||||
.then(async (recipients) => {
|
||||
for (const r of recipients) {
|
||||
const targetUserId =
|
||||
await this.userService.findDocControlIdByOrg(
|
||||
r.recipientOrganizationId
|
||||
);
|
||||
if (targetUserId) {
|
||||
await this.notificationService.send({
|
||||
userId: targetUserId,
|
||||
title: 'New Correspondence Received',
|
||||
message: `${corrForNotify.correspondenceNumber} has been submitted to your organization.`,
|
||||
type: 'EMAIL',
|
||||
entityType: 'correspondence',
|
||||
entityId: revision.correspondenceId,
|
||||
link: `/correspondences/${corrForNotify.publicId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err: Error) =>
|
||||
this.logger.warn(`Submit notification failed: ${err.message}`)
|
||||
);
|
||||
})
|
||||
.catch((err: Error) =>
|
||||
this.logger.warn(`Submit notification failed: ${err.message}`)
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`After-commit notification setup failed (non-critical): ${errMsg}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -166,7 +196,8 @@ export class CorrespondenceWorkflowService {
|
||||
private async syncStatus(
|
||||
revision: CorrespondenceRevision,
|
||||
workflowState: string,
|
||||
queryRunner?: import('typeorm').QueryRunner
|
||||
queryRunner?: import('typeorm').QueryRunner,
|
||||
skipRagPrepare = false
|
||||
) {
|
||||
const statusMap: Record<string, string> = {
|
||||
DRAFT: 'DRAFT',
|
||||
@@ -174,21 +205,95 @@ export class CorrespondenceWorkflowService {
|
||||
APPROVED: 'CLBOWN',
|
||||
REJECTED: 'CCBOWN',
|
||||
};
|
||||
|
||||
const targetCode = statusMap[workflowState] || 'DRAFT';
|
||||
|
||||
const status = await this.statusRepo.findOne({
|
||||
where: { statusCode: targetCode }, // ✅ FIX: CamelCase
|
||||
where: { statusCode: targetCode },
|
||||
});
|
||||
|
||||
if (status) {
|
||||
// ✅ FIX: CamelCase (correspondenceStatusId)
|
||||
revision.statusId = status.id;
|
||||
|
||||
const manager = queryRunner
|
||||
? queryRunner.manager
|
||||
: this.revisionRepo.manager;
|
||||
await manager.save(revision);
|
||||
}
|
||||
// Await RAG preparation เพื่อให้ unit test assert ได้
|
||||
// caller (submitWorkflow/processAction) ก็ยังคง await syncStatus ตามปกติ
|
||||
if (!skipRagPrepare && workflowState !== 'DRAFT') {
|
||||
await this.triggerRagPrepare(revision, targetCode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* triggerRagPrepare — รวบรวมข้อมูลจาก revision/correspondence แล้ว enqueue rag-prepare job
|
||||
* คืน Promise เพื่อให้ test สามารถ await และ assert ได้ ส่วน production caller ก็ await ผ่าน syncStatus
|
||||
*/
|
||||
private async triggerRagPrepare(
|
||||
revision: CorrespondenceRevision,
|
||||
statusCode: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
let correspondence: Correspondence | null | undefined =
|
||||
revision.correspondence;
|
||||
if (!correspondence) {
|
||||
correspondence = await this.correspondenceRepo.findOne({
|
||||
where: { id: revision.correspondenceId },
|
||||
relations: ['project', 'type'],
|
||||
});
|
||||
}
|
||||
if (!correspondence) {
|
||||
return;
|
||||
}
|
||||
let projectPublicId = '';
|
||||
if (correspondence.project) {
|
||||
projectPublicId = correspondence.project.publicId;
|
||||
} else {
|
||||
const proj = await this.correspondenceRepo.manager.findOne(Project, {
|
||||
where: { id: correspondence.projectId },
|
||||
});
|
||||
if (proj) {
|
||||
projectPublicId = proj.publicId;
|
||||
}
|
||||
}
|
||||
const docType = correspondence.type?.typeCode || 'LETTER';
|
||||
let attachmentPath: string | undefined;
|
||||
const attachments = await this.revisionRepo.manager.find(
|
||||
CorrespondenceRevisionAttachment,
|
||||
{ where: { correspondenceRevisionId: revision.id } }
|
||||
);
|
||||
if (attachments && attachments.length > 0) {
|
||||
const pdfAtt = attachments.find((att) => {
|
||||
const ext =
|
||||
att.attachment?.originalFilename?.split('.').pop()?.toLowerCase() ||
|
||||
'';
|
||||
return (
|
||||
ext === 'pdf' ||
|
||||
att.attachment?.filePath?.toLowerCase().endsWith('.pdf')
|
||||
);
|
||||
});
|
||||
if (pdfAtt && pdfAtt.attachment) {
|
||||
attachmentPath = pdfAtt.attachment.filePath;
|
||||
} else if (attachments[0].attachment) {
|
||||
attachmentPath = attachments[0].attachment.filePath;
|
||||
}
|
||||
}
|
||||
await this.aiQueueService.enqueueRagPrepare({
|
||||
documentPublicId: correspondence.publicId,
|
||||
projectPublicId: projectPublicId,
|
||||
correspondenceNumber: correspondence.correspondenceNumber,
|
||||
docType: docType,
|
||||
statusCode: statusCode,
|
||||
revisionNumber: revision.revisionNumber,
|
||||
subject: revision.subject,
|
||||
documentDate: revision.documentDate
|
||||
? revision.documentDate.toISOString().split('T')[0]
|
||||
: undefined,
|
||||
attachmentPath: attachmentPath,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`Failed to enqueue RAG preparation for revision ${revision.id}: ${errMsg}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { SearchModule } from '../search/search.module';
|
||||
import { FileStorageModule } from '../../common/file-storage/file-storage.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
import { CirculationModule } from '../circulation/circulation.module';
|
||||
import { AiModule } from '../ai/ai.module';
|
||||
|
||||
/**
|
||||
* CorrespondenceModule
|
||||
@@ -53,6 +54,7 @@ import { CirculationModule } from '../circulation/circulation.module';
|
||||
FileStorageModule,
|
||||
NotificationModule,
|
||||
CirculationModule,
|
||||
AiModule,
|
||||
],
|
||||
controllers: [CorrespondenceController],
|
||||
providers: [
|
||||
|
||||
Reference in New Issue
Block a user