You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
206 lines
7.1 KiB
206 lines
7.1 KiB
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { QuestionField } from "@/lib/schema-adapter";
|
|
import { QuestionPhoto } from "./question-photo";
|
|
|
|
let answerMap: Record<string, unknown> = {};
|
|
const mockSetAnswerValue = vi.fn((q: QuestionField, val: unknown) => {
|
|
answerMap[q.id] = val;
|
|
});
|
|
const mockRegisterPendingUpload = vi.fn();
|
|
|
|
const mockUploadTmpMedia = vi.fn(async (file: File) => ({
|
|
path: `/media/tmp/${file.name}`,
|
|
name: file.name,
|
|
}));
|
|
|
|
vi.mock("@/hooks/marriage/use-upload-tmp-media", () => ({
|
|
useUploadTmpMediaMutation: ({
|
|
onSuccess,
|
|
onError,
|
|
}: {
|
|
onSuccess?: (data: any) => void;
|
|
onError?: (err: any) => void;
|
|
}) => ({
|
|
mutate: (file: File) => {
|
|
mockUploadTmpMedia(file)
|
|
.then((res) => onSuccess?.(res))
|
|
.catch((err) => onError?.(err));
|
|
},
|
|
mutateAsync: async (file: File) => {
|
|
try {
|
|
const res = await mockUploadTmpMedia(file);
|
|
onSuccess?.(res);
|
|
return res;
|
|
} catch (err) {
|
|
onError?.(err);
|
|
throw err;
|
|
}
|
|
},
|
|
isPending: false,
|
|
isError: false,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("./question-answer-storage", () => ({
|
|
useQuestionAnswers: () => ({
|
|
getAnswerValue: (q: QuestionField) => answerMap[q.id] ?? null,
|
|
setAnswerValue: mockSetAnswerValue,
|
|
registerPendingUpload: mockRegisterPendingUpload,
|
|
isLoading: false,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/translations/provider", () => ({
|
|
useI18n: () => ({
|
|
locale: "en",
|
|
dictionary: {
|
|
"Photo upload failed. Please try again.": "Photo upload failed. Please try again.",
|
|
"Failed to load image. Please try uploading again.": "Failed to load image. Please try uploading again.",
|
|
},
|
|
}),
|
|
}));
|
|
|
|
const mockPhotoQuestion: QuestionField = {
|
|
id: "documents_verification.face_photo",
|
|
title: "Face Photo",
|
|
type: "photo",
|
|
order: 1,
|
|
required: true,
|
|
baseRequired: true,
|
|
isVisible: true,
|
|
description: "Recent clear face photo",
|
|
tooltip: "",
|
|
extras: {
|
|
options: [".jpg", ".jpeg", ".png"],
|
|
} as any,
|
|
options: [],
|
|
};
|
|
|
|
describe("QuestionPhoto Component", () => {
|
|
beforeEach(() => {
|
|
answerMap = {};
|
|
mockSetAnswerValue.mockClear();
|
|
mockUploadTmpMedia.mockClear();
|
|
mockRegisterPendingUpload.mockClear();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
it("renders default avatar placeholder when no photo uploaded", () => {
|
|
const { container } = render(<QuestionPhoto question={mockPhotoQuestion} />);
|
|
|
|
expect(screen.getByText(/Face/)).toBeInTheDocument();
|
|
expect(screen.getByText(/Photo/)).toBeInTheDocument();
|
|
expect(screen.getByText("Recent clear face photo")).toBeInTheDocument();
|
|
// Default placeholder SVG is rendered
|
|
expect(container.querySelector("img[src*='Frame 2095586679.svg']")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders uploaded image and camera badge when answer is provided", () => {
|
|
answerMap[mockPhotoQuestion.id] = "https://example.com/avatar.jpg";
|
|
const { container } = render(<QuestionPhoto question={mockPhotoQuestion} />);
|
|
|
|
const img = container.querySelector("img[src='https://example.com/avatar.jpg']");
|
|
expect(img).toBeInTheDocument();
|
|
expect(img).toHaveClass("rounded-full");
|
|
// Camera badge rendered
|
|
expect(container.querySelector(".bg-\\[\\#F0445B\\]")).toBeInTheDocument();
|
|
});
|
|
|
|
it("triggers upload and renders circular spinner without black background overlay during file selection", async () => {
|
|
let resolveUpload!: (val: any) => void;
|
|
mockUploadTmpMedia.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveUpload = resolve;
|
|
}),
|
|
);
|
|
|
|
const { container } = render(<QuestionPhoto question={mockPhotoQuestion} />);
|
|
|
|
const fileInput = container.querySelector("input[type='file']") as HTMLInputElement;
|
|
const testFile = new File(["dummy content"], "my-photo.jpg", { type: "image/jpeg" });
|
|
|
|
const originalCreateObjectURL = URL.createObjectURL;
|
|
URL.createObjectURL = vi.fn(() => "blob:http://localhost/test-blob");
|
|
|
|
try {
|
|
fireEvent.change(fileInput, { target: { files: [testFile] } });
|
|
|
|
await waitFor(() => {
|
|
const spinner = container.querySelector(".animate-spin");
|
|
expect(spinner).toBeInTheDocument();
|
|
expect(spinner).toHaveClass("border-[#F0445B]/25");
|
|
});
|
|
|
|
expect(container.querySelector(".bg-black\\/40")).toBeNull();
|
|
expect(container.querySelector(".bg-black")).toBeNull();
|
|
|
|
resolveUpload({ path: "/media/uploaded.jpg" });
|
|
|
|
await waitFor(() => {
|
|
expect(container.querySelector(".animate-spin")).toBeNull();
|
|
});
|
|
} finally {
|
|
URL.createObjectURL = originalCreateObjectURL;
|
|
}
|
|
});
|
|
|
|
it("shows error toast, reverts to default avatar placeholder, and clears answer when upload fails", async () => {
|
|
mockUploadTmpMedia.mockImplementationOnce(
|
|
() => Promise.reject(new Error("Network Error")),
|
|
);
|
|
|
|
const { container } = render(<QuestionPhoto question={mockPhotoQuestion} />);
|
|
const fileInput = container.querySelector("input[type='file']") as HTMLInputElement;
|
|
const testFile = new File(["dummy content"], "bad-photo.jpg", { type: "image/jpeg" });
|
|
|
|
const originalCreateObjectURL = URL.createObjectURL;
|
|
const originalRevokeObjectURL = URL.revokeObjectURL;
|
|
URL.createObjectURL = vi.fn(() => "blob:http://localhost/bad-blob");
|
|
URL.revokeObjectURL = vi.fn();
|
|
|
|
try {
|
|
fireEvent.change(fileInput, { target: { files: [testFile] } });
|
|
|
|
// Error toast should appear
|
|
await waitFor(() => {
|
|
// Toast message should appear
|
|
expect(screen.getByText("Photo upload failed. Please try again.")).toBeInTheDocument();
|
|
|
|
// Answer value is NOT cleared — matches yesterday's behavior
|
|
// (error just shows toast, does not wipe the form state)
|
|
expect(mockSetAnswerValue).not.toHaveBeenCalledWith(mockPhotoQuestion, null);
|
|
|
|
// File input is reset allowing re-upload
|
|
expect(fileInput.value).toBe("");
|
|
});
|
|
} finally {
|
|
URL.createObjectURL = originalCreateObjectURL;
|
|
URL.revokeObjectURL = originalRevokeObjectURL;
|
|
}
|
|
});
|
|
|
|
it("keeps stored answer when rendered image fails to load (preview-only failure)", async () => {
|
|
answerMap[mockPhotoQuestion.id] = "https://example.com/broken-avatar.jpg";
|
|
const { container } = render(<QuestionPhoto question={mockPhotoQuestion} />);
|
|
|
|
const img = container.querySelector("img[src='https://example.com/broken-avatar.jpg']") as HTMLImageElement;
|
|
expect(img).toBeInTheDocument();
|
|
|
|
// Trigger image error (e.g. 404 / ERR_CONNECTION_REFUSED)
|
|
fireEvent.error(img);
|
|
|
|
// The stored answer should NOT be cleared — the file is already on the server.
|
|
// This matches yesterday's behavior where preview errors never wiped the answer.
|
|
expect(mockSetAnswerValue).not.toHaveBeenCalledWith(mockPhotoQuestion, null);
|
|
|
|
// The component should remain in "answered" state via data-question-answered
|
|
// because storedValue still holds the uploaded path
|
|
expect(container.querySelector("[data-question-answered='true']")).toBeInTheDocument();
|
|
});
|
|
});
|