690419:1831 feat: update CI/CD to use SSH key authentication #05
This commit is contained in:
@@ -91,9 +91,39 @@ describe('useWorkflowAction — T027a error handling (Clarify Q1+Q2)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('403: should show unauthorized toast', async () => {
|
||||
it('M1 (403): should use backend-provided message instead of hardcoded string', async () => {
|
||||
// backend ส่ง message แบบ contextual (cross-contract) — frontend ต้อง preserve
|
||||
vi.mocked(workflowEngineService.transition).mockRejectedValue(
|
||||
makeApiError(403, 'ไม่มีสิทธิ์', ['ติดต่อ Admin'])
|
||||
makeApiError(
|
||||
403,
|
||||
'คุณไม่มีสิทธิ์เข้าถึง Workflow ของสัญญานี้',
|
||||
['ตรวจสอบสิทธิ์กับ Project 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);
|
||||
});
|
||||
|
||||
// ✓ toast ต้องใช้ backend message (ไม่ใช่ "คุณไม่มีสิทธิ์ดำเนินการในขั้นตอนนี้" generic)
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'คุณไม่มีสิทธิ์เข้าถึง Workflow ของสัญญานี้',
|
||||
expect.objectContaining({
|
||||
description: 'ตรวจสอบสิทธิ์กับ Project Admin',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('M1 (403): should fallback to generic message when backend message missing', async () => {
|
||||
vi.mocked(workflowEngineService.transition).mockRejectedValue(
|
||||
makeApiError(403, '', ['ติดต่อ Admin'])
|
||||
);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
@@ -115,6 +145,81 @@ describe('useWorkflowAction — T027a error handling (Clarify Q1+Q2)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('M3 (409): should reset idempotency key after 409 so retry uses fresh key', async () => {
|
||||
// First call → 409
|
||||
vi.mocked(workflowEngineService.transition).mockRejectedValueOnce(
|
||||
makeApiError(409, 'ไม่สามารถอัปโหลดในสถานะนี้ได้', ['รีเฟรชหน้า'])
|
||||
);
|
||||
// Second call → success
|
||||
vi.mocked(workflowEngineService.transition).mockResolvedValueOnce({
|
||||
success: true,
|
||||
nextState: 'APPROVED',
|
||||
});
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useWorkflowAction('inst-1'), { wrapper });
|
||||
|
||||
// First mutate → 409
|
||||
await act(async () => {
|
||||
result.current.mutate({ action: 'APPROVE' });
|
||||
});
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
const firstKey = vi
|
||||
.mocked(workflowEngineService.transition)
|
||||
.mock.calls[0][2];
|
||||
|
||||
// Second mutate → success
|
||||
await act(async () => {
|
||||
result.current.mutate({ action: 'APPROVE' });
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
const secondKey = vi
|
||||
.mocked(workflowEngineService.transition)
|
||||
.mock.calls[1][2];
|
||||
|
||||
// ✓ Key ต้องแตกต่างกัน (reset แล้วหลัง 409)
|
||||
expect(firstKey).not.toBe(secondKey);
|
||||
expect(firstKey).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(secondKey).toMatch(/^[0-9a-f-]{36}$/);
|
||||
});
|
||||
|
||||
it('M3 (503): should NOT reset idempotency key (user can retry with same key)', async () => {
|
||||
// First call → 503
|
||||
vi.mocked(workflowEngineService.transition).mockRejectedValueOnce(
|
||||
makeApiError(503, 'ระบบยุ่ง')
|
||||
);
|
||||
// Second call → success
|
||||
vi.mocked(workflowEngineService.transition).mockResolvedValueOnce({
|
||||
success: true,
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
const firstKey = vi
|
||||
.mocked(workflowEngineService.transition)
|
||||
.mock.calls[0][2];
|
||||
|
||||
await act(async () => {
|
||||
result.current.mutate({ action: 'APPROVE' });
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
const secondKey = vi
|
||||
.mocked(workflowEngineService.transition)
|
||||
.mock.calls[1][2];
|
||||
|
||||
// ✓ Key ต้องเหมือนเดิม (503 = retryable, same intent)
|
||||
expect(firstKey).toBe(secondKey);
|
||||
});
|
||||
|
||||
it('should show success toast on 200', async () => {
|
||||
vi.mocked(workflowEngineService.transition).mockResolvedValue({
|
||||
success: true,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import apiClient from '../lib/api/client';
|
||||
|
||||
export interface RagCitation {
|
||||
chunkId: string;
|
||||
docNumber: string | null;
|
||||
docType: string;
|
||||
revision: string | null;
|
||||
snippet: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface RagQueryRequest {
|
||||
question: string;
|
||||
projectPublicId: string;
|
||||
}
|
||||
|
||||
export interface RagQueryResponse {
|
||||
answer: string;
|
||||
citations: RagCitation[];
|
||||
confidence: number;
|
||||
usedFallbackModel: boolean;
|
||||
cachedAt?: string;
|
||||
}
|
||||
|
||||
export function useRagQuery() {
|
||||
return useMutation<RagQueryResponse, Error, RagQueryRequest>({
|
||||
mutationFn: async (payload) => {
|
||||
const idempotencyKey = `rag-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const res = await apiClient.post<{ data: RagQueryResponse }>('/rag/query', payload, {
|
||||
headers: { 'Idempotency-Key': idempotencyKey },
|
||||
});
|
||||
return res.data.data;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -12,13 +12,13 @@ import type { ApiErrorResponse } from '@/lib/api/client';
|
||||
import type { WorkflowTransitionWithAttachmentsDto } from '@/types/dto/workflow-engine/workflow-engine.dto';
|
||||
|
||||
// Type guard — ตรวจสอบว่า error ที่ได้มาเป็น ApiErrorResponse (จาก parseApiError interceptor)
|
||||
// S3: ป้องกัน edge case `{ error: null }` ซึ่ง typeof null === 'object' แต่ destructure จะ throw
|
||||
function isApiErrorResponse(err: unknown): err is ApiErrorResponse {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'error' in err &&
|
||||
typeof (err as ApiErrorResponse).error === 'object'
|
||||
);
|
||||
if (typeof err !== 'object' || err === null || !('error' in err)) {
|
||||
return false;
|
||||
}
|
||||
const inner = (err as { error: unknown }).error;
|
||||
return typeof inner === 'object' && inner !== null;
|
||||
}
|
||||
|
||||
export function useWorkflowAction(instanceId: string | undefined) {
|
||||
@@ -69,15 +69,17 @@ export function useWorkflowAction(instanceId: string | undefined) {
|
||||
|
||||
// Clarify Q1: 409 Conflict (ไม่อยู่ในสถานะที่อนุญาตให้อัปโหลด)
|
||||
if (statusCode === 409) {
|
||||
// M3: reset idempotency key — user intent กับ state เดิมใช้ไม่ได้แล้ว
|
||||
setIdempotencyKey(uuidv4());
|
||||
toast.error(message || 'ไม่สามารถดำเนินการในสถานะนี้ได้', {
|
||||
description: recoveryActions?.[0],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 403 Forbidden — ไม่มีสิทธิ์
|
||||
// 403 Forbidden — ไม่มีสิทธิ์ (M1: ใช้ message จาก backend เพื่อคง context)
|
||||
if (statusCode === 403) {
|
||||
toast.error('คุณไม่มีสิทธิ์ดำเนินการในขั้นตอนนี้', {
|
||||
toast.error(message || 'คุณไม่มีสิทธิ์ดำเนินการในขั้นตอนนี้', {
|
||||
description: recoveryActions?.[0],
|
||||
});
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user