test(ui): GroupDmSettings — mock ImageCropModal to harden cancel + save-with-icon tests
This commit is contained in:
@@ -13,6 +13,47 @@ vi.mock('../../audio/AudioManager', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock ImageCropModal so tests can deterministically drive the
|
||||||
|
// `onCropComplete(blob)` path. The real `react-easy-crop` widget doesn't fire
|
||||||
|
// its `onCropComplete` callback reliably under jsdom (no real layout), so the
|
||||||
|
// production cropper is replaced with a minimal dialog that exposes a
|
||||||
|
// "Confirm Crop" button which immediately hands a synthetic Blob back to the
|
||||||
|
// parent — exercising the same staged-icon state transition as production.
|
||||||
|
vi.mock('../ui/ImageCropModal', () => ({
|
||||||
|
ImageCropModal: ({
|
||||||
|
isOpen,
|
||||||
|
onCropComplete,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
imageSrc: string;
|
||||||
|
onCropComplete: (blob: Blob) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
cropShape?: 'rect' | 'round';
|
||||||
|
aspectRatio?: number;
|
||||||
|
maxOutputDimension?: number;
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
return (
|
||||||
|
<div role="dialog" aria-label="cropper-mock">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="cropper-mock-confirm"
|
||||||
|
onClick={() =>
|
||||||
|
onCropComplete(new Blob(['fake-image-bytes'], { type: 'image/webp' }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Confirm Crop
|
||||||
|
</button>
|
||||||
|
<button type="button" data-testid="cropper-mock-cancel" onClick={onClose}>
|
||||||
|
Cancel Crop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock the api client — assert call counts on uploads + updateMetadata.
|
// Mock the api client — assert call counts on uploads + updateMetadata.
|
||||||
const mockUpdateMetadata = vi.fn();
|
const mockUpdateMetadata = vi.fn();
|
||||||
const mockLeave = vi.fn();
|
const mockLeave = vi.fn();
|
||||||
@@ -223,35 +264,31 @@ describe('GroupDmSettings — owner overview', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('GroupDmSettings — icon staging', () => {
|
describe('GroupDmSettings — icon staging', () => {
|
||||||
// Helper: drive a crop blob into the staged-icon state without going through
|
// Helper: drive a crop blob into the staged-icon state by exercising the
|
||||||
// the full file-picker → ImageCropModal pipeline. We simulate the same effect
|
// production pipeline end-to-end:
|
||||||
// by firing a change event on the hidden file input, then completing the
|
// 1. Fire a change event on the hidden file input (simulates file picker).
|
||||||
// cropper's onCropComplete via the rendered button.
|
// 2. Wait for the (mocked) ImageCropModal dialog to appear once
|
||||||
|
// FileReader.onload resolves and `cropSrc` becomes non-null.
|
||||||
|
// 3. Click "Confirm Crop" on the mock — this fires the real
|
||||||
|
// `onCropComplete(blob)` callback synchronously, which causes
|
||||||
|
// GroupDmSettings to transition iconState → 'staged'.
|
||||||
|
// 4. Wait for the cropper dialog to disappear (parent setCropSrc(null)).
|
||||||
|
// After this helper resolves, the component is in the staged-icon state
|
||||||
|
// and Save will exercise the upload + PATCH path.
|
||||||
async function stageIcon(user: ReturnType<typeof userEvent.setup>) {
|
async function stageIcon(user: ReturnType<typeof userEvent.setup>) {
|
||||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
expect(fileInput).not.toBeNull();
|
expect(fileInput).not.toBeNull();
|
||||||
const file = new File(['raw'], 'pick.png', { type: 'image/png' });
|
const file = new File(['raw'], 'pick.png', { type: 'image/png' });
|
||||||
// FileReader runs async. Fire the change, then poll for the cropper modal.
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||||
});
|
});
|
||||||
// The cropper opens once the FileReader resolves. Wait for its Apply button.
|
// Cropper opens once FileReader.onload resolves cropSrc.
|
||||||
const applyBtn = await screen.findByRole('button', { name: /apply/i });
|
const confirmBtn = await screen.findByTestId('cropper-mock-confirm');
|
||||||
|
await user.click(confirmBtn);
|
||||||
// react-easy-crop emits its onCropComplete with a real Area asynchronously.
|
// After confirm, the parent clears cropSrc → cropper unmounts.
|
||||||
// To avoid depending on cropper internals, we directly stub `cropImage`
|
await waitFor(() =>
|
||||||
// (mocked above) and just click Apply — but the component only invokes
|
expect(screen.queryByRole('dialog', { name: 'cropper-mock' })).toBeNull(),
|
||||||
// cropImage when `croppedAreaPixels` is non-null. Force the state by
|
);
|
||||||
// briefly inserting a Cropper crop event. In practice react-easy-crop
|
|
||||||
// fires onCropComplete on mount with the default frame, so wait for the
|
|
||||||
// Apply button to become clickable then click it.
|
|
||||||
// If the button is still disabled (no crop event yet), advance microtasks.
|
|
||||||
await waitFor(() => {
|
|
||||||
// No-op wait; this gives react-easy-crop a tick to fire its initial event.
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
// Click via fireEvent.click (bypasses pointer-events check if any).
|
|
||||||
await user.click(applyBtn).catch(() => fireEvent.click(applyBtn));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
it('Cancel discards a staged icon — no upload fires', async () => {
|
it('Cancel discards a staged icon — no upload fires', async () => {
|
||||||
@@ -260,20 +297,20 @@ describe('GroupDmSettings — icon staging', () => {
|
|||||||
setStoreState({ dmChannel: dm, authUser: makeUser({ id: 'user-self' }) });
|
setStoreState({ dmChannel: dm, authUser: makeUser({ id: 'user-self' }) });
|
||||||
renderModal();
|
renderModal();
|
||||||
|
|
||||||
// Stage an icon via the file picker → cropper round-trip.
|
// Stage an icon via the file picker → cropper round-trip. The mock
|
||||||
// If the cropper integration can't be driven from jsdom, fall through to
|
// guarantees onCropComplete fires synchronously on click — if this throws
|
||||||
// the deterministic state-driven path: simulate Cancel without staging.
|
// it's a real test failure (no graceful fallback).
|
||||||
try {
|
|
||||||
await stageIcon(user);
|
await stageIcon(user);
|
||||||
} catch {
|
|
||||||
// Cropper couldn't be driven in jsdom — that's fine; Cancel-with-nothing
|
// Sanity: staging marks the form dirty → Save becomes enabled.
|
||||||
// also satisfies "no upload fires." Continue.
|
const saveBtn = document.querySelector('[data-group-dm-save]') as HTMLButtonElement;
|
||||||
}
|
await waitFor(() => expect(saveBtn.disabled).toBe(false));
|
||||||
|
|
||||||
const cancelBtn = document.querySelector('[data-group-dm-cancel]') as HTMLButtonElement;
|
const cancelBtn = document.querySelector('[data-group-dm-cancel]') as HTMLButtonElement;
|
||||||
await user.click(cancelBtn);
|
await user.click(cancelBtn);
|
||||||
|
|
||||||
// No upload should have fired regardless of whether a blob was staged.
|
// Cancel must close the modal and NOT fire any upload or PATCH.
|
||||||
|
await waitFor(() => expect(useUIStore.getState().activeModal).toBeNull());
|
||||||
expect(mockStartUpload).not.toHaveBeenCalled();
|
expect(mockStartUpload).not.toHaveBeenCalled();
|
||||||
expect(mockUpdateMetadata).not.toHaveBeenCalled();
|
expect(mockUpdateMetadata).not.toHaveBeenCalled();
|
||||||
// Direct /api/uploads POSTs (legacy paths) also must not have happened.
|
// Direct /api/uploads POSTs (legacy paths) also must not have happened.
|
||||||
@@ -289,36 +326,11 @@ describe('GroupDmSettings — icon staging', () => {
|
|||||||
setStoreState({ dmChannel: dm, authUser: makeUser({ id: 'user-self' }) });
|
setStoreState({ dmChannel: dm, authUser: makeUser({ id: 'user-self' }) });
|
||||||
renderModal();
|
renderModal();
|
||||||
|
|
||||||
// Drive the cropper to produce a blob. If we can't, simulate the staged
|
// Stage an icon via the cropper-mock — this hands a real Blob to the
|
||||||
// state directly via a controlled child render bypass — the component
|
// component's handleCropComplete, transitioning iconState to 'staged'.
|
||||||
// contract is: a staged blob in state means Save uploads it.
|
|
||||||
let staged = false;
|
|
||||||
try {
|
|
||||||
await stageIcon(user);
|
await stageIcon(user);
|
||||||
staged = true;
|
|
||||||
} catch {
|
|
||||||
// Fall back: directly trigger the file pick + skip the cropper. We
|
|
||||||
// can't reach Save with a dirty icon without staging, so if cropper
|
|
||||||
// isn't drivable we mark the test as skipped via early return.
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!staged) {
|
// Save should now trigger startUpload (the staged-icon code path).
|
||||||
// The cropper isn't drivable in this jsdom — instead, force the
|
|
||||||
// dirty state via a name change AND assert that an icon-less Save
|
|
||||||
// still doesn't trigger an upload, which is also a valid contract test.
|
|
||||||
const input = screen.getByLabelText('Group name') as HTMLInputElement;
|
|
||||||
await user.clear(input);
|
|
||||||
await user.type(input, 'Renamed');
|
|
||||||
const saveBtn = document.querySelector('[data-group-dm-save]') as HTMLButtonElement;
|
|
||||||
await user.click(saveBtn);
|
|
||||||
|
|
||||||
await waitFor(() => expect(mockUpdateMetadata).toHaveBeenCalled());
|
|
||||||
expect(mockUpdateMetadata).toHaveBeenCalledWith('dm-1', { name: 'Renamed' });
|
|
||||||
expect(mockStartUpload).not.toHaveBeenCalled();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cropper succeeded → an icon blob is staged. Save should upload + PATCH.
|
|
||||||
const saveBtn = document.querySelector('[data-group-dm-save]') as HTMLButtonElement;
|
const saveBtn = document.querySelector('[data-group-dm-save]') as HTMLButtonElement;
|
||||||
await waitFor(() => expect(saveBtn.disabled).toBe(false));
|
await waitFor(() => expect(saveBtn.disabled).toBe(false));
|
||||||
await user.click(saveBtn);
|
await user.click(saveBtn);
|
||||||
|
|||||||
Reference in New Issue
Block a user