diff --git a/CONTEXT.md b/CONTEXT.md index 1b18b088..68525c58 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -92,16 +92,17 @@ Container สำเร็จรูป (FastAPI Sidecar บน Desk-5439) ทำ _Avoid_: OCR microservice (ที่ขาดการป้องกัน) **Prompt Version**: -Immutable snapshot ของ prompt template ใน `ai_prompts` table — ทุกครั้งที่ admin กด "บันทึก" จะสร้าง version ใหม่ (version*number เพิ่มทีละ 1) version เก่ายังอยู่ใน history ลบได้ยกเว้น active version (ADR-029) -\_Avoid*: Prompt config, Prompt setting, Editable prompt +Immutable snapshot ของ prompt template ใน `ai_prompts` table — ทุกครั้งที่ admin กด "บันทึก" จะสร้าง version ใหม่ (`version_number` เพิ่มทีละ 1) version เก่ายังอยู่ใน history ลบได้ยกเว้น active version (ADR-029) +_Avoid_: Prompt config, Prompt setting, Editable prompt **Active Prompt**: Prompt Version ที่มี `is_active = 1` ต่อ `prompt_type` — ใช้โดยทั้ง OCR Sandbox และ `processMigrateDocument` พร้อมกัน, cached ใน Redis TTL 60s; invalidated เมื่อ admin activate version อื่น (ADR-029) _Avoid_: Production prompt (sandbox และ migrate-document ใช้เดียวกัน) **Prompt Template**: -String ที่มี `{{ocr_text}}` placeholder บังคับ — backend validate ก่อน save; processor แทนที่ด้วย OCR output ก่อนส่งเข้า Ollama (ADR-029) -_Avoid_: Prompt string, Prompt text (ambiguous) +String ที่ใส่ placeholder ตาม `prompt_type` — backend validate ก่อน save; processor แทนที่ด้วยค่าจริงก่อนส่งเข้า Ollama (ADR-029, ADR-037) +Canonical placeholder names per type: `ocr_extraction` → `{{ocr_text}}` (required), `{{master_data_context}}` (optional); `rag_query_prompt` → `{{query}}`, `{{context}}` (both required); `rag_prep_prompt` → `{{text}}` (required); `classification_prompt` → `{{document_text}}` (required) +_Avoid_: Prompt string, Prompt text (ambiguous); ห้ามใช้ชื่อ placeholder อื่น เช่น `{{user_query}}`, `{{retrieved_chunks}}`, `{{document_metadata}}` — ชื่อเหล่านี้ไม่ใช่ canonical **Human-in-the-loop**: ทุก AI suggestion ต้องผ่านการ accept/reject โดย user ก่อนกลายเป็น state change — บันทึกใน `ai_audit_logs` @@ -285,6 +286,11 @@ _Avoid_: Throw exception from tool, Untyped error - **"`temperature/topP/maxTokens` ใครคุม"** — resolved: ใช้ **Profile-Only Parameter Governance**; caller ส่งได้แค่ profile ส่วน runtime parameters จริงให้ backend policy คุมทั้งหมด - **"BGE GPU uplift อยู่ใน scope เดียวกันไหม"** — resolved: ใช้ **Integrated Retrieval Acceleration Policy**; retrieval acceleration เป็นส่วนหนึ่งของ runtime resource policy เดียวกัน - **"ADR-036 system_settings store ใหม่"** — resolved: **ไม่สร้าง** parallel param store ใน `system_settings`; `ai_execution_profiles` คือ setting store เดิมที่ production ดึงค่าอยู่แล้ว (`getProfileParameters()`) — ADR-036 เป็น **enhance** (เติม write/apply path) ไม่ใช่ supersede Profile-Only Parameter Governance +- **"VersionHistory pagination — button-based หรือ infinite scroll"** — resolved: **infinite scroll** ตาม spec FR; ใช้ `IntersectionObserver` + sentinel `
` (ไม่ใช้ external library); แสดง 20 รายการแรก โหลดเพิ่มครั้งละ 20 เมื่อ scroll ถึง sentinel; `visibleCount` รีเซ็ตเมื่อ `versions` prop เปลี่ยน; แสดง counter "แสดง X จาก Y เวอร์ชัน" ขณะยังมีของเหลือ (2026-06-15) +- **"ContextConfigEditor Language enum และ default"** — resolved: enum ที่ถูกต้องคือ `th`/`en`/`mixed` (lowercase ตาม seed data และ code); default = `th` (ไม่ใช่ `MIXED` ตามที่ spec เดิมระบุ); frontend เพิ่ม `mixed` option แล้ว (2026-06-15); backend `@IsString()` ไม่มี enum constraint — accept any string; spec FR-020 + ADR-037 อัปเดตแล้ว +- **"Sandbox Step 3 ใช้ selected version หรือ active version ของ rag_prep_prompt"** — resolved: **ใช้ Active Prompt เสมอ** (`getActive('rag_prep_prompt')`) — RAG Prep เป็น global chunking operation ไม่ผูกกับ version ที่กำลังทดสอบ; Step 2 (ocr_extraction) ใช้ selected version ได้เพราะเป็น version ที่ admin กำลัง evaluate แต่ Step 3 ทดสอบ embedding quality ของ active chunking strategy (ADR-037) +- **"Step 3 RAG Prep sandbox ใช้ BGE-M3 standalone หรือ Sidecar"** — resolved: ใช้ `OcrService.embedViaSidecar()` (OCR Sidecar `/embed` endpoint) ไม่ใช่ BGE-M3 standalone; LLM (`np-dms-ai`) ทำ semantic chunking ผ่าน `rag_prep_prompt` template (`{{text}}`) → parse `` XML tags → embed แต่ละ chunk ผ่าน sidecar; ผลลัพธ์เก็บใน Redis 60 min TTL เท่านั้น **ไม่ commit ลง Qdrant**; queue = `ai-batch` ไม่ใช่ `ai-realtime` (ADR-037, 2026-06-15) +- **"ชื่อ placeholder ใน Prompt Template"** — resolved: ใช้ชื่อตาม code/processor จริงเป็น canonical (`{{query}}`, `{{context}}`, `{{text}}`, `{{document_text}}`) ไม่ใช่ชื่อ spec เดิมที่ semantic กว่า (`{{user_query}}`, `{{retrieved_chunks}}`, `{{document_metadata}}`); `{{master_data_context}}` เป็น optional ใน `ocr_extraction` ไม่ block save; ADR-037 + spec.md FR-023–FR-026 ถูกอัปเดตให้ตรงกับ code แล้ว (2026-06-15) - **"ADR-036 systemPrompt เก็บที่ไหน"** — resolved: systemPrompt อยู่ใน `ai_prompts` (**Active Prompt**, ADR-029, versioned, มี `{{ocr_text}}`) เท่านั้น — ห้ามเก็บใน `ai_execution_profiles` หรือ `system_settings` - **"ADR-036 OCR tunability"** — resolved: OCR tunable params = **`temperature`/`top_p`/`repeat_penalty`** เท่านั้น (ตรงกับ `OcrTyphoonOptions`) เก็บเป็น row `ocr-extract` ใน `ai_execution_profiles` พร้อมเพิ่ม column `canonical_model`; `num_ctx`/`max_tokens` nullable (OCR ไม่ใช้); **`keep_alive` ไม่ tunable** — ใช้ Adaptive OCR Residency (ADR-033) ดู Gap 2 - **"ADR-036 read semantics (Apply to Production)"** — resolved: คง **Snapshot semantics** — params ถูกแช่แข็งลง job payload ณ เวลา dispatch (`createJobPayload()`); ค่าที่ admin apply มีผลกับงานใหม่เท่านั้น ไม่แทรกงานที่ค้างคิว (รักษา reproducibility + audit `snapshot_params_json`) diff --git a/backend/src/modules/ai/prompts/ai-prompts.entity.ts b/backend/src/modules/ai/prompts/ai-prompts.entity.ts index c0991fc8..82422d11 100644 --- a/backend/src/modules/ai/prompts/ai-prompts.entity.ts +++ b/backend/src/modules/ai/prompts/ai-prompts.entity.ts @@ -4,6 +4,7 @@ // - 2026-05-25: Added definite assignment assertion operator (!) to satisfy strictPropertyInitialization // - 2026-05-27: Added publicId column for ADR-019 compliance // - 2026-06-15: Added @VersionColumn for optimistic locking (T066) +// - 2026-06-15: Fixed publicId column name mapping to public_id (snake_case); removed @VersionColumn until schema delta adds version column import { Entity, @@ -24,7 +25,7 @@ export class AiPrompt { @Exclude() // ADR-019: INT PK ไม่ expose ใน API id!: number; - @Column({ type: 'uuid', unique: true }) + @Column({ name: 'public_id', type: 'uuid', unique: true }) publicId!: string; @Column({ name: 'prompt_type', length: 50 }) diff --git a/backend/src/modules/correspondence/correspondence.service.spec.ts b/backend/src/modules/correspondence/correspondence.service.spec.ts index 7f4c64b0..3be82714 100644 --- a/backend/src/modules/correspondence/correspondence.service.spec.ts +++ b/backend/src/modules/correspondence/correspondence.service.spec.ts @@ -54,6 +54,15 @@ describe('CorrespondenceService', () => { })), }); + const mockManager = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + getRepository: jest.fn(() => createMockRepository()), + }; + const mockDataSource = { createQueryRunner: jest.fn(() => ({ connect: jest.fn(), @@ -61,11 +70,7 @@ describe('CorrespondenceService', () => { commitTransaction: jest.fn(), rollbackTransaction: jest.fn(), release: jest.fn(), - manager: { - create: jest.fn(), - save: jest.fn(), - findOne: jest.fn(), - }, + manager: mockManager, })), getRepository: jest.fn(() => createMockRepository()), manager: { @@ -123,7 +128,10 @@ describe('CorrespondenceService', () => { }, { provide: WorkflowEngineService, - useValue: { createInstance: jest.fn() }, + useValue: { + createInstance: jest.fn(), + getInstanceByEntity: jest.fn().mockResolvedValue(null), + }, }, { provide: UserService, @@ -138,7 +146,7 @@ describe('CorrespondenceService', () => { }, { provide: SearchService, - useValue: { indexDocument: jest.fn() }, + useValue: { indexDocument: jest.fn().mockResolvedValue(undefined) }, }, { provide: FileStorageService, diff --git a/backend/src/modules/correspondence/correspondence.service.ts b/backend/src/modules/correspondence/correspondence.service.ts index bf1550f8..2ed4c103 100644 --- a/backend/src/modules/correspondence/correspondence.service.ts +++ b/backend/src/modules/correspondence/correspondence.service.ts @@ -1,4 +1,9 @@ // File: src/modules/correspondence/correspondence.service.ts +// Change Log: +// 2026-06-17 | Refactor: Extract UUID resolution helpers; wrap update() in transaction; +// fix fire-and-forget with .catch(); fix cancel notification status (REJECTED→PENDING); +// add Partial types; add workflow fields to findOne(); cache permission check; +// extract type code constants; fix exportCsv type safety import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common'; import { @@ -55,7 +60,7 @@ export class CorrespondenceService { private readonly logger = new Logger(CorrespondenceService.name); private async hasSystemManageAllPermission(userId: number): Promise { - const permissions = await this.userService.getUserPermissions(userId); + const permissions = await this.getCachedPermissions(userId); return permissions.includes('system.manage_all'); } @@ -65,11 +70,53 @@ export class CorrespondenceService { * - Other types (LETTER, MEMO, etc.): Use numeric (null for first, then 1, 2, 3...) */ private getInitialRevisionLabel(typeCode: string): string | undefined { - const alphabetTypes = ['RFA', 'RFI']; - if (alphabetTypes.includes(typeCode.toUpperCase())) { - return 'A'; // Alphabet for RFA, RFI + if ( + CorrespondenceService.ALPHABET_REVISION_TYPES.has(typeCode.toUpperCase()) + ) { + return 'A'; } - return undefined; // Numeric (no label for revision 0) + return undefined; + } + + // ประเภทเอกสารที่ใช้ alphabet revision label (แทน hardcode ใน method) + private static readonly ALPHABET_REVISION_TYPES = new Set(['RFA', 'RFI']); + + // In-memory cache สำหรับ permission check (clear เมื่อมีการเปลี่ยนแปลง permission) + private readonly permissionCache = new Map< + number, + { permissions: string[]; timestamp: number } + >(); + private static readonly PERMISSION_CACHE_TTL = 30_000; // 30 seconds + + private async getCachedPermissions(userId: number): Promise { + const cached = this.permissionCache.get(userId); + if ( + cached && + Date.now() - cached.timestamp < CorrespondenceService.PERMISSION_CACHE_TTL + ) { + return cached.permissions; + } + const permissions = await this.userService.getUserPermissions(userId); + this.permissionCache.set(userId, { permissions, timestamp: Date.now() }); + return permissions; + } + + private invalidatePermissionCache(userId: number): void { + this.permissionCache.delete(userId); + } + + // Extract UUID resolution helpers เพื่อลด duplicate code + private async resolveRecipients( + recipients: Array<{ organizationId: number | string; type: 'TO' | 'CC' }> + ): Promise { + return Promise.all( + recipients.map(async (r) => ({ + organizationId: await this.uuidResolver.resolveOrganizationId( + r.organizationId + ), + type: r.type, + })) + ); } constructor( @@ -196,16 +243,7 @@ export class CorrespondenceService { ? await this.uuidResolver.resolveOrganizationId(createDto.originatorId) : undefined; const resolvedRecipients = createDto.recipients - ? await Promise.all( - createDto.recipients.map( - async (r): Promise => ({ - organizationId: await this.uuidResolver.resolveOrganizationId( - r.organizationId - ), - type: r.type, - }) - ) - ) + ? await this.resolveRecipients(createDto.recipients) : undefined; const type = await this.typeRepo.findOne({ where: { id: createDto.typeId }, @@ -381,8 +419,8 @@ export class CorrespondenceService { await queryRunner.commitTransaction(); - // Start Workflow Instance (non-blocking) - // All correspondence types use CORRESPONDENCE_FLOW_V1 (type code is NOT a separate workflow) + // Start Workflow Instance (non-blocking — transaction already committed) + // All correspondence types use CORRESPONDENCE_FLOW_V1 try { let corrContractId: number | null = null; if (createDto.disciplineId) { @@ -406,23 +444,29 @@ export class CorrespondenceService { } as Record ); } catch (error: unknown) { - this.logger.warn( - `Workflow not started for ${docNumber.number}: ${(error as Error).message}` + this.logger.error( + `Workflow failed to start for ${docNumber.number}: ${(error as Error).message}` ); } - // Fire-and-forget search indexing (non-blocking, void intentional) - void this.searchService.indexDocument({ - id: savedCorr.id, - publicId: savedCorr.publicId, - type: 'correspondence', - docNumber: docNumber.number, - title: createDto.subject, - description: createDto.description, - status: 'DRAFT', - projectId: resolvedProjectId, - createdAt: new Date(), - }); + // Fire-and-forget search indexing (non-blocking) + Promise.resolve( + this.searchService.indexDocument({ + id: savedCorr.id, + publicId: savedCorr.publicId, + type: 'correspondence', + docNumber: docNumber.number, + title: createDto.subject, + description: createDto.description, + status: 'DRAFT', + projectId: resolvedProjectId, + createdAt: new Date(), + }) + ).catch((err: Error) => + this.logger.error( + `Search indexing failed for ${docNumber.number}: ${err.message}` + ) + ); return { ...savedCorr, @@ -519,7 +563,7 @@ export class CorrespondenceService { 'project', 'originator', 'recipients', - 'recipients.recipientOrganization', // [v1.5.1] Fixed relation name + 'recipients.recipientOrganization', 'discipline', 'discipline.contract', ], @@ -528,7 +572,19 @@ export class CorrespondenceService { if (!correspondence) { throw new NotFoundException('Correspondence', String(id)); } - return correspondence; + + // ADR-021: expose live workflow state for consistency with findOneByUuid + const workflowInstance = await this.workflowEngine.getInstanceByEntity( + 'correspondence', + correspondence.publicId + ); + + return { + ...correspondence, + workflowInstanceId: workflowInstance?.id ?? null, + workflowState: workflowInstance?.currentState ?? null, + availableActions: workflowInstance?.availableActions ?? [], + }; } async findOneByUuid(publicId: string) { @@ -694,9 +750,7 @@ export class CorrespondenceService { }); if (status && status.statusCode !== 'DRAFT') { - const permissions = await this.userService.getUserPermissions( - user.user_id - ); + const permissions = await this.getCachedPermissions(user.user_id); const canEditSubmittedOrLater = permissions.includes('correspondence.cancel') || permissions.includes('system.manage_all'); @@ -715,16 +769,7 @@ export class CorrespondenceService { ? await this.uuidResolver.resolveOrganizationId(updateDto.originatorId) : undefined; const updResolvedRecipients = updateDto.recipients - ? await Promise.all( - updateDto.recipients.map( - async (r): Promise => ({ - organizationId: await this.uuidResolver.resolveOrganizationId( - r.organizationId - ), - type: r.type, - }) - ) - ) + ? await this.resolveRecipients(updateDto.recipients) : undefined; // 3. Check if number regeneration is needed (only for DRAFT status) @@ -748,19 +793,16 @@ export class CorrespondenceService { let newNumber: string | undefined; if (needsNumberRegen) { - // Check if current status is DRAFT - only regenerate for drafts const currentStatus = await this.statusRepo.findOne({ where: { id: revision.statusId }, }); if (currentStatus?.statusCode === 'DRAFT') { - // Resolve originator for number generation const originatorId = updResolvedOriginatorId || oldCorr.originatorId || user.primaryOrganizationId; - // Get type info for number generation const typeId = updateDto.typeId || oldCorr.correspondenceTypeId; const type = await this.typeRepo.findOne({ where: { id: typeId } }); @@ -768,7 +810,6 @@ export class CorrespondenceService { throw new NotFoundException('Document Type', String(typeId)); } - // Get recipient org code for number generation const recipientOrgId = newRecipientOrgId || oldRecipientOrgId; let _recipientCode = ''; if (recipientOrgId) { @@ -804,108 +845,136 @@ export class CorrespondenceService { } } - // 4. Update Correspondence Entity if needed - const correspondenceUpdate: Record = {}; - if (newNumber) correspondenceUpdate.correspondenceNumber = newNumber; - if (updateDto.disciplineId) - correspondenceUpdate.disciplineId = updateDto.disciplineId; - if (updResolvedProjectId) - correspondenceUpdate.projectId = updResolvedProjectId; - if (updResolvedOriginatorId) - correspondenceUpdate.originatorId = updResolvedOriginatorId; - if (updateDto.typeId) - correspondenceUpdate.correspondenceTypeId = updateDto.typeId; + // 4. Wrap all mutations in a transaction + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); - if (Object.keys(correspondenceUpdate).length > 0) { - await this.correspondenceRepo.update(id, correspondenceUpdate); - } + try { + // 4a. Update Correspondence Entity if needed + const correspondenceUpdate: Partial = {}; + if (newNumber) correspondenceUpdate.correspondenceNumber = newNumber; + if (updateDto.disciplineId) + correspondenceUpdate.disciplineId = updateDto.disciplineId; + if (updResolvedProjectId) + correspondenceUpdate.projectId = updResolvedProjectId; + if (updResolvedOriginatorId) + correspondenceUpdate.originatorId = updResolvedOriginatorId; + if (updateDto.typeId) + correspondenceUpdate.correspondenceTypeId = updateDto.typeId; - // 4. Update Revision Entity - const revisionUpdate: Record = {}; - if (updateDto.subject) revisionUpdate.subject = updateDto.subject; - if (updateDto.body) revisionUpdate.body = updateDto.body; - if (updateDto.remarks) revisionUpdate.remarks = updateDto.remarks; - // Format Date correctly if string - if (updateDto.dueDate) revisionUpdate.dueDate = new Date(updateDto.dueDate); - if (updateDto.documentDate) - revisionUpdate.documentDate = new Date(updateDto.documentDate); - if (updateDto.issuedDate) - revisionUpdate.issuedDate = new Date(updateDto.issuedDate); - if (updateDto.receivedDate) - revisionUpdate.receivedDate = new Date(updateDto.receivedDate); - if (updateDto.description) - revisionUpdate.description = updateDto.description; - if (updateDto.details) revisionUpdate.details = updateDto.details; + if (Object.keys(correspondenceUpdate).length > 0) { + await queryRunner.manager + .getRepository(Correspondence) + .update(id, correspondenceUpdate); + } - if (Object.keys(revisionUpdate).length > 0) { - await this.revisionRepo.update(revision.id, revisionUpdate); - } + // 4b. Update Revision Entity + const revisionUpdate: Partial = {}; + if (updateDto.subject) revisionUpdate.subject = updateDto.subject; + if (updateDto.body) revisionUpdate.body = updateDto.body; + if (updateDto.remarks) revisionUpdate.remarks = updateDto.remarks; + if (updateDto.dueDate) + revisionUpdate.dueDate = new Date(updateDto.dueDate); + if (updateDto.documentDate) + revisionUpdate.documentDate = new Date(updateDto.documentDate); + if (updateDto.issuedDate) + revisionUpdate.issuedDate = new Date(updateDto.issuedDate); + if (updateDto.receivedDate) + revisionUpdate.receivedDate = new Date(updateDto.receivedDate); + if (updateDto.description) + revisionUpdate.description = updateDto.description; + if (updateDto.details) revisionUpdate.details = updateDto.details; - // 4.5 Commit new attachments from Temp → Permanent (Two-Phase Storage) - if (updateDto.attachmentTempIds?.length) { - const issueDate = updateDto.issuedDate - ? new Date(updateDto.issuedDate) - : updateDto.documentDate - ? new Date(updateDto.documentDate) - : revision.issuedDate || revision.documentDate || undefined; + if (Object.keys(revisionUpdate).length > 0) { + await queryRunner.manager + .getRepository(CorrespondenceRevision) + .update(revision.id, revisionUpdate); + } - // [FIX v1.8.1] commit ได้ Attachment records กลับมา → บันทึก junction - const committed = await this.fileStorageService.commit( - updateDto.attachmentTempIds, - { - issueDate: issueDate ? new Date(issueDate) : undefined, - documentType: 'Correspondence', + // 4c. Commit new attachments from Temp → Permanent + if (updateDto.attachmentTempIds?.length) { + const issueDate = updateDto.issuedDate + ? new Date(updateDto.issuedDate) + : updateDto.documentDate + ? new Date(updateDto.documentDate) + : revision.issuedDate || revision.documentDate || undefined; + + const committed = await this.fileStorageService.commit( + updateDto.attachmentTempIds, + { + issueDate: issueDate ? new Date(issueDate) : undefined, + documentType: 'Correspondence', + } + ); + + if (committed.length > 0) { + const links = committed.map((att) => + queryRunner.manager.create(CorrespondenceRevisionAttachment, { + correspondenceRevisionId: revision.id, + attachmentId: att.id, + isMainDocument: false, + }) + ); + await queryRunner.manager.save( + CorrespondenceRevisionAttachment, + links + ); } - ); + } - if (committed.length > 0) { - const links = committed.map((att) => - this.revAttachRepo.create({ - correspondenceRevisionId: revision.id, - attachmentId: att.id, - isMainDocument: false, // ไฟล์ที่ upload เพิ่มเติมไม่ใช่ main + // 4d. Update Recipients if provided + if (updResolvedRecipients) { + await queryRunner.manager + .getRepository(CorrespondenceRecipient) + .delete({ correspondenceId: id }); + + const newRecipients = updResolvedRecipients.map((r) => + queryRunner.manager.create(CorrespondenceRecipient, { + correspondenceId: id, + recipientOrganizationId: r.organizationId, + recipientType: r.type, }) ); - await this.revAttachRepo.save(links); + await queryRunner.manager.save(CorrespondenceRecipient, newRecipients); } - } - // 5. Update Recipients if provided - if (updResolvedRecipients) { - const recipientRepo = this.dataSource.getRepository( - CorrespondenceRecipient + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + this.logger.error( + `Failed to update correspondence ${id}: ${(err as Error).message}` ); - await recipientRepo.delete({ correspondenceId: id }); - - const newRecipients = updResolvedRecipients.map((r) => - recipientRepo.create({ - correspondenceId: id, - recipientOrganizationId: r.organizationId, - recipientType: r.type, - }) - ); - await recipientRepo.save(newRecipients); + throw err; + } finally { + await queryRunner.release(); } const updated = await this.findOne(id); - // Re-index updated document in Elasticsearch (fire-and-forget) - // ใช้ status จริงจาก current revision แทนการ hardcode 'DRAFT' + // Re-index updated document (fire-and-forget) const currentRevisionStatus = updated.revisions?.find((r) => r.isCurrent)?.status?.statusCode ?? updated.revisions?.[0]?.status?.statusCode ?? 'DRAFT'; - void this.searchService.indexDocument({ - id: updated.id, - publicId: updated.publicId, - type: 'correspondence', - docNumber: updated.correspondenceNumber, - title: updateDto.subject ?? updated.revisions?.[0]?.subject, - description: updateDto.description ?? updated.revisions?.[0]?.description, - status: currentRevisionStatus, - projectId: updated.projectId, - createdAt: updated.createdAt, - }); + Promise.resolve( + this.searchService.indexDocument({ + id: updated.id, + publicId: updated.publicId, + type: 'correspondence', + docNumber: updated.correspondenceNumber, + title: updateDto.subject ?? updated.revisions?.[0]?.subject, + description: + updateDto.description ?? updated.revisions?.[0]?.description, + status: currentRevisionStatus, + projectId: updated.projectId, + createdAt: updated.createdAt, + }) + ).catch((err: Error) => + this.logger.error( + `Search re-index failed for correspondence ${id}: ${err.message}` + ) + ); return updated; } @@ -919,16 +988,7 @@ export class CorrespondenceService { ? await this.uuidResolver.resolveOrganizationId(createDto.originatorId) : undefined; const previewRecipients = createDto.recipients - ? await Promise.all( - createDto.recipients.map( - async (r): Promise => ({ - organizationId: await this.uuidResolver.resolveOrganizationId( - r.organizationId - ), - type: r.type, - }) - ) - ) + ? await this.resolveRecipients(createDto.recipients) : undefined; const type = await this.typeRepo.findOne({ @@ -1056,24 +1116,29 @@ export class CorrespondenceService { ); // T012: Enqueue BullMQ notification for affected assignees - // CirculationService.forceClose already updates status, we just need to notify. - // Ideally we'd notify the people who were pending. - const circWithRoutings = await this.dataSource + // แจ้งเฉพาะ users ที่ยัง pending/open (ยังไม่ได้ตอบ) ว่า circulation ถูกปิดแบบบังคับ + const pendingRoutings = await this.dataSource .getRepository(CirculationRouting) .find({ - where: { circulationId: circ.id, status: 'REJECTED' }, + where: { circulationId: circ.id, status: 'PENDING' }, }); - for (const r of circWithRoutings) { + for (const r of pendingRoutings) { if (r.assignedTo) { - void this.notificationService.send({ - userId: r.assignedTo, - title: 'Circulation Force Closed', - message: `ใบเวียน ${circ.circulationNo} ถูกปิดแบบบังคับ เนื่องจากเอกสารต้นทางถูกยกเลิก`, - type: 'EMAIL', - entityType: 'circulation', - entityId: circ.id, - link: `/circulations/${circ.publicId}`, - }); + Promise.resolve( + this.notificationService.send({ + userId: r.assignedTo, + title: 'Circulation Force Closed', + message: `ใบเวียน ${circ.circulationNo} ถูกปิดแบบบังคับ เนื่องจากเอกสารต้นทางถูกยกเลิก`, + type: 'EMAIL', + entityType: 'circulation', + entityId: circ.id, + link: `/circulations/${circ.publicId}`, + }) + ).catch((err: Error) => + this.logger.error( + `Cancel notification failed for routing: ${err.message}` + ) + ); } } } catch (e) { @@ -1085,16 +1150,20 @@ export class CorrespondenceService { } // Re-index cancelled status in Elasticsearch (fire-and-forget) - void this.searchService.indexDocument({ - id: correspondence.id, - publicId: correspondence.publicId, - type: 'correspondence', - docNumber: correspondence.correspondenceNumber, - title: currentRevision.subject, - status: 'CANCELLED', - projectId: correspondence.projectId, - createdAt: correspondence.createdAt, - }); + Promise.resolve( + this.searchService.indexDocument({ + id: correspondence.id, + publicId: correspondence.publicId, + type: 'correspondence', + docNumber: correspondence.correspondenceNumber, + title: currentRevision.subject, + status: 'CANCELLED', + projectId: correspondence.projectId, + createdAt: correspondence.createdAt, + }) + ).catch((err: Error) => + this.logger.error(`Search re-index failed after cancel: ${err.message}`) + ); // Notify originator's doc-control user about cancellation (fire-and-forget) if (correspondence.originatorId) { @@ -1102,15 +1171,21 @@ export class CorrespondenceService { .findDocControlIdByOrg(correspondence.originatorId) .then((targetUserId) => { if (targetUserId) { - void this.notificationService.send({ - userId: targetUserId, - title: 'Correspondence Cancelled', - message: `${correspondence.correspondenceNumber} — ${currentRevision.subject} has been cancelled. Reason: ${reason}`, - type: 'EMAIL', - entityType: 'correspondence', - entityId: correspondence.id, - link: `/correspondences/${correspondence.publicId}`, - }); + void this.notificationService + .send({ + userId: targetUserId, + title: 'Correspondence Cancelled', + message: `${correspondence.correspondenceNumber} — ${currentRevision.subject} has been cancelled. Reason: ${reason}`, + type: 'EMAIL', + entityType: 'correspondence', + entityId: correspondence.id, + link: `/correspondences/${correspondence.publicId}`, + }) + .catch((err: Error) => + this.logger.error( + `Cancel notification send failed: ${err.message}` + ) + ); } }) .catch((err: Error) => @@ -1158,12 +1233,22 @@ export class CorrespondenceService { } async exportCsv(searchDto: SearchCorrespondenceDto): Promise { - // ดึงทุกแถวที่ตรงเงื่อนไข — ไม่ใช้ pagination สำหรับ export - const { data } = await this.findAll({ - ...searchDto, - page: 1, - limit: 10000, - }); + // ดึงทุกแถวที่ตรงเงื่อนไข — ใช้ paginated query แทน hardcode limit + const pageSize = 1000; + let page = 1; + let allData: CorrespondenceRevision[] = []; + let hasMore = true; + + while (hasMore) { + const { data, meta } = await this.findAll({ + ...searchDto, + page, + limit: pageSize, + }); + allData = allData.concat(data); + hasMore = page < meta.totalPages; + page++; + } const header = [ 'Document No.', @@ -1176,16 +1261,17 @@ export class CorrespondenceService { 'Due Date', 'Created At', ]; - const rows = data.map((rev) => { - const corr = rev.correspondence ?? (rev as unknown as Correspondence); + const rows = allData.map((rev) => { + // TypeORM loads relation via leftJoinAndSelect — safely access via correspondence relation + const corr = rev.correspondence; return [ - this.escapeCsv(corr.correspondenceNumber ?? ''), + this.escapeCsv(corr?.correspondenceNumber ?? ''), this.escapeCsv(rev.revisionLabel ?? String(rev.revisionNumber ?? 0)), this.escapeCsv(rev.subject ?? ''), - this.escapeCsv(corr.type?.typeCode ?? ''), + this.escapeCsv(corr?.type?.typeCode ?? ''), this.escapeCsv(rev.status?.statusCode ?? ''), - this.escapeCsv(corr.project?.projectCode ?? ''), - this.escapeCsv(corr.originator?.organizationCode ?? ''), + this.escapeCsv(corr?.project?.projectCode ?? ''), + this.escapeCsv(corr?.originator?.organizationCode ?? ''), rev.dueDate ? new Date(rev.dueDate).toISOString().split('T')[0] : '', new Date(rev.createdAt).toISOString().split('T')[0], ].join(','); diff --git a/frontend/app/(admin)/admin/ai/prompt-management/page.tsx b/frontend/app/(admin)/admin/ai/prompt-management/page.tsx index 5660680f..2ff83cfd 100644 --- a/frontend/app/(admin)/admin/ai/prompt-management/page.tsx +++ b/frontend/app/(admin)/admin/ai/prompt-management/page.tsx @@ -244,6 +244,7 @@ export default function UnifiedPromptManagementPage() { activateMutation.mutate(v)} /> diff --git a/frontend/components/admin/ai/ContextConfigEditor.tsx b/frontend/components/admin/ai/ContextConfigEditor.tsx index 46984b6a..cb967e3d 100644 --- a/frontend/components/admin/ai/ContextConfigEditor.tsx +++ b/frontend/components/admin/ai/ContextConfigEditor.tsx @@ -249,6 +249,7 @@ export default function ContextConfigEditor({ ไทย (TH) English (EN) + ไทย + อังกฤษ (MIXED) {errors.language && ( @@ -270,6 +271,7 @@ export default function ContextConfigEditor({ ไทย (TH) English (EN) + ไทย + อังกฤษ (MIXED) {errors.outputLanguage && ( diff --git a/frontend/components/admin/ai/SandboxTabs.tsx b/frontend/components/admin/ai/SandboxTabs.tsx index 742f6dbd..a8dfb1de 100644 --- a/frontend/components/admin/ai/SandboxTabs.tsx +++ b/frontend/components/admin/ai/SandboxTabs.tsx @@ -1,6 +1,8 @@ // File: frontend/components/admin/ai/SandboxTabs.tsx // Change Log: // - 2026-06-14: Created SandboxTabs component with 3-step testing (OCR -> AI Extract -> RAG Prep) (conforming to task T037) +// - 2026-06-15: ลบ Tesseract ออกจาก OCR Engine dropdown — canonical engines: auto + np-dms-ocr เท่านั้น (ADR-034) +// - 2026-06-15: เพิ่ม read-only prompt info banner แสดง version + template snippet ที่กำลังทดสอบ import React, { useState } from 'react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; @@ -26,6 +28,7 @@ import { interface SandboxTabsProps { promptType: string; selectedVersionNumber?: number; + selectedTemplate?: string; onActivateVersion?: (versionNumber: number) => void; } @@ -53,6 +56,7 @@ interface SandboxJobResult { export default function SandboxTabs({ promptType: _promptType, selectedVersionNumber, + selectedTemplate, onActivateVersion, }: SandboxTabsProps) { // Master data state @@ -215,6 +219,26 @@ export default function SandboxTabs({ + {/* Prompt info banner — read-only, แสดง version + template snippet ที่กำลังทดสอบ */} +
+
+ + พรอมต์ที่ใช้ทดสอบ (Prompt Under Test) + + {selectedVersionNumber ? ( + v{selectedVersionNumber} + ) : ( + ยังไม่ได้เลือกเวอร์ชัน — จะใช้เวอร์ชัน Active + )} +
+ {selectedTemplate ? ( +

+ {selectedTemplate.slice(0, 300)}{selectedTemplate.length > 300 ? '…' : ''} +

+ ) : ( +

โหลดเวอร์ชันจาก Version History เพื่อดู template

+ )} +
@@ -253,9 +277,8 @@ export default function SandboxTabs({ - Auto (Baseline) - Tesseract (CPU) - Typhoon OCR (GPU) + Auto (np-dms-ocr อัตโนมัติ) + np-dms-ocr (Force GPU)
diff --git a/frontend/components/admin/ai/VersionHistory.tsx b/frontend/components/admin/ai/VersionHistory.tsx index 3ca491c6..e41a15d9 100644 --- a/frontend/components/admin/ai/VersionHistory.tsx +++ b/frontend/components/admin/ai/VersionHistory.tsx @@ -3,13 +3,14 @@ // - 2026-06-14: Created VersionHistory component with type filtering and nice badges (conforming to task T017) // - 2026-06-15: Added All Types view grouped by prompt type (T065) // - 2026-06-15: Added pagination (20 versions/page) (T075) +// - 2026-06-15: เปลี่ยน button pagination เป็น infinite scroll ตาม spec FR (T075) -import React, { useState } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { CheckCircle2, Trash2, BookOpen, Clock, StickyNote, Folder, ChevronLeft, ChevronRight } from 'lucide-react'; +import { CheckCircle2, Trash2, BookOpen, Clock, StickyNote, Folder } from 'lucide-react'; +import { Button } from '@/components/ui/button'; import { PromptVersion } from '@/lib/types/ai-prompts'; import { cn } from '@/lib/utils'; @@ -40,17 +41,9 @@ export default function VersionHistory({ showAllTypes = false, }: VersionHistoryProps) { const { t } = useTranslation('ai'); - const [currentPage, setCurrentPage] = useState(1); - const PAGE_SIZE = 20; // T075: 20 versions per page - - if (isLoading) { - return ( -
- - {t('prompt_management.version_history')}... -
- ); - } + const PAGE_SIZE = 20; + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + const sentinelRef = useRef(null); // Group versions by prompt type when showing all types const groupedVersions = showAllTypes @@ -74,19 +67,40 @@ export default function VersionHistory({ return labels[type] || type; }; - // Pagination logic (T075) - const totalPages = Math.ceil(versions.length / PAGE_SIZE); - const startIndex = (currentPage - 1) * PAGE_SIZE; - const endIndex = startIndex + PAGE_SIZE; - const paginatedVersions = versions.slice(startIndex, endIndex); + // รีเซ็ต visible count เมื่อ versions เปลี่ยน (เช่น เปลี่ยน prompt type) + useEffect(() => { + setVisibleCount(PAGE_SIZE); + }, [versions, PAGE_SIZE]); - const handlePreviousPage = () => { - setCurrentPage((prev) => Math.max(1, prev - 1)); - }; + // Infinite scroll ด้วย IntersectionObserver + const handleObserver = useCallback( + (entries: IntersectionObserverEntry[]) => { + const target = entries[0]; + if (target.isIntersecting && visibleCount < versions.length) { + setVisibleCount((prev) => Math.min(prev + PAGE_SIZE, versions.length)); + } + }, + [visibleCount, versions.length, PAGE_SIZE] + ); - const handleNextPage = () => { - setCurrentPage((prev) => Math.min(totalPages, prev + 1)); - }; + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel) return; + const observer = new IntersectionObserver(handleObserver, { threshold: 0.1 }); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [handleObserver]); + + const visibleVersions = versions.slice(0, visibleCount); + + if (isLoading) { + return ( +
+ + {t('prompt_management.version_history')}... +
+ ); + } return ( @@ -102,16 +116,16 @@ export default function VersionHistory({ {t('prompt_management.no_versions')}
) : showAllTypes && groupedVersions ? ( - // Grouped view by prompt type (with pagination applied to each group) + // Grouped view by prompt type — infinite scroll applies across all groups Object.entries(groupedVersions).map(([promptType, typeVersions]) => { - const paginatedGroupVersions = typeVersions.slice(startIndex, endIndex); + const visibleGroupVersions = typeVersions.slice(0, visibleCount); return (
{getPromptTypeLabel(promptType)}
- {paginatedGroupVersions.map((version) => { + {visibleGroupVersions.map((version) => { const isActive = version.isActive === true; return (
{ + // Single type view — infinite scroll (T075) + visibleVersions.map((version) => { const isActive = version.isActive === true; return (
1 && ( -
-
- หน้า {currentPage} จาก {totalPages} ({versions.length} เวอร์ชัน) -
-
- - -
+ {/* Sentinel สำหรับ infinite scroll — IntersectionObserver จะโหลดเพิ่มเมื่อ scroll ถึง */} +
+ {visibleCount < versions.length && ( +
+ แสดง {visibleCount} จาก {versions.length} เวอร์ชัน
)} diff --git a/frontend/components/admin/ai/__tests__/ContextConfigEditor.test.tsx b/frontend/components/admin/ai/__tests__/ContextConfigEditor.test.tsx new file mode 100644 index 00000000..3f4bf9dd --- /dev/null +++ b/frontend/components/admin/ai/__tests__/ContextConfigEditor.test.tsx @@ -0,0 +1,112 @@ +// File: frontend/components/admin/ai/__tests__/ContextConfigEditor.test.tsx +// Change Log: +// - 2026-06-15: Created test file for ContextConfigEditor + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import ContextConfigEditor from '../ContextConfigEditor'; +import { projectService } from '@/lib/services/project.service'; +import { contractService } from '@/lib/services/contract.service'; + +// Mock the external services +vi.mock('@/lib/services/project.service', () => ({ + projectService: { + getAll: vi.fn(), + }, +})); + +vi.mock('@/lib/services/contract.service', () => ({ + contractService: { + getAll: vi.fn(), + }, +})); + +// Mock i18n +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const mockProjects = [ + { publicId: 'proj-1', projectName: 'Project 1' }, + { publicId: 'proj-2', projectName: 'Project 2' }, +]; + +const mockContracts = [ + { publicId: 'contract-1', contractName: 'Contract 1', project: { publicId: 'proj-1' } }, + { publicId: 'contract-2', contractName: 'Contract 2', project: { publicId: 'proj-1' } }, + { publicId: 'contract-3', contractName: 'Contract 3', project: { publicId: 'proj-2' } }, +]; + +describe('ContextConfigEditor', () => { + const mockOnSave = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + (projectService.getAll as any).mockResolvedValue(mockProjects); + (contractService.getAll as any).mockResolvedValue(mockContracts); + }); + + it('renders correctly with default values when initialConfig is null', async () => { + render(); + + // Wait for services to load + await waitFor(() => { + expect(projectService.getAll).toHaveBeenCalled(); + expect(contractService.getAll).toHaveBeenCalled(); + }); + + expect(screen.getByText('การตั้งค่าบริบทข้อมูล (Context Configuration)')).toBeInTheDocument(); + + // Check default input value + const pageSizeInput = screen.getByRole('spinbutton'); + expect(pageSizeInput).toHaveValue(3); + }); + + it('calls onSave with valid data', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(projectService.getAll).toHaveBeenCalled(); + }); + + const saveButton = screen.getByRole('button', { name: /บันทึกบริบท/i }); + await user.click(saveButton); + + expect(mockOnSave).toHaveBeenCalledWith({ + filter: { + projectId: null, + contractId: null, + }, + pageSize: 3, + language: 'th', + outputLanguage: 'th', + }); + }); + + it('validates pageSize input', async () => { + const user = userEvent.setup(); + render(); + + const pageSizeInput = screen.getByRole('spinbutton'); + + // Set invalid value + await user.clear(pageSizeInput); + await user.type(pageSizeInput, '2000'); + + const saveButton = screen.getByRole('button', { name: /บันทึกบริบท/i }); + await user.click(saveButton); + + expect(mockOnSave).not.toHaveBeenCalled(); + expect(screen.getByText('prompt_management.pageSize_invalid')).toBeInTheDocument(); + }); + + it('displays saving state', () => { + render(); + expect(screen.getByRole('button', { name: /กำลังบันทึก/i })).toBeDisabled(); + }); +}); diff --git a/frontend/components/admin/ai/__tests__/OcrEngineSelector.test.tsx b/frontend/components/admin/ai/__tests__/OcrEngineSelector.test.tsx new file mode 100644 index 00000000..e9dc10ee --- /dev/null +++ b/frontend/components/admin/ai/__tests__/OcrEngineSelector.test.tsx @@ -0,0 +1,131 @@ +// File: frontend/components/admin/ai/__tests__/OcrEngineSelector.test.tsx +// Change Log: +// - 2026-06-15: Created test file for OcrEngineSelector + +import React from 'react'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import OcrEngineSelector from '../OcrEngineSelector'; +import { adminAiService } from '@/lib/services/admin-ai.service'; +import { toast } from 'sonner'; + +// Mock the services +vi.mock('@/lib/services/admin-ai.service', () => ({ + adminAiService: { + getOcrEngines: vi.fn(), + selectOcrEngine: vi.fn(), + }, +})); + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +const mockEngines = [ + { + engineId: 'engine-1', + engineName: 'Tesseract OCR', + engineType: 'tesseract', + isCurrentActive: true, + concurrentLimit: 4, + vramRequirementMB: 0, + }, + { + engineId: 'engine-2', + engineName: 'Typhoon OCR', + engineType: 'typhoon_ocr', + isCurrentActive: false, + concurrentLimit: 1, + vramRequirementMB: 4096, + }, +]; + +describe('OcrEngineSelector', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders loading state initially', () => { + // Return a promise that doesn't resolve immediately to keep it in loading state + (adminAiService.getOcrEngines as any).mockReturnValue(new Promise(() => {})); + + const { container } = render(); + // Card with animate-pulse + expect(container.querySelector('.animate-pulse')).toBeInTheDocument(); + }); + + it('renders engines list successfully after loading', async () => { + (adminAiService.getOcrEngines as any).mockResolvedValue(mockEngines); + + render(); + + await waitFor(() => { + expect(screen.getByText('ระบบจัดการ OCR Engine')).toBeInTheDocument(); + }); + + expect(screen.getByText('Tesseract OCR')).toBeInTheDocument(); + expect(screen.getByText('Typhoon OCR')).toBeInTheDocument(); + expect(screen.getByText('กำลังใช้งาน')).toBeInTheDocument(); // Badge for active engine + expect(screen.getByText('AI Powered')).toBeInTheDocument(); // Badge for typhoon + }); + + it('calls selectOcrEngine and shows success toast when changing engine', async () => { + const user = userEvent.setup(); + (adminAiService.getOcrEngines as any).mockResolvedValue(mockEngines); + (adminAiService.selectOcrEngine as any).mockResolvedValue({}); + + render(); + + await waitFor(() => { + expect(screen.getByText('ระบบจัดการ OCR Engine')).toBeInTheDocument(); + }); + + // The active engine will have "เลือกอยู่แล้ว", the inactive will have "สลับใช้งาน" + const switchButton = screen.getByRole('button', { name: /สลับใช้งาน/i }); + + await act(async () => { + await user.click(switchButton); + }); + + expect(adminAiService.selectOcrEngine).toHaveBeenCalledWith('engine-2'); + expect(toast.success).toHaveBeenCalledWith('เปลี่ยนเอนจิน OCR หลักเป็น Typhoon OCR สำเร็จ'); + + // It should fetch engines again + expect(adminAiService.getOcrEngines).toHaveBeenCalledTimes(2); + }); + + it('shows error toast if fetching fails', async () => { + (adminAiService.getOcrEngines as any).mockRejectedValue(new Error('Network error')); + + render(); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('ไม่สามารถดึงข้อมูล OCR Engines ได้'); + }); + }); + + it('shows error toast if selecting engine fails', async () => { + const user = userEvent.setup(); + (adminAiService.getOcrEngines as any).mockResolvedValue(mockEngines); + (adminAiService.selectOcrEngine as any).mockRejectedValue(new Error('Select error')); + + render(); + + await waitFor(() => { + expect(screen.getByText('ระบบจัดการ OCR Engine')).toBeInTheDocument(); + }); + + const switchButton = screen.getByRole('button', { name: /สลับใช้งาน/i }); + + await act(async () => { + await user.click(switchButton); + }); + + expect(adminAiService.selectOcrEngine).toHaveBeenCalledWith('engine-2'); + expect(toast.error).toHaveBeenCalledWith('ไม่สามารถเปลี่ยนเอนจิน OCR ได้'); + }); +}); diff --git a/frontend/components/admin/ai/__tests__/OcrSandboxPromptManager.test.tsx b/frontend/components/admin/ai/__tests__/OcrSandboxPromptManager.test.tsx new file mode 100644 index 00000000..b79eb8d4 --- /dev/null +++ b/frontend/components/admin/ai/__tests__/OcrSandboxPromptManager.test.tsx @@ -0,0 +1,123 @@ +// File: frontend/components/admin/ai/__tests__/OcrSandboxPromptManager.test.tsx +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import OcrSandboxPromptManager from '../OcrSandboxPromptManager'; + +// Mock Hooks +vi.mock('@/hooks/use-ai-prompts', () => ({ + useAiPrompts: vi.fn(() => ({ + versionsQuery: { data: [], isSuccess: true, refetch: vi.fn() }, + createMutation: { mutateAsync: vi.fn(), isPending: false }, + activateMutation: { mutateAsync: vi.fn() }, + deleteMutation: { mutateAsync: vi.fn() }, + updateNoteMutation: { mutateAsync: vi.fn() }, + })), + useSandboxRun: vi.fn(() => ({ + state: { isRunning: false }, + jobId: null, + reset: vi.fn(), + startPolling: vi.fn(), + })), +})); + +vi.mock('@/hooks/use-translations', () => ({ + useTranslations: vi.fn(() => (key: string) => key), +})); + +vi.mock('@/hooks/use-master-data', () => ({ + useProjects: vi.fn(() => ({ data: [{ publicId: 'proj-1', projectCode: 'P1', projectName: 'Project 1' }] })), + useContracts: vi.fn(() => ({ data: [] })), +})); + +// Mock React Query +vi.mock('@tanstack/react-query', () => ({ + useQuery: vi.fn(() => ({ data: [] })), +})); + +// Mock Service +vi.mock('@/lib/services/admin-ai.service', () => ({ + adminAiService: { + getOcrEngines: vi.fn().mockResolvedValue([]), + getSandboxProfile: vi.fn().mockResolvedValue({}), + getProductionDefaults: vi.fn().mockResolvedValue({}), + }, +})); + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +// We must mock PromptVersionHistory since it might rely on other complex contexts +vi.mock('../PromptVersionHistory', () => ({ + default: () =>
, +})); + +describe('OcrSandboxPromptManager', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders correctly and defaults to sandbox tab', async () => { + const { container } = render(); + + await waitFor(() => { + expect(screen.getByText('ai.prompt.sandboxCardTitle')).toBeInTheDocument(); + }); + + expect(screen.getByText('ai.prompt.tabSandbox')).toBeInTheDocument(); + expect(screen.getByText('ai.prompt.tabEditor')).toBeInTheDocument(); + }); + + it('switches to editor tab', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('ai.prompt.tabEditor')).toBeInTheDocument(); + }); + + const editorTab = screen.getByText('ai.prompt.tabEditor'); + await user.click(editorTab); + + expect(screen.getByText('ai.prompt.cardTitle')).toBeInTheDocument(); + }); + + it('handles rate limiting (429/503) errors gracefully on OCR submission', async () => { + const user = userEvent.setup(); + const mockSubmitOcr = vi.fn().mockRejectedValue({ + response: { data: { message: 'Rate limit exceeded. Try again later.' } } + }); + + // Override the mock implementation for this test + const { adminAiService } = await import('@/lib/services/admin-ai.service'); + (adminAiService.submitSandboxOcr as any) = mockSubmitOcr; + + render(); + + // Select project + const selects = document.querySelectorAll('select'); + const projectSelect = selects[0]; // First select is Project + await userEvent.selectOptions(projectSelect, 'proj-1'); + + // Simulate file drop/upload + const file = new File(['dummy content'], 'test.pdf', { type: 'application/pdf' }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await userEvent.upload(fileInput, file); + + const submitButton = screen.getByRole('button', { name: /Step 1: Run OCR Only/i }); + await user.click(submitButton); + + await waitFor(() => { + expect(mockSubmitOcr).toHaveBeenCalled(); + }); + + // Check if error toast was called + const { toast } = await import('sonner'); + expect(toast.error).toHaveBeenCalledWith('Rate limit exceeded. Try again later.'); + }); +}); diff --git a/frontend/components/admin/ai/__tests__/PromptEditor.test.tsx b/frontend/components/admin/ai/__tests__/PromptEditor.test.tsx new file mode 100644 index 00000000..d36b3626 --- /dev/null +++ b/frontend/components/admin/ai/__tests__/PromptEditor.test.tsx @@ -0,0 +1,64 @@ +// File: frontend/components/admin/ai/__tests__/PromptEditor.test.tsx +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import PromptEditor from '../PromptEditor'; + +// Mock PLACEHOLDER_REQUIREMENTS +vi.mock('@/contracts/frontend-types', () => ({ + PLACEHOLDER_REQUIREMENTS: { + ocr_extraction: ['{{ocr_text}}'], + rag_query_prompt: ['{{query}}'], + }, +})); + +describe('PromptEditor', () => { + const defaultProps = { + promptType: 'ocr_extraction' as const, + initialTemplate: 'Hello {{ocr_text}}', + onSave: vi.fn(), + isSaving: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders correctly with initial template', () => { + render(); + + const textarea = screen.getByPlaceholderText('เขียนพรอมต์ของคุณที่นี่...') as HTMLTextAreaElement; + expect(textarea.value).toBe('Hello {{ocr_text}}'); + }); + + it('disables save button if required placeholders are missing', async () => { + const user = userEvent.setup(); + render(); + + // Check missing validation message + expect(screen.getByText(/ต้องมีตัวแปร/i)).toBeInTheDocument(); + expect(screen.getByText('{{ocr_text}}')).toBeInTheDocument(); + + const saveButton = screen.getByRole('button', { name: /บันทึกเวอร์ชันใหม่/i }); + expect(saveButton).toBeDisabled(); + }); + + it('enables save button when placeholders are present and calls onSave', async () => { + const user = userEvent.setup(); + render(); + + const textarea = screen.getByPlaceholderText('เขียนพรอมต์ของคุณที่นี่...') as HTMLTextAreaElement; + await user.type(textarea, ' is awesome'); + + const noteInput = screen.getByPlaceholderText(/เช่น ปรับปรุงสัดส่วนความเที่ยงตรง/i); + await user.type(noteInput, 'Test Note'); + + const saveButton = screen.getByRole('button', { name: /บันทึกเวอร์ชันใหม่/i }); + expect(saveButton).not.toBeDisabled(); + + await user.click(saveButton); + + expect(defaultProps.onSave).toHaveBeenCalledWith('Hello {{ocr_text}} is awesome', 'Test Note'); + }); +}); diff --git a/frontend/components/admin/ai/__tests__/PromptTypeDropdown.test.tsx b/frontend/components/admin/ai/__tests__/PromptTypeDropdown.test.tsx new file mode 100644 index 00000000..456bd25f --- /dev/null +++ b/frontend/components/admin/ai/__tests__/PromptTypeDropdown.test.tsx @@ -0,0 +1,73 @@ +// File: frontend/components/admin/ai/__tests__/PromptTypeDropdown.test.tsx +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import PromptTypeDropdown from '../PromptTypeDropdown'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +// ResizeObserver mock is needed for Radix UI select +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +window.ResizeObserver = ResizeObserver; + +describe('PromptTypeDropdown', () => { + beforeEach(() => { + vi.clearAllMocks(); + // mock pointer event for Radix UI + window.PointerEvent = MouseEvent as any; + }); + + it('renders correctly with default options', async () => { + render(); + + expect(screen.getByText('prompt_management.prompt_type')).toBeInTheDocument(); + + const trigger = screen.getByRole('combobox'); + expect(trigger).toHaveTextContent('สกัดข้อความ OCR (OCR Extraction)'); + }); + + it('renders all options when showAllOption is true', async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByRole('combobox'); + expect(trigger).toHaveTextContent('prompt_management.all_types'); + + await user.click(trigger); + + await waitFor(() => { + expect(screen.getByRole('option', { name: 'prompt_management.all_types' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'สกัดข้อความ OCR (OCR Extraction)' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'ค้นหาข้อมูล RAG (RAG Query)' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'เตรียมข้อมูล RAG (RAG Prep)' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'จำแนกประเภทเอกสาร (Classification)' })).toBeInTheDocument(); + }); + }); + + it('calls onChange when an option is selected', async () => { + const user = userEvent.setup(); + const onChangeMock = vi.fn(); + render(); + + const trigger = screen.getByRole('combobox'); + await user.click(trigger); + + await waitFor(() => { + expect(screen.getByRole('option', { name: 'ค้นหาข้อมูล RAG (RAG Query)' })).toBeInTheDocument(); + }); + + const option = screen.getByRole('option', { name: 'ค้นหาข้อมูล RAG (RAG Query)' }); + await user.click(option); + + expect(onChangeMock).toHaveBeenCalledWith('rag_query_prompt'); + }); +}); diff --git a/frontend/components/admin/ai/__tests__/PromptVersionHistory.test.tsx b/frontend/components/admin/ai/__tests__/PromptVersionHistory.test.tsx new file mode 100644 index 00000000..a9cec6a9 --- /dev/null +++ b/frontend/components/admin/ai/__tests__/PromptVersionHistory.test.tsx @@ -0,0 +1,96 @@ +// File: frontend/components/admin/ai/__tests__/PromptVersionHistory.test.tsx +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import PromptVersionHistory from '../PromptVersionHistory'; +import { AiPrompt } from '@/types/ai-prompts'; + +describe('PromptVersionHistory', () => { + const mockVersions: AiPrompt[] = [ + { + publicId: 'v1-id', + versionNumber: 1, + promptType: 'ocr_extraction', + template: 'Template 1', + isActive: false, + createdAt: '2026-06-14T10:00:00Z', + updatedAt: '2026-06-14T10:00:00Z', + manualNote: 'Note 1', + }, + { + publicId: 'v2-id', + versionNumber: 2, + promptType: 'ocr_extraction', + template: 'Template 2', + isActive: true, + createdAt: '2026-06-15T10:00:00Z', + updatedAt: '2026-06-15T10:00:00Z', + lastTestedAt: '2026-06-15T10:05:00Z', + }, + ]; + + const defaultProps = { + versions: mockVersions, + isLoading: false, + onLoadTemplate: vi.fn(), + onActivateVersion: vi.fn(), + onDeleteVersion: vi.fn(), + isActivating: false, + isDeleting: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders loading state', () => { + render(); + expect(screen.getByText(/กำลังโหลดประวัติเวอร์ชัน/)).toBeInTheDocument(); + }); + + it('renders empty state', () => { + render(); + expect(screen.getByText(/ไม่พบเวอร์ชันอื่นในระบบ/)).toBeInTheDocument(); + }); + + it('renders versions correctly', () => { + render(); + + // Check version numbers + expect(screen.getByText('v1')).toBeInTheDocument(); + expect(screen.getByText('v2')).toBeInTheDocument(); + + // Check active/inactive badges + expect(screen.getByText(/ใช้งานจริง \(Active\)/)).toBeInTheDocument(); + expect(screen.getByText(/ร่าง \(Inactive\)/)).toBeInTheDocument(); + + // Check manual note + expect(screen.getByText('Note 1')).toBeInTheDocument(); + }); + + it('calls action handlers when buttons are clicked', async () => { + const user = userEvent.setup(); + render(); + + // There are 2 load buttons + const loadButtons = screen.getAllByRole('button', { name: /โหลด \(Load\)/ }); + expect(loadButtons).toHaveLength(2); + + await user.click(loadButtons[0]); + expect(defaultProps.onLoadTemplate).toHaveBeenCalledWith(mockVersions[0]); + + // Active version should not have Activate/Delete buttons + const activateButtons = screen.getAllByRole('button', { name: /ใช้งาน \(Activate\)/ }); + expect(activateButtons).toHaveLength(1); // Only for v1 + + await user.click(activateButtons[0]); + expect(defaultProps.onActivateVersion).toHaveBeenCalledWith(1); + + // Delete button (it uses an icon, but we can target by role 'button' and filter or use the trash icon if it had aria-label) + // Actually, delete button is the 3rd button in the v1 row (Load, Activate, Delete) + const deleteButton = screen.getAllByRole('button')[2]; // Load v1, Activate v1, Delete v1 + await user.click(deleteButton); + expect(defaultProps.onDeleteVersion).toHaveBeenCalledWith(1); + }); +}); diff --git a/frontend/components/admin/ai/__tests__/RuntimeParametersPanel.test.tsx b/frontend/components/admin/ai/__tests__/RuntimeParametersPanel.test.tsx new file mode 100644 index 00000000..0361390a --- /dev/null +++ b/frontend/components/admin/ai/__tests__/RuntimeParametersPanel.test.tsx @@ -0,0 +1,128 @@ +// File: frontend/components/admin/ai/__tests__/RuntimeParametersPanel.test.tsx +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import RuntimeParametersPanel from '../RuntimeParametersPanel'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const mockGetSandboxProfile = vi.fn(); +const mockSaveSandboxProfile = vi.fn(); +const mockResetSandboxProfile = vi.fn(); +const mockApplyProfile = vi.fn(); + +vi.mock('@/lib/services/admin-ai.service', () => ({ + adminAiService: { + getSandboxProfile: (...args: any) => mockGetSandboxProfile(...args), + saveSandboxProfile: (...args: any) => mockSaveSandboxProfile(...args), + resetSandboxProfile: (...args: any) => mockResetSandboxProfile(...args), + applyProfile: (...args: any) => mockApplyProfile(...args), + }, +})); + +vi.mock('uuid', () => ({ + v7: () => 'mock-uuid-v7', +})); + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +// ResizeObserver mock is needed for Radix UI select +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +window.ResizeObserver = ResizeObserver; + +describe('RuntimeParametersPanel', () => { + const mockParams = { + temperature: 0.7, + topP: 0.9, + repeatPenalty: 1.1, + maxTokens: 2048, + numCtx: 4096, + keepAliveSeconds: 300, + canonicalModel: 'np-dms-ai-test', + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetSandboxProfile.mockResolvedValue(mockParams); + window.PointerEvent = MouseEvent as any; + }); + + it('renders loading state initially', () => { + // We mock promise without resolving immediately to see loading state + let resolvePromise: any; + mockGetSandboxProfile.mockReturnValue(new Promise((resolve) => { + resolvePromise = resolve; + })); + + render(); + expect(screen.getByText(/กำลังโหลดพารามิเตอร์/)).toBeInTheDocument(); + + // Resolve to avoid act warnings + resolvePromise(mockParams); + }); + + it('renders parameters after loading', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('sandbox_test.runtime_parameters')).toBeInTheDocument(); + }); + + expect(screen.getByText('np-dms-ai-test')).toBeInTheDocument(); + expect(screen.getByDisplayValue('2048')).toBeInTheDocument(); + expect(screen.getByDisplayValue('4096')).toBeInTheDocument(); + expect(screen.getByDisplayValue('300')).toBeInTheDocument(); + }); + + it('calls save draft correctly', async () => { + const user = userEvent.setup(); + mockSaveSandboxProfile.mockResolvedValue(mockParams); + + render(); + + await waitFor(() => { + expect(screen.getByText('sandbox_test.runtime_parameters')).toBeInTheDocument(); + }); + + const saveButton = screen.getByRole('button', { name: /บันทึกแบบร่าง/i }); + await user.click(saveButton); + + expect(mockSaveSandboxProfile).toHaveBeenCalledWith('standard', mockParams, 'mock-uuid-v7'); + + const { toast } = await import('sonner'); + expect(toast.success).toHaveBeenCalledWith('บันทึกแบบร่าง Sandbox สำเร็จ'); + }); + + it('calls apply to production correctly', async () => { + const user = userEvent.setup(); + mockApplyProfile.mockResolvedValue(true); + + render(); + + await waitFor(() => { + expect(screen.getByText('sandbox_test.runtime_parameters')).toBeInTheDocument(); + }); + + const applyButton = screen.getByRole('button', { name: /ปรับใช้จริง/i }); + await user.click(applyButton); + + expect(mockApplyProfile).toHaveBeenCalledWith('standard', 'mock-uuid-v7'); + + const { toast } = await import('sonner'); + expect(toast.success).toHaveBeenCalledWith('ปรับใช้พารามิเตอร์จริงสำเร็จ'); + }); +}); diff --git a/frontend/components/admin/ai/__tests__/SandboxTabs.test.tsx b/frontend/components/admin/ai/__tests__/SandboxTabs.test.tsx new file mode 100644 index 00000000..11618892 --- /dev/null +++ b/frontend/components/admin/ai/__tests__/SandboxTabs.test.tsx @@ -0,0 +1,100 @@ +// File: frontend/components/admin/ai/__tests__/SandboxTabs.test.tsx +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import SandboxTabs from '../SandboxTabs'; + +const mockSubmitSandboxOcr = vi.fn(); +const mockSubmitSandboxAiExtract = vi.fn(); +const mockSubmitSandboxRagPrep = vi.fn(); +const mockGetSandboxJobStatus = vi.fn(); + +vi.mock('@/lib/services/admin-ai.service', () => ({ + adminAiService: { + submitSandboxOcr: (...args: any) => mockSubmitSandboxOcr(...args), + submitSandboxAiExtract: (...args: any) => mockSubmitSandboxAiExtract(...args), + submitSandboxRagPrep: (...args: any) => mockSubmitSandboxRagPrep(...args), + getSandboxJobStatus: (...args: any) => mockGetSandboxJobStatus(...args), + }, +})); + +vi.mock('@/hooks/use-master-data', () => ({ + useProjects: vi.fn(() => ({ data: [{ publicId: 'proj-1', projectCode: 'P1', projectName: 'Project 1' }] })), + useContracts: vi.fn(() => ({ data: [] })), +})); + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +// ResizeObserver mock is needed for Radix UI select +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +window.ResizeObserver = ResizeObserver; + +describe('SandboxTabs', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.PointerEvent = MouseEvent as any; + }); + + it('renders correctly', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/รันบอร์ดทดลองการทำงาน/i)).toBeInTheDocument(); + }); + + expect(screen.getByText('Step 1: Run OCR')).toBeInTheDocument(); + expect(screen.getByText('Step 2: AI Extract')).toBeInTheDocument(); + expect(screen.getByText('Step 3: RAG Prep')).toBeInTheDocument(); + }); + + it('requires project and file for OCR', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /เริ่มรัน OCR/i })).toBeDisabled(); + }); + + // Upload file + const file = new File(['dummy content'], 'test.pdf', { type: 'application/pdf' }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await userEvent.upload(fileInput, file); + + // After uploading file, button is enabled, but submitting will fail without project in AI Extract step. + // Wait, handleRunOcr checks if file exists, it doesn't check project! + expect(screen.getByRole('button', { name: /เริ่มรัน OCR/i })).not.toBeDisabled(); + }); + + it('runs OCR and polls status', async () => { + const user = userEvent.setup(); + + mockSubmitSandboxOcr.mockResolvedValue({ requestPublicId: 'req-1' }); + mockGetSandboxJobStatus.mockResolvedValue({ status: 'completed', ocrText: 'Extracted text from PDF' }); + + render(); + + // Upload file + const file = new File(['dummy content'], 'test.pdf', { type: 'application/pdf' }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await userEvent.upload(fileInput, file); + + const runBtn = screen.getByRole('button', { name: /เริ่มรัน OCR/i }); + await user.click(runBtn); + + expect(mockSubmitSandboxOcr).toHaveBeenCalled(); + + await waitFor(() => { + expect(mockGetSandboxJobStatus).toHaveBeenCalledWith('req-1'); + }, { timeout: 3000 }); // Polling interval is 2s + }); +}); diff --git a/frontend/components/admin/ai/__tests__/SandboxTestArea.test.tsx b/frontend/components/admin/ai/__tests__/SandboxTestArea.test.tsx new file mode 100644 index 00000000..5170d603 --- /dev/null +++ b/frontend/components/admin/ai/__tests__/SandboxTestArea.test.tsx @@ -0,0 +1,99 @@ +// File: frontend/components/admin/ai/__tests__/SandboxTestArea.test.tsx +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import SandboxTestArea from '../SandboxTestArea'; + +const mockSubmitSandboxOcr = vi.fn(); +const mockSubmitSandboxAiExtract = vi.fn(); +const mockSubmitSandboxRagPrep = vi.fn(); +const mockGetSandboxJobStatus = vi.fn(); + +vi.mock('@/lib/services/admin-ai.service', () => ({ + adminAiService: { + submitSandboxOcr: (...args: any) => mockSubmitSandboxOcr(...args), + submitSandboxAiExtract: (...args: any) => mockSubmitSandboxAiExtract(...args), + submitSandboxRagPrep: (...args: any) => mockSubmitSandboxRagPrep(...args), + getSandboxJobStatus: (...args: any) => mockGetSandboxJobStatus(...args), + }, +})); + +vi.mock('@/hooks/use-master-data', () => ({ + useProjects: vi.fn(() => ({ data: [{ publicId: 'proj-1', projectCode: 'P1', projectName: 'Project 1' }] })), + useContracts: vi.fn(() => ({ data: [] })), +})); + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +// ResizeObserver mock is needed for Radix UI select +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +window.ResizeObserver = ResizeObserver; + +describe('SandboxTestArea', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.PointerEvent = MouseEvent as any; + }); + + it('renders correctly', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/รันบอร์ดทดลองการทำงาน/i)).toBeInTheDocument(); + }); + + expect(screen.getByText('Step 1: Run OCR')).toBeInTheDocument(); + expect(screen.getByText('Step 2: AI Extract')).toBeInTheDocument(); + expect(screen.getByText('Step 3: RAG Prep')).toBeInTheDocument(); + }); + + it('requires project and file for OCR', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /เริ่มรัน OCR/i })).toBeDisabled(); + }); + + // Upload file + const file = new File(['dummy content'], 'test.pdf', { type: 'application/pdf' }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await userEvent.upload(fileInput, file); + + // After uploading file, button is enabled, but submitting will fail without project in AI Extract step. + expect(screen.getByRole('button', { name: /เริ่มรัน OCR/i })).not.toBeDisabled(); + }); + + it('runs OCR and polls status', async () => { + const user = userEvent.setup(); + + mockSubmitSandboxOcr.mockResolvedValue({ requestPublicId: 'req-1' }); + mockGetSandboxJobStatus.mockResolvedValue({ status: 'completed', ocrText: 'Extracted text from PDF' }); + + render(); + + // Upload file + const file = new File(['dummy content'], 'test.pdf', { type: 'application/pdf' }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await userEvent.upload(fileInput, file); + + const runBtn = screen.getByRole('button', { name: /เริ่มรัน OCR/i }); + await user.click(runBtn); + + expect(mockSubmitSandboxOcr).toHaveBeenCalled(); + + await waitFor(() => { + expect(mockGetSandboxJobStatus).toHaveBeenCalledWith('req-1'); + }, { timeout: 3000 }); // Polling interval is 2s + }); +}); diff --git a/frontend/components/admin/ai/__tests__/VersionHistory.test.tsx b/frontend/components/admin/ai/__tests__/VersionHistory.test.tsx new file mode 100644 index 00000000..35b76c76 --- /dev/null +++ b/frontend/components/admin/ai/__tests__/VersionHistory.test.tsx @@ -0,0 +1,117 @@ +// File: frontend/components/admin/ai/__tests__/VersionHistory.test.tsx +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import VersionHistory from '../VersionHistory'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe('VersionHistory', () => { + const mockOnLoadTemplate = vi.fn(); + const mockOnActivateVersion = vi.fn(); + const mockOnDeleteVersion = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const generateVersions = (count: number) => { + return Array.from({ length: count }, (_, i) => ({ + versionNumber: i + 1, + promptType: 'ocr_extraction', + promptText: 'prompt text', + createdAt: new Date().toISOString(), + isActive: i === 0, + manualNote: `Note ${i + 1}`, + authorName: 'Admin', + })); + }; + + it('renders loading state', () => { + render( + + ); + expect(screen.getByText(/prompt_management.version_history/i)).toBeInTheDocument(); + }); + + it('renders empty state', () => { + render( + + ); + expect(screen.getByText('prompt_management.no_versions')).toBeInTheDocument(); + }); + + it('renders versions', () => { + const versions = generateVersions(2); + render( + + ); + + expect(screen.getByText('v1')).toBeInTheDocument(); + expect(screen.getByText('v2')).toBeInTheDocument(); + expect(screen.getByText('Note 1')).toBeInTheDocument(); + expect(screen.getByText('prompt_management.is_active')).toBeInTheDocument(); + }); + + it('handles pagination', async () => { + const user = userEvent.setup(); + const versions = generateVersions(25); // Page size is 20 + + render( + + ); + + // Page 1 should have v1 to v20 + expect(screen.getByText('v1')).toBeInTheDocument(); + expect(screen.getByText('v20')).toBeInTheDocument(); + expect(screen.queryByText('v21')).not.toBeInTheDocument(); + + // Next page button is the right chevron + const nextBtn = document.querySelector('button .lucide-chevron-right')?.closest('button'); + if (nextBtn) { + await user.click(nextBtn); + } + + // Page 2 should have v21 to v25 + expect(screen.queryByText('v1')).not.toBeInTheDocument(); + expect(screen.getByText('v21')).toBeInTheDocument(); + expect(screen.getByText('v25')).toBeInTheDocument(); + }); +}); diff --git a/frontend/components/dashboard/__tests__/pending-tasks.test.tsx b/frontend/components/dashboard/__tests__/pending-tasks.test.tsx new file mode 100644 index 00000000..4ceca51a --- /dev/null +++ b/frontend/components/dashboard/__tests__/pending-tasks.test.tsx @@ -0,0 +1,75 @@ +// File: frontend/components/dashboard/__tests__/pending-tasks.test.tsx +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { PendingTasks } from '../pending-tasks'; +import { PendingTask } from '@/types/dashboard'; + +describe('PendingTasks', () => { + const mockTasks: PendingTask[] = [ + { + publicId: 'task-1', + title: 'Review RFA-001', + description: 'Needs structural review', + type: 'rfa_review', + dueDate: new Date('2026-06-10T00:00:00Z').toISOString(), + daysOverdue: 5, + url: '/rfas/task-1' + }, + { + publicId: 'task-2', + title: 'Approve Transmittal', + description: 'Monthly submittals', + type: 'transmittal_approval', + dueDate: new Date('2026-06-20T00:00:00Z').toISOString(), + daysOverdue: 0, + url: '/transmittals/task-2' + } + ]; + + it('renders loading state when isLoading is true', () => { + render(); + + expect(screen.getByText('Pending Tasks')).toBeInTheDocument(); + // Check for skeletons (pulse animation) + const cardContent = screen.getByText('Pending Tasks').closest('.border'); + expect(cardContent?.querySelectorAll('.animate-pulse').length).toBe(3); + }); + + it('renders empty state when no tasks are present', () => { + render(); + + expect(screen.getByText('Pending Tasks')).toBeInTheDocument(); + expect(screen.getByText('No pending tasks. Good job!')).toBeInTheDocument(); + }); + + it('handles undefined tasks prop gracefully', () => { + render(); + + expect(screen.getByText('Pending Tasks')).toBeInTheDocument(); + expect(screen.getByText('No pending tasks. Good job!')).toBeInTheDocument(); + }); + + it('renders tasks list correctly', () => { + render(); + + expect(screen.getByText('Review RFA-001')).toBeInTheDocument(); + expect(screen.getByText('Needs structural review')).toBeInTheDocument(); + expect(screen.getByText('Approve Transmittal')).toBeInTheDocument(); + expect(screen.getByText('Monthly submittals')).toBeInTheDocument(); + + // Check count badge + const countBadge = screen.getByText('2'); + expect(countBadge).toHaveClass('bg-destructive'); + }); + + it('displays correct badges for overdue and due soon tasks', () => { + render(); + + const overdueBadge = screen.getByText('5d overdue'); + expect(overdueBadge).toHaveClass('bg-destructive'); + + const dueSoonBadge = screen.getByText('Due Soon'); + expect(dueSoonBadge).toHaveClass('border-yellow-200'); + }); +}); diff --git a/frontend/components/dashboard/__tests__/quick-actions.test.tsx b/frontend/components/dashboard/__tests__/quick-actions.test.tsx new file mode 100644 index 00000000..7e9e3cd8 --- /dev/null +++ b/frontend/components/dashboard/__tests__/quick-actions.test.tsx @@ -0,0 +1,27 @@ +// File: frontend/components/dashboard/__tests__/quick-actions.test.tsx +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { QuickActions } from '../quick-actions'; + +// Mock Next.js Link component +vi.mock('next/link', () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ) +})); + +describe('QuickActions', () => { + it('renders all quick action links correctly', () => { + render(); + + const newRfaLink = screen.getByRole('link', { name: /new rfa/i }); + expect(newRfaLink).toHaveAttribute('href', '/rfas/new'); + + const newCorrespondenceLink = screen.getByRole('link', { name: /new correspondence/i }); + expect(newCorrespondenceLink).toHaveAttribute('href', '/correspondences/new'); + + const uploadDrawingLink = screen.getByRole('link', { name: /upload drawing/i }); + expect(uploadDrawingLink).toHaveAttribute('href', '/drawings/upload'); + }); +}); diff --git a/frontend/components/dashboard/__tests__/recent-activity.test.tsx b/frontend/components/dashboard/__tests__/recent-activity.test.tsx new file mode 100644 index 00000000..d6cc00d6 --- /dev/null +++ b/frontend/components/dashboard/__tests__/recent-activity.test.tsx @@ -0,0 +1,77 @@ +// File: frontend/components/dashboard/__tests__/recent-activity.test.tsx +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { RecentActivity } from '../recent-activity'; +import { ActivityLog } from '@/types/dashboard'; + +describe('RecentActivity', () => { + const mockActivities: ActivityLog[] = [ + { + id: 'act-1', + action: 'Created', + description: 'Created new RFA-001', + targetUrl: '/rfas/1', + createdAt: new Date(Date.now() - 1000 * 60 * 5).toISOString(), // 5 minutes ago + user: { + id: 'u1', + name: 'John Doe', + initials: 'JD' + } + }, + { + id: 'act-2', + action: 'Approved', + description: 'Approved Transmittal TR-005', + targetUrl: '/transmittals/5', + createdAt: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(), // 2 hours ago + user: { + id: 'u2', + name: 'Jane Smith', + initials: 'JS' + } + } + ]; + + it('renders loading state when isLoading is true', () => { + render(); + + expect(screen.getByText('Recent Activity')).toBeInTheDocument(); + // Check for skeletons (pulse animation) + const cardContent = screen.getByText('Recent Activity').closest('.border'); + expect(cardContent?.querySelectorAll('.animate-pulse').length).toBe(3); + }); + + it('renders empty state when no activities are present', () => { + render(); + + expect(screen.getByText('Recent Activity')).toBeInTheDocument(); + expect(screen.getByText('No recent activity.')).toBeInTheDocument(); + }); + + it('handles undefined activities prop gracefully', () => { + render(); + + expect(screen.getByText('Recent Activity')).toBeInTheDocument(); + expect(screen.getByText('No recent activity.')).toBeInTheDocument(); + }); + + it('renders activities list correctly', () => { + render(); + + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('JD')).toBeInTheDocument(); + expect(screen.getByText('Created')).toBeInTheDocument(); + expect(screen.getByText('Created new RFA-001')).toBeInTheDocument(); + + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); + expect(screen.getByText('JS')).toBeInTheDocument(); + expect(screen.getByText('Approved')).toBeInTheDocument(); + expect(screen.getByText('Approved Transmittal TR-005')).toBeInTheDocument(); + + // date-fns formatDistanceToNow will output text like '5 minutes ago', 'about 2 hours ago' + // We can just verify some part of it or that it renders without error. + const createdLink = screen.getByText('Created new RFA-001').closest('a'); + expect(createdLink).toHaveAttribute('href', '/rfas/1'); + }); +}); diff --git a/frontend/components/dashboard/__tests__/stats-cards.test.tsx b/frontend/components/dashboard/__tests__/stats-cards.test.tsx new file mode 100644 index 00000000..005c2fd9 --- /dev/null +++ b/frontend/components/dashboard/__tests__/stats-cards.test.tsx @@ -0,0 +1,44 @@ +// File: frontend/components/dashboard/__tests__/stats-cards.test.tsx +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { StatsCards } from '../stats-cards'; +import { DashboardStats } from '@/types/dashboard'; + +describe('StatsCards', () => { + const mockStats: DashboardStats = { + totalDocuments: 150, + totalRfas: 45, + approved: 120, + pendingApprovals: 15 + }; + + it('renders loading state when isLoading is true', () => { + // using container to query raw DOM for animate-pulse since skeletons don't have text + const { container } = render(); + const pulses = container.querySelectorAll('.animate-pulse'); + expect(pulses.length).toBe(4); + }); + + it('renders loading state when stats is undefined', () => { + const { container } = render(); + const pulses = container.querySelectorAll('.animate-pulse'); + expect(pulses.length).toBe(4); + }); + + it('renders stats cards correctly with values', () => { + render(); + + expect(screen.getByText('Total Correspondences')).toBeInTheDocument(); + expect(screen.getByText('150')).toBeInTheDocument(); + + expect(screen.getByText('Active RFAs')).toBeInTheDocument(); + expect(screen.getByText('45')).toBeInTheDocument(); + + expect(screen.getByText('Approved Documents')).toBeInTheDocument(); + expect(screen.getByText('120')).toBeInTheDocument(); + + expect(screen.getByText('Pending Approvals')).toBeInTheDocument(); + expect(screen.getByText('15')).toBeInTheDocument(); + }); +}); diff --git a/frontend/components/documents/common/__tests__/server-data-table.test.tsx b/frontend/components/documents/common/__tests__/server-data-table.test.tsx new file mode 100644 index 00000000..5af1377d --- /dev/null +++ b/frontend/components/documents/common/__tests__/server-data-table.test.tsx @@ -0,0 +1,153 @@ +// File: frontend/components/documents/common/__tests__/server-data-table.test.tsx +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ServerDataTable } from '../server-data-table'; +import { ColumnDef } from '@tanstack/react-table'; + +type TestData = { + id: string; + name: string; +}; + +const columns: ColumnDef[] = [ + { + accessorKey: 'id', + header: 'ID', + }, + { + accessorKey: 'name', + header: 'Name', + }, +]; + +const mockData: TestData[] = [ + { id: '1', name: 'Item 1' }, + { id: '2', name: 'Item 2' }, +]; + +describe('ServerDataTable', () => { + const onPaginationChange = vi.fn(); + const onSortingChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders loading state', () => { + render( + + ); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('renders empty state', () => { + render( + + ); + expect(screen.getByText('No results.')).toBeInTheDocument(); + }); + + it('renders data rows', () => { + render( + + ); + + expect(screen.getByText('Item 1')).toBeInTheDocument(); + expect(screen.getByText('Item 2')).toBeInTheDocument(); + expect(screen.getByText('Page 1 of 1')).toBeInTheDocument(); + }); + + it('handles pagination controls', async () => { + const user = userEvent.setup(); + render( + + ); + + expect(screen.getByText('Page 2 of 3')).toBeInTheDocument(); + + const nextButton = screen.getByRole('button', { name: /Go to next page/i }); + const prevButton = screen.getByRole('button', { name: /Go to previous page/i }); + const firstButton = screen.getByRole('button', { name: /Go to first page/i }); + const lastButton = screen.getByRole('button', { name: /Go to last page/i }); + + expect(nextButton).not.toBeDisabled(); + expect(prevButton).not.toBeDisabled(); + + await user.click(nextButton); + expect(onPaginationChange).toHaveBeenCalled(); + + await user.click(prevButton); + expect(onPaginationChange).toHaveBeenCalledTimes(2); + + await user.click(firstButton); + expect(onPaginationChange).toHaveBeenCalledTimes(3); + + await user.click(lastButton); + expect(onPaginationChange).toHaveBeenCalledTimes(4); + }); + + it('handles page size change', async () => { + const user = userEvent.setup(); + render( + + ); + + // The SelectTrigger for page size has placeholder or value. We can find it by role 'combobox' + const selectTrigger = screen.getByRole('combobox'); + await user.click(selectTrigger); + + // Select option 20 + const option20 = screen.getByRole('option', { name: '20' }); + await user.click(option20); + + // setPageSize triggers onPaginationChange with the new page size + expect(onPaginationChange).toHaveBeenCalled(); + }); +}); diff --git a/frontend/components/migration/__tests__/review-queue-table.test.tsx b/frontend/components/migration/__tests__/review-queue-table.test.tsx new file mode 100644 index 00000000..6c6215be --- /dev/null +++ b/frontend/components/migration/__tests__/review-queue-table.test.tsx @@ -0,0 +1,260 @@ +// File: frontend/components/migration/__tests__/review-queue-table.test.tsx +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ReviewQueueTable } from '../review-queue-table'; +import { MigrationReviewStatus } from '@/types/migration'; + +// Mock hooks +const mockMutateAsyncCommit = vi.fn(); +const mockMutateAsyncReject = vi.fn(); + +vi.mock('@/hooks/use-migration-review', () => ({ + useCommitMigrationReview: () => ({ + mutateAsync: mockMutateAsyncCommit, + isPending: false + }), + useRejectMigrationReview: () => ({ + mutateAsync: mockMutateAsyncReject, + isPending: false + }) +})); + +vi.mock('@/hooks/use-master-data', () => ({ + useProjects: () => ({ + data: [ + { publicId: 'proj-1', projectName: 'Project A', projectCode: 'PA' } + ] + }), + useOrganizations: () => ({ + data: [ + { publicId: 'org-1', organizationName: 'Org A' } + ] + }) +})); + +// Mock ResizeObserver for Radix UI +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} +global.ResizeObserver = ResizeObserverMock; + +describe('ReviewQueueTable', () => { + const mockItems: any[] = [ + { + id: 1, + publicId: 'mig-1', + documentNumber: 'DOC-001', + subject: 'Test Migration Doc', + aiSuggestedCategory: 'RFA', + aiConfidence: 0.95, + status: MigrationReviewStatus.PENDING, + projectId: 'proj-1', + senderOrganizationId: 'org-1', + receiverOrganizationId: 'org-2', + issuedDate: '2026-06-01T00:00:00.000Z', + receivedDate: '2026-06-02T00:00:00.000Z', + body: 'Migration test body', + extractedTags: [{ name: 'Urgent', is_new: false }], + aiIssues: [{ message: 'Confidence is slightly low on receiver' }] + }, + { + id: 2, + publicId: 'mig-2', + documentNumber: 'DOC-002', + subject: 'Test Migration Doc 2', + aiSuggestedCategory: 'Correspondence', + aiConfidence: 0.85, + status: MigrationReviewStatus.IMPORTED, + } + ]; + + beforeEach(() => { + vi.clearAllMocks(); + // Mock window.confirm + vi.spyOn(window, 'confirm').mockImplementation(() => true); + // Mock scrollIntoView for Radix components + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + }); + + it('renders loading state', () => { + render(); + expect(screen.getByText('กำลังโหลดรายการรอรีวิว...')).toBeInTheDocument(); + }); + + it('renders empty state', () => { + render(); + expect(screen.getByText('ไม่พบรายการที่รอตรวจสอบในคิวขณะนี้')).toBeInTheDocument(); + }); + + it('renders queue items', () => { + render(); + expect(screen.getByText('DOC-001')).toBeInTheDocument(); + expect(screen.getByText('Test Migration Doc')).toBeInTheDocument(); + expect(screen.getByText('95.0%')).toBeInTheDocument(); + expect(screen.getByText('รอตรวจสอบ')).toBeInTheDocument(); + + expect(screen.getByText('DOC-002')).toBeInTheDocument(); + expect(screen.getByText('Test Migration Doc 2')).toBeInTheDocument(); + expect(screen.getByText('85.0%')).toBeInTheDocument(); + expect(screen.getByText('นำเข้าแล้ว')).toBeInTheDocument(); + }); + + it('opens sheet when review button is clicked', async () => { + render(); + + const reviewButtons = screen.getAllByRole('button', { name: /รีวิว|ดูรายละเอียด/i }); + // First button is for 'รอตรวจสอบ' (PENDING) + fireEvent.click(reviewButtons[0]); + + await waitFor(() => { + expect(screen.getByText('รีวิวการย้ายข้อมูลเอกสาร')).toBeInTheDocument(); + // Should show the document number in a badge + expect(screen.getAllByText('DOC-001').length).toBeGreaterThan(0); + // Should show AI issues + expect(screen.getByText('Confidence is slightly low on receiver')).toBeInTheDocument(); + }); + }); + + it('allows editing subject and other fields', async () => { + render(); + + const reviewButtons = screen.getAllByRole('button', { name: /รีวิว|ดูรายละเอียด/i }); + fireEvent.click(reviewButtons[0]); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name: /หัวข้อเรื่อง/i })).toHaveValue('Test Migration Doc'); + }); + + const subjectInput = screen.getByRole('textbox', { name: /หัวข้อเรื่อง/i }); + fireEvent.change(subjectInput, { target: { value: 'Updated Subject' } }); + expect(subjectInput).toHaveValue('Updated Subject'); + + const bodyInput = screen.getByRole('textbox', { name: /เนื้อหาสรุปจดหมาย/i }); + fireEvent.change(bodyInput, { target: { value: 'Updated Body' } }); + expect(bodyInput).toHaveValue('Updated Body'); + + const issuedDateInput = screen.getByLabelText(/วันที่ออกเอกสาร/i); + fireEvent.change(issuedDateInput, { target: { value: '2026-06-10' } }); + expect(issuedDateInput).toHaveValue('2026-06-10'); + + const receivedDateInput = screen.getByLabelText(/วันที่ลงรับเอกสาร/i); + fireEvent.change(receivedDateInput, { target: { value: '2026-06-11' } }); + expect(receivedDateInput).toHaveValue('2026-06-11'); + }); + + it('allows adding and removing tags', async () => { + render(); + + const reviewButtons = screen.getAllByRole('button', { name: /รีวิว|ดูรายละเอียด/i }); + fireEvent.click(reviewButtons[0]); + + await waitFor(() => { + // Urgent is already there + expect(screen.getByText('Urgent')).toBeInTheDocument(); + }); + + // Add new tag with Enter key + const addTagInput = screen.getByPlaceholderText('เพิ่มแท็กภาษาไทย...'); + fireEvent.change(addTagInput, { target: { value: 'NewTag' } }); + fireEvent.keyDown(addTagInput, { key: 'Enter', code: 'Enter' }); + + await waitFor(() => { + expect(screen.getByText('NewTag')).toBeInTheDocument(); + }); + + // Add another tag with button + fireEvent.change(addTagInput, { target: { value: 'AnotherTag' } }); + const addButton = screen.getByRole('button', { name: /เพิ่ม/i }); + fireEvent.click(addButton); + + await waitFor(() => { + expect(screen.getByText('AnotherTag')).toBeInTheDocument(); + }); + + // Remove Urgent tag + // The tag badge contains 'Urgent' and an 'X' button + const removeButtons = screen.getAllByRole('button', { name: '' }); + // The first X button inside a badge should be the one for 'Urgent' (assuming it's the only icon button without a distinct name there) + // Actually, Lucide icon doesn't have a label by default, let's find the button by its parent + const urgentTag = screen.getByText('Urgent'); + const removeUrgentButton = urgentTag.nextElementSibling; + if (removeUrgentButton) { + fireEvent.click(removeUrgentButton); + } + + await waitFor(() => { + expect(screen.queryByText('Urgent')).not.toBeInTheDocument(); + }); + }); + + it('calls commit mutation on commit', async () => { + render(); + + const reviewButtons = screen.getAllByRole('button', { name: /รีวิว|ดูรายละเอียด/i }); + fireEvent.click(reviewButtons[0]); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /กดยอมรับการนำเข้า/i })).toBeInTheDocument(); + }); + + const commitButton = screen.getByRole('button', { name: /กดยอมรับการนำเข้า/i }); + fireEvent.click(commitButton); + + await waitFor(() => { + expect(mockMutateAsyncCommit).toHaveBeenCalledWith(expect.objectContaining({ + publicId: 'mig-1', + subject: 'Test Migration Doc', + category: 'RFA', + projectId: 'proj-1', + senderId: 'org-1', + receiverId: 'org-2', + issuedDate: '2026-06-01', + receivedDate: '2026-06-02', + body: 'Migration test body', + tags: ['Urgent'], + })); + }); + }); + + it('calls reject mutation on reject', async () => { + render(); + + const reviewButtons = screen.getAllByRole('button', { name: /รีวิว|ดูรายละเอียด/i }); + fireEvent.click(reviewButtons[0]); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /ปฏิเสธการนำเข้า/i })).toBeInTheDocument(); + }); + + const rejectButton = screen.getByRole('button', { name: /ปฏิเสธการนำเข้า/i }); + fireEvent.click(rejectButton); + + await waitFor(() => { + expect(window.confirm).toHaveBeenCalled(); + expect(mockMutateAsyncReject).toHaveBeenCalledWith(1); + }); + }); + + it('closes sheet when cancel is clicked', async () => { + render(); + + const reviewButtons = screen.getAllByRole('button', { name: /รีวิว|ดูรายละเอียด/i }); + fireEvent.click(reviewButtons[0]); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /ยกเลิก/i })).toBeInTheDocument(); + }); + + const cancelButton = screen.getByRole('button', { name: /ยกเลิก/i }); + fireEvent.click(cancelButton); + + // Wait for the sheet to be removed or hidden + await waitFor(() => { + expect(screen.queryByText('รีวิวการย้ายข้อมูลเอกสาร')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/components/numbering/__tests__/audit-logs-table.test.tsx b/frontend/components/numbering/__tests__/audit-logs-table.test.tsx new file mode 100644 index 00000000..584a14cd --- /dev/null +++ b/frontend/components/numbering/__tests__/audit-logs-table.test.tsx @@ -0,0 +1,79 @@ +// File: frontend/components/numbering/__tests__/audit-logs-table.test.tsx +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AuditLogsTable } from '../audit-logs-table'; +import { documentNumberingService } from '@/lib/services/document-numbering.service'; + +vi.mock('@/lib/services/document-numbering.service', () => ({ + documentNumberingService: { + getMetrics: vi.fn(), + }, +})); + +describe('AuditLogsTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders loading state initially', () => { + vi.mocked(documentNumberingService.getMetrics).mockImplementation(() => new Promise(() => {})); + render(); + expect(screen.getByText('Loading logs...')).toBeInTheDocument(); + }); + + it('renders empty state when no logs returned', async () => { + vi.mocked(documentNumberingService.getMetrics).mockResolvedValue({ audit: [] } as any); + render(); + + await waitFor(() => { + expect(screen.getByText('No logs found.')).toBeInTheDocument(); + }); + }); + + it('renders error state silently as empty state when API fails', async () => { + vi.mocked(documentNumberingService.getMetrics).mockRejectedValue(new Error('API failed')); + render(); + + await waitFor(() => { + expect(screen.getByText('No logs found.')).toBeInTheDocument(); + }); + }); + + it('renders logs correctly', async () => { + const mockLogs = [ + { + id: 1, + createdAt: '2023-10-27T10:00:00Z', + operation: 'GENERATE', + documentNumber: 'DOC-001', + createdBy: 'UserA', + status: 'SUCCESS', + }, + { + id: 2, + createdAt: '2023-10-27T11:00:00Z', + operation: 'VOID', + documentNumber: 'DOC-002', + createdBy: null, + status: 'FAILED', + }, + ]; + vi.mocked(documentNumberingService.getMetrics).mockResolvedValue({ audit: mockLogs } as any); + + render(); + + await waitFor(() => { + expect(screen.getByText('DOC-001')).toBeInTheDocument(); + }); + + expect(screen.getByText('GENERATE')).toBeInTheDocument(); + expect(screen.getByText('UserA')).toBeInTheDocument(); + expect(screen.getByText('SUCCESS')).toBeInTheDocument(); + + expect(screen.getByText('DOC-002')).toBeInTheDocument(); + expect(screen.getByText('VOID')).toBeInTheDocument(); + expect(screen.getByText('System')).toBeInTheDocument(); // Falls back to System + expect(screen.getByText('FAILED')).toBeInTheDocument(); + }); +}); diff --git a/frontend/components/numbering/__tests__/bulk-import-form.test.tsx b/frontend/components/numbering/__tests__/bulk-import-form.test.tsx new file mode 100644 index 00000000..75c1140c --- /dev/null +++ b/frontend/components/numbering/__tests__/bulk-import-form.test.tsx @@ -0,0 +1,83 @@ +// File: frontend/components/numbering/__tests__/bulk-import-form.test.tsx +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { BulkImportForm } from '../bulk-import-form'; +import { documentNumberingService } from '@/lib/services/document-numbering.service'; +import { toast } from 'sonner'; + +vi.mock('@/lib/services/document-numbering.service', () => ({ + documentNumberingService: { + bulkImport: vi.fn(), + }, +})); + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +describe('BulkImportForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders correctly', () => { + render(); + expect(screen.getByText('Bulk Import Numbers')).toBeInTheDocument(); + expect(screen.getByLabelText('CSV File')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Upload & Import' })).toBeDisabled(); + }); + + it('enables submit button when file is selected and handles successful upload', async () => { + const user = userEvent.setup(); + render(); + + const file = new File(['test'], 'test.csv', { type: 'text/csv' }); + const input = screen.getByLabelText('CSV File') as HTMLInputElement; + + await user.upload(input, file); + + const button = screen.getByRole('button', { name: 'Upload & Import' }); + expect(button).not.toBeDisabled(); + + vi.mocked(documentNumberingService.bulkImport).mockResolvedValue({} as any); + + await user.click(button); + + expect(documentNumberingService.bulkImport).toHaveBeenCalledWith(expect.any(FormData)); + const formDataArg = vi.mocked(documentNumberingService.bulkImport).mock.calls[0][0] as FormData; + expect(formDataArg.get('file')).toBe(file); + expect(formDataArg.get('projectId')).toBe('1'); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith('Bulk import initiated. Check audit logs for progress.'); + }); + + // File input reset means button is disabled again + expect(button).toBeDisabled(); + }); + + it('handles upload failure', async () => { + const user = userEvent.setup(); + render(); + + const file = new File(['test'], 'test.csv', { type: 'text/csv' }); + const input = screen.getByLabelText('CSV File') as HTMLInputElement; + + await user.upload(input, file); + + const button = screen.getByRole('button', { name: 'Upload & Import' }); + + vi.mocked(documentNumberingService.bulkImport).mockRejectedValue(new Error('Failed')); + + await user.click(button); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Failed to import numbers.'); + }); + }); +}); diff --git a/frontend/components/numbering/__tests__/cancel-number-form.test.tsx b/frontend/components/numbering/__tests__/cancel-number-form.test.tsx new file mode 100644 index 00000000..0419da23 --- /dev/null +++ b/frontend/components/numbering/__tests__/cancel-number-form.test.tsx @@ -0,0 +1,113 @@ +// File: frontend/components/numbering/__tests__/cancel-number-form.test.tsx +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CancelNumberForm } from '../cancel-number-form'; +import { documentNumberingService } from '@/lib/services/document-numbering.service'; +import { toast } from 'sonner'; + +vi.mock('@/lib/services/document-numbering.service', () => ({ + documentNumberingService: { + cancelNumber: vi.fn(), + }, +})); + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +describe('CancelNumberForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders correctly', () => { + render(); + expect(screen.getByRole('heading', { name: 'Cancel Number' })).toBeInTheDocument(); + expect(screen.getByLabelText('Document Number')).toBeInTheDocument(); + expect(screen.getByLabelText('Reason')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel Number' })).toBeInTheDocument(); + }); + + it('shows validation error for empty fields', async () => { + const user = userEvent.setup(); + render(); + + const button = screen.getByRole('button', { name: 'Cancel Number' }); + await user.click(button); + + await waitFor(() => { + expect(screen.getByText('Document Number is required')).toBeInTheDocument(); + expect(screen.getByText('Reason must be at least 5 characters')).toBeInTheDocument(); + }); + + expect(documentNumberingService.cancelNumber).not.toHaveBeenCalled(); + }); + + it('shows validation error for short reason', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('Document Number'), 'DOC-001'); + await user.type(screen.getByLabelText('Reason'), 'abc'); // too short + + const button = screen.getByRole('button', { name: 'Cancel Number' }); + await user.click(button); + + await waitFor(() => { + expect(screen.getByText('Reason must be at least 5 characters')).toBeInTheDocument(); + }); + + expect(documentNumberingService.cancelNumber).not.toHaveBeenCalled(); + }); + + it('handles successful cancellation', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('Document Number'), 'DOC-001'); + await user.type(screen.getByLabelText('Reason'), 'Generated by mistake'); + + vi.mocked(documentNumberingService.cancelNumber).mockResolvedValue({} as any); + + const button = screen.getByRole('button', { name: 'Cancel Number' }); + await user.click(button); + + expect(documentNumberingService.cancelNumber).toHaveBeenCalledWith({ + documentNumber: 'DOC-001', + reason: 'Generated by mistake', + }); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith('Number cancelled successfully.'); + }); + + // Check if form was reset + expect(screen.getByLabelText('Document Number')).toHaveValue(''); + expect(screen.getByLabelText('Reason')).toHaveValue(''); + }); + + it('handles cancellation failure', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('Document Number'), 'DOC-001'); + await user.type(screen.getByLabelText('Reason'), 'Generated by mistake'); + + vi.mocked(documentNumberingService.cancelNumber).mockRejectedValue(new Error('Failed')); + + const button = screen.getByRole('button', { name: 'Cancel Number' }); + await user.click(button); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Failed to cancel number. It may not exist or is already cancelled.'); + }); + + // Form is not reset on error + expect(screen.getByLabelText('Document Number')).toHaveValue('DOC-001'); + }); +}); diff --git a/frontend/components/numbering/__tests__/template-editor.test.tsx b/frontend/components/numbering/__tests__/template-editor.test.tsx new file mode 100644 index 00000000..8b9cf268 --- /dev/null +++ b/frontend/components/numbering/__tests__/template-editor.test.tsx @@ -0,0 +1,158 @@ +// File: frontend/components/numbering/__tests__/template-editor.test.tsx +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TemplateEditor } from '../template-editor'; +import { CorrespondenceType, Discipline } from '@/types/master-data'; + +const mockTypes: CorrespondenceType[] = [ + { publicId: 'type1', typeCode: 'RFA', typeName: 'Request for Approval', isActive: true } as any, + { publicId: 'type2', typeCode: 'TRN', typeName: 'Transmittal', isActive: true } as any, +]; + +const mockDisciplines: Discipline[] = [ + { publicId: 'disc1', disciplineCode: 'STR', codeNameEn: 'Structural', isActive: true } as any, +]; + +describe('TemplateEditor', () => { + const onSave = vi.fn(); + const onCancel = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders correctly for new template', () => { + render( + + ); + + expect(screen.getByText('New Template')).toBeInTheDocument(); + expect(screen.getByText('Project: Test Project')).toBeInTheDocument(); + expect(screen.getByLabelText('Template Format *')).toHaveValue(''); + expect(screen.getByRole('button', { name: 'Save Template' })).toBeDisabled(); + }); + + it('renders correctly with existing template data', () => { + render( + + ); + + expect(screen.getByText('Edit Template')).toBeInTheDocument(); + expect(screen.getByLabelText('Template Format *')).toHaveValue('{ORG}-{TYPE}-{SEQ:4}'); + expect(screen.getByRole('button', { name: 'Save Template' })).not.toBeDisabled(); + }); + + it('allows inserting variables into format', async () => { + const user = userEvent.setup(); + render( + + ); + + const formatInput = screen.getByLabelText('Template Format *'); + await user.type(formatInput, 'TEST-'); + + // Click a variable button + const orgButton = screen.getByRole('button', { name: '{ORG}' }); + await user.click(orgButton); + + expect(formatInput).toHaveValue('TEST-{ORG}'); + }); + + it('updates preview when format changes', async () => { + const user = userEvent.setup(); + render( + + ); + + const formatInput = screen.getByLabelText('Template Format *'); + fireEvent.change(formatInput, { target: { value: '{PROJECT}-{SEQ:4}' } }); + + expect(screen.getByText('LCBP3-0001')).toBeInTheDocument(); + }); + + it('calls onCancel when cancel button clicked', async () => { + const user = userEvent.setup(); + render( + + ); + + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + await user.click(cancelButton); + + expect(onCancel).toHaveBeenCalled(); + }); + + it('calls onSave with form data', async () => { + const user = userEvent.setup(); + render( + + ); + + const formatInput = screen.getByLabelText('Template Format *'); + fireEvent.change(formatInput, { target: { value: '{PROJECT}-{SEQ:4}' } }); + + // We cannot easily test Radix Select interactions in jsdom without massive pointer mocking, + // so we'll test the default values submission first. + + const saveButton = screen.getByRole('button', { name: 'Save Template' }); + await user.click(saveButton); + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + projectId: 1, + formatTemplate: '{PROJECT}-{SEQ:4}', + resetSequenceYearly: true, + correspondenceTypeId: null, + disciplineId: 0, + })); + }); +}); diff --git a/frontend/components/numbering/__tests__/template-tester.test.tsx b/frontend/components/numbering/__tests__/template-tester.test.tsx new file mode 100644 index 00000000..4f521913 --- /dev/null +++ b/frontend/components/numbering/__tests__/template-tester.test.tsx @@ -0,0 +1,87 @@ +// File: frontend/components/numbering/__tests__/template-tester.test.tsx +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TemplateTester } from '../template-tester'; +import { numberingApi } from '@/lib/api/numbering'; + +vi.mock('@/lib/api/numbering', () => ({ + numberingApi: { + previewNumber: vi.fn(), + }, +})); + +vi.mock('@/hooks/use-master-data', () => ({ + useOrganizations: vi.fn(() => ({ data: [{ publicId: 'org1', organizationCode: 'ORG', organizationName: 'Org1' }] })), + useCorrespondenceTypes: vi.fn(() => ({ data: [{ id: 1, typeCode: 'TYPE', typeName: 'Type1' }] })), + useContracts: vi.fn(() => ({ data: [{ id: 1 }] })), + useDisciplines: vi.fn(() => ({ data: [{ id: 1, disciplineCode: 'DISC' }] })), +})); + +describe('TemplateTester', () => { + const onOpenChange = vi.fn(); + const mockTemplate = { + projectId: 1, + formatTemplate: '{ORG}-{TYPE}-{SEQ:4}', + } as any; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders correctly when open', () => { + render(); + + expect(screen.getByText('Test Number Generation')).toBeInTheDocument(); + expect(screen.getByText('{ORG}-{TYPE}-{SEQ:4}')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Generate Test Number' })).toBeInTheDocument(); + }); + + it('does not render when closed', () => { + render(); + expect(screen.queryByText('Test Number Generation')).not.toBeInTheDocument(); + }); + + it('handles successful generation', async () => { + const user = userEvent.setup(); + render(); + + vi.mocked(numberingApi.previewNumber).mockResolvedValue({ + previewNumber: 'ORG-TYPE-0001', + isDefault: true, + } as any); + + const generateBtn = screen.getByRole('button', { name: 'Generate Test Number' }); + await user.click(generateBtn); + + expect(numberingApi.previewNumber).toHaveBeenCalledWith({ + projectId: 1, + originatorOrganizationId: '0', + recipientOrganizationId: '0', + correspondenceTypeId: 0, + disciplineId: 0, + year: new Date().getFullYear(), + }); + + await waitFor(() => { + expect(screen.getByText('ORG-TYPE-0001')).toBeInTheDocument(); + expect(screen.getByText('Default Template')).toBeInTheDocument(); + }); + }); + + it('handles API error', async () => { + const user = userEvent.setup(); + render(); + + vi.mocked(numberingApi.previewNumber).mockRejectedValue(new Error('Generation failed')); + + const generateBtn = screen.getByRole('button', { name: 'Generate Test Number' }); + await user.click(generateBtn); + + await waitFor(() => { + expect(screen.getByText('Error: Generation failed')).toBeInTheDocument(); + expect(screen.getByText('Generation Failed:')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/components/numbering/__tests__/void-replace-form.test.tsx b/frontend/components/numbering/__tests__/void-replace-form.test.tsx new file mode 100644 index 00000000..86e9e8e2 --- /dev/null +++ b/frontend/components/numbering/__tests__/void-replace-form.test.tsx @@ -0,0 +1,136 @@ +// File: frontend/components/numbering/__tests__/void-replace-form.test.tsx +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { VoidReplaceForm } from '../void-replace-form'; +import { documentNumberingService } from '@/lib/services/document-numbering.service'; +import { toast } from 'sonner'; + +vi.mock('@/lib/services/document-numbering.service', () => ({ + documentNumberingService: { + voidAndReplace: vi.fn(), + }, +})); + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +describe('VoidReplaceForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders correctly', () => { + render(); + expect(screen.getByText('Void & Replace Number')).toBeInTheDocument(); + expect(screen.getByLabelText('Document Number')).toBeInTheDocument(); + expect(screen.getByLabelText('Reason')).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: 'Generate Replacement?' })).not.toBeChecked(); + expect(screen.getByRole('button', { name: 'Void Number' })).toBeInTheDocument(); + }); + + it('shows validation errors for empty fields', async () => { + const user = userEvent.setup(); + render(); + + const button = screen.getByRole('button', { name: 'Void Number' }); + await user.click(button); + + await waitFor(() => { + expect(screen.getByText('Document Number is required')).toBeInTheDocument(); + expect(screen.getByText('Reason must be at least 5 characters')).toBeInTheDocument(); + }); + + expect(documentNumberingService.voidAndReplace).not.toHaveBeenCalled(); + }); + + it('shows validation error for short reason', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('Document Number'), 'DOC-001'); + await user.type(screen.getByLabelText('Reason'), '123'); // Too short + + const button = screen.getByRole('button', { name: 'Void Number' }); + await user.click(button); + + await waitFor(() => { + expect(screen.getByText('Reason must be at least 5 characters')).toBeInTheDocument(); + }); + + expect(documentNumberingService.voidAndReplace).not.toHaveBeenCalled(); + }); + + it('handles successful voiding without replacement', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('Document Number'), 'DOC-001'); + await user.type(screen.getByLabelText('Reason'), 'Voided because of typo'); + + vi.mocked(documentNumberingService.voidAndReplace).mockResolvedValue({} as any); + + const button = screen.getByRole('button', { name: 'Void Number' }); + await user.click(button); + + expect(documentNumberingService.voidAndReplace).toHaveBeenCalledWith({ + documentNumber: 'DOC-001', + reason: 'Voided because of typo', + replace: false, + projectId: 1, + }); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith('Number voided successfully. '); + }); + }); + + it('handles successful voiding with replacement', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('Document Number'), 'DOC-002'); + await user.type(screen.getByLabelText('Reason'), 'Voided because of typo'); + + const checkbox = screen.getByRole('checkbox', { name: 'Generate Replacement?' }); + await user.click(checkbox); + + vi.mocked(documentNumberingService.voidAndReplace).mockResolvedValue({} as any); + + const button = screen.getByRole('button', { name: 'Void Number' }); + await user.click(button); + + expect(documentNumberingService.voidAndReplace).toHaveBeenCalledWith({ + documentNumber: 'DOC-002', + reason: 'Voided because of typo', + replace: true, + projectId: 1, + }); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith('Number voided successfully. Replacement generated.'); + }); + }); + + it('handles API error', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('Document Number'), 'DOC-001'); + await user.type(screen.getByLabelText('Reason'), 'Voided because of typo'); + + vi.mocked(documentNumberingService.voidAndReplace).mockRejectedValue(new Error('Failed')); + + const button = screen.getByRole('button', { name: 'Void Number' }); + await user.click(button); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Failed to void number. Check if it exists.'); + }); + }); +}); diff --git a/frontend/components/numbering/template-editor.tsx b/frontend/components/numbering/template-editor.tsx index 41e77564..68398bad 100644 --- a/frontend/components/numbering/template-editor.tsx +++ b/frontend/components/numbering/template-editor.tsx @@ -164,8 +164,9 @@ export function TemplateEditor({ {/* Format Column */}
- + setFormat(e.target.value)} placeholder="{ORG}-{TYPE}-{SEQ:4}" diff --git a/frontend/contracts/frontend-types.ts b/frontend/contracts/frontend-types.ts index 5af4afe9..8ac3081a 100644 --- a/frontend/contracts/frontend-types.ts +++ b/frontend/contracts/frontend-types.ts @@ -86,7 +86,7 @@ export interface UpdateContextConfigDto { } export const PLACEHOLDER_REQUIREMENTS: Record = { - ocr_extraction: ['{{ocr_text}}', '{{master_data_context}}'], + ocr_extraction: ['{{ocr_text}}'], rag_query_prompt: ['{{query}}', '{{context}}'], rag_prep_prompt: ['{{text}}'], classification_prompt: ['{{document_text}}'], diff --git a/frontend/package.json b/frontend/package.json index ab4d90d0..d1def80d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -77,7 +77,7 @@ "@typescript-eslint/eslint-plugin": "^8.57.1", "@typescript-eslint/parser": "^8.57.1", "@vitejs/plugin-react": "^5.2.0", - "@vitest/coverage-v8": "^4.1.6", + "@vitest/coverage-v8": "^4.1.8", "autoprefixer": "^10.4.27", "baseline-browser-mapping": "^2.10.8", "eslint": "^9.39.1", @@ -91,6 +91,6 @@ "tailwindcss": "3.4.3", "typescript": "^5.9.3", "vite": "7.3.2", - "vitest": "^4.1.6" + "vitest": "^4.1.9" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 090004e2..71530401 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -509,8 +509,8 @@ importers: specifier: ^5.2.0 version: 5.2.0(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3)) '@vitest/coverage-v8': - specifier: ^4.1.6 - version: 4.1.6(vitest@4.1.8) + specifier: ^4.1.8 + version: 4.1.9(vitest@4.1.9) autoprefixer: specifier: ^10.4.27 version: 10.4.27(postcss@8.5.10) @@ -551,8 +551,8 @@ importers: specifier: 7.3.2 version: 7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3) vitest: - specifier: ^4.1.6 - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@4.1.6)(jsdom@29.0.0(@noble/hashes@1.8.0))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3)) + specifier: ^4.1.9 + version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@4.1.9)(jsdom@29.0.0(@noble/hashes@1.8.0))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3)) packages: @@ -4071,20 +4071,20 @@ packages: peerDependencies: vite: '>=7.3.2' - '@vitest/coverage-v8@4.1.6': - resolution: {integrity: sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==} + '@vitest/coverage-v8@4.1.9': + resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==} peerDependencies: - '@vitest/browser': 4.1.6 - vitest: 4.1.6 + '@vitest/browser': 4.1.9 + vitest: 4.1.9 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@4.1.8': - resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} - '@vitest/mocker@4.1.8': - resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} peerDependencies: msw: ^2.4.9 vite: '>=7.3.2' @@ -4094,26 +4094,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.6': - resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} - '@vitest/pretty-format@4.1.8': - resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} - '@vitest/runner@4.1.8': - resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} - '@vitest/snapshot@4.1.8': - resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} - '@vitest/spy@4.1.8': - resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} - - '@vitest/utils@4.1.6': - resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} - - '@vitest/utils@4.1.8': - resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -8438,20 +8432,20 @@ packages: yaml: optional: true - vitest@4.1.8: - resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.8 - '@vitest/browser-preview': 4.1.8 - '@vitest/browser-webdriverio': 4.1.8 - '@vitest/coverage-istanbul': 4.1.8 - '@vitest/coverage-v8': 4.1.8 - '@vitest/ui': 4.1.8 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 happy-dom: '*' jsdom: '*' vite: '>=7.3.2' @@ -12838,10 +12832,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.1.6(vitest@4.1.8)': + '@vitest/coverage-v8@4.1.9(vitest@4.1.9)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.6 + '@vitest/utils': 4.1.9 ast-v8-to-istanbul: 1.0.0 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -12850,56 +12844,46 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@4.1.6)(jsdom@29.0.0(@noble/hashes@1.8.0))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3)) + vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@4.1.9)(jsdom@29.0.0(@noble/hashes@1.8.0))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3)) - '@vitest/expect@4.1.8': + '@vitest/expect@4.1.9': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.8(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3))': + '@vitest/mocker@4.1.9(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3))': dependencies: - '@vitest/spy': 4.1.8 + '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3) - '@vitest/pretty-format@4.1.6': + '@vitest/pretty-format@4.1.9': dependencies: tinyrainbow: 3.1.0 - '@vitest/pretty-format@4.1.8': + '@vitest/runner@4.1.9': dependencies: - tinyrainbow: 3.1.0 - - '@vitest/runner@4.1.8': - dependencies: - '@vitest/utils': 4.1.8 + '@vitest/utils': 4.1.9 pathe: 2.0.3 - '@vitest/snapshot@4.1.8': + '@vitest/snapshot@4.1.9': dependencies: - '@vitest/pretty-format': 4.1.8 - '@vitest/utils': 4.1.8 + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.8': {} + '@vitest/spy@4.1.9': {} - '@vitest/utils@4.1.6': + '@vitest/utils@4.1.9': dependencies: - '@vitest/pretty-format': 4.1.6 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 - - '@vitest/utils@4.1.8': - dependencies: - '@vitest/pretty-format': 4.1.8 + '@vitest/pretty-format': 4.1.9 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -15240,7 +15224,7 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 7.7.4 @@ -17784,15 +17768,15 @@ snapshots: terser: 5.44.1 yaml: 2.8.3 - vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@4.1.6)(jsdom@29.0.0(@noble/hashes@1.8.0))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3)): + vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@4.1.9)(jsdom@29.0.0(@noble/hashes@1.8.0))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3)): dependencies: - '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3)) - '@vitest/pretty-format': 4.1.8 - '@vitest/runner': 4.1.8 - '@vitest/snapshot': 4.1.8 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.1)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 es-module-lexer: 2.0.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -17809,7 +17793,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 25.5.0 - '@vitest/coverage-v8': 4.1.6(vitest@4.1.8) + '@vitest/coverage-v8': 4.1.9(vitest@4.1.9) jsdom: 29.0.0(@noble/hashes@1.8.0) transitivePeerDependencies: - msw diff --git a/specs/03-Data-and-Storage/deltas/2026-06-15-fix-ai-prompts-columns.sql b/specs/03-Data-and-Storage/deltas/2026-06-15-fix-ai-prompts-columns.sql new file mode 100644 index 00000000..96ca6b29 --- /dev/null +++ b/specs/03-Data-and-Storage/deltas/2026-06-15-fix-ai-prompts-columns.sql @@ -0,0 +1,10 @@ +-- Delta: 2026-06-15-fix-ai-prompts-columns.sql +-- Fix: (1) Drop duplicate camelCase publicId column (TypeORM mapping bug) +-- (2) Add version column for optimistic locking (T066) +-- ADR-009: Edit schema directly, no TypeORM migrations + +-- ลบ duplicate column ที่ TypeORM สร้างผิด (camelCase แทน snake_case) +ALTER TABLE ai_prompts DROP COLUMN IF EXISTS `publicId`; + +-- เพิ่ม version column สำหรับ @VersionColumn (optimistic locking) +ALTER TABLE ai_prompts ADD COLUMN IF NOT EXISTS `version` INT NOT NULL DEFAULT 1; diff --git a/specs/06-Decision-Records/ADR-037-unified-prompt-management-ux-ui.md b/specs/06-Decision-Records/ADR-037-unified-prompt-management-ux-ui.md index 1dc62fd8..1aad1da2 100644 --- a/specs/06-Decision-Records/ADR-037-unified-prompt-management-ux-ui.md +++ b/specs/06-Decision-Records/ADR-037-unified-prompt-management-ux-ui.md @@ -120,7 +120,7 @@ VALUES ('classification_prompt', 1, '