690419:1411 feat: update CI/CD to use SSH key authentication #05
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
// ADR-021 T027a: ทดสอบ HTTP 503 / 409 / 403 error handling ใน useWorkflowAction
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import { createTestQueryClient } from '@/lib/test-utils';
|
||||
import { useWorkflowAction } from '../use-workflow-action';
|
||||
import { workflowEngineService } from '@/lib/services/workflow-engine.service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Mock service
|
||||
vi.mock('@/lib/services/workflow-engine.service', () => ({
|
||||
workflowEngineService: {
|
||||
transition: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock sonner toast
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper — สร้าง ApiErrorResponse ปลอมตามรูปแบบ parseApiError
|
||||
function makeApiError(statusCode: number, message: string, recoveryActions?: string[]) {
|
||||
return {
|
||||
error: {
|
||||
type: 'BUSINESS',
|
||||
code: 'HTTP_ERROR',
|
||||
message,
|
||||
severity: statusCode >= 500 ? 'HIGH' : 'MEDIUM',
|
||||
timestamp: new Date().toISOString(),
|
||||
statusCode,
|
||||
recoveryActions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('useWorkflowAction — T027a error handling (Clarify Q1+Q2)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('Q2 (503): should show "ระบบยุ่ง" toast when Redlock Fail-closed', async () => {
|
||||
vi.mocked(workflowEngineService.transition).mockRejectedValue(
|
||||
makeApiError(503, 'ระบบยุ่งชั่วคราว กรุณาลองใหม่ภายหลัง')
|
||||
);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useWorkflowAction('inst-1'), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
result.current.mutate({ action: 'APPROVE' });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'ระบบยุ่งชั่วคราว กรุณาลองใหม่อีกครั้งภายหลัง',
|
||||
expect.objectContaining({
|
||||
description: expect.stringContaining('ข้อมูลของคุณปลอดภัย'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('Q1 (409): should show state violation toast with backend message', async () => {
|
||||
vi.mocked(workflowEngineService.transition).mockRejectedValue(
|
||||
makeApiError(409, 'ไม่สามารถอัปโหลดไฟล์ในสถานะนี้ได้', [
|
||||
'อนุญาตเฉพาะสถานะ PENDING_REVIEW หรือ PENDING_APPROVAL',
|
||||
])
|
||||
);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useWorkflowAction('inst-1'), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
result.current.mutate({ action: 'APPROVE', attachmentPublicIds: ['a1'] });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'ไม่สามารถอัปโหลดไฟล์ในสถานะนี้ได้',
|
||||
expect.objectContaining({
|
||||
description: 'อนุญาตเฉพาะสถานะ PENDING_REVIEW หรือ PENDING_APPROVAL',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('403: should show unauthorized toast', async () => {
|
||||
vi.mocked(workflowEngineService.transition).mockRejectedValue(
|
||||
makeApiError(403, 'ไม่มีสิทธิ์', ['ติดต่อ Admin'])
|
||||
);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useWorkflowAction('inst-1'), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
result.current.mutate({ action: 'APPROVE' });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'คุณไม่มีสิทธิ์ดำเนินการในขั้นตอนนี้',
|
||||
expect.objectContaining({
|
||||
description: 'ติดต่อ Admin',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should show success toast on 200', async () => {
|
||||
vi.mocked(workflowEngineService.transition).mockResolvedValue({
|
||||
success: true,
|
||||
nextState: 'APPROVED',
|
||||
});
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useWorkflowAction('inst-1'), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
result.current.mutate({ action: 'APPROVE' });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(toast.success).toHaveBeenCalledWith('ดำเนินการเรียบร้อยแล้ว');
|
||||
});
|
||||
|
||||
it('should reject when instanceId is undefined', async () => {
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useWorkflowAction(undefined), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
result.current.mutate({ action: 'APPROVE' });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ไม่พบ Workflow Instance ID')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// ADR-021 T027: useWorkflowAction — hook สำหรับส่ง Approve/Reject/Return action
|
||||
// สร้าง Idempotency-Key ครั้งเดียวต่อ action intent (via useState) ป้องกัน duplicate submission
|
||||
// ADR-021 T027a (Clarify Q1+Q2): จัดการ HTTP 409 (state violation) และ 503 (Redlock fail-closed)
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
@@ -7,8 +8,19 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { toast } from 'sonner';
|
||||
import { workflowEngineService } from '@/lib/services/workflow-engine.service';
|
||||
import type { ApiErrorResponse } from '@/lib/api/client';
|
||||
import type { WorkflowTransitionWithAttachmentsDto } from '@/types/dto/workflow-engine/workflow-engine.dto';
|
||||
|
||||
// Type guard — ตรวจสอบว่า error ที่ได้มาเป็น ApiErrorResponse (จาก parseApiError interceptor)
|
||||
function isApiErrorResponse(err: unknown): err is ApiErrorResponse {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'error' in err &&
|
||||
typeof (err as ApiErrorResponse).error === 'object'
|
||||
);
|
||||
}
|
||||
|
||||
export function useWorkflowAction(instanceId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -41,8 +53,48 @@ export function useWorkflowAction(instanceId: string | undefined) {
|
||||
|
||||
toast.success('ดำเนินการเรียบร้อยแล้ว');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่');
|
||||
onError: (error: unknown) => {
|
||||
// ADR-021 T027a: แยก handler ตาม status code
|
||||
if (isApiErrorResponse(error)) {
|
||||
const { statusCode, message, recoveryActions } = error.error;
|
||||
|
||||
// Clarify Q2: 503 Service Unavailable (Redlock Fail-closed)
|
||||
if (statusCode === 503) {
|
||||
toast.error('ระบบยุ่งชั่วคราว กรุณาลองใหม่อีกครั้งภายหลัง', {
|
||||
description: 'การทำรายการไม่ถูกดำเนินการ ข้อมูลของคุณปลอดภัย',
|
||||
});
|
||||
// Keep idempotencyKey unchanged — user can retry ด้วย key เดิม
|
||||
return;
|
||||
}
|
||||
|
||||
// Clarify Q1: 409 Conflict (ไม่อยู่ในสถานะที่อนุญาตให้อัปโหลด)
|
||||
if (statusCode === 409) {
|
||||
toast.error(message || 'ไม่สามารถดำเนินการในสถานะนี้ได้', {
|
||||
description: recoveryActions?.[0],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 403 Forbidden — ไม่มีสิทธิ์
|
||||
if (statusCode === 403) {
|
||||
toast.error('คุณไม่มีสิทธิ์ดำเนินการในขั้นตอนนี้', {
|
||||
description: recoveryActions?.[0],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback — ใช้ message จาก backend
|
||||
toast.error(message || 'เกิดข้อผิดพลาด กรุณาลองใหม่');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback — plain Error (เช่น ไม่พบ instanceId)
|
||||
if (error instanceof Error) {
|
||||
toast.error(error.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error('เกิดข้อผิดพลาดที่ไม่คาดคิด กรุณาลองใหม่');
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user