690605:2335 ADR-035-135 #1
CI / CD Pipeline / build (push) Successful in 4m54s
CI / CD Pipeline / deploy (push) Successful in 6m19s

This commit is contained in:
2026-06-05 23:35:22 +07:00
parent 285c007dff
commit 26cc71ce60
47 changed files with 2912 additions and 1767 deletions
@@ -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: [