From 29a79ba3f6726781de4ae7856e32ed5c0aeb561f Mon Sep 17 00:00:00 2001 From: edochieblessing09-max Date: Mon, 19 Oct 2026 23:42:38 +0100 Subject: [PATCH] fix(kyc): attach media stream on camera activation and handle capture errors --- .../KycDocumentCapture/KycDocumentCapture.css | 9 ++ .../KycDocumentCapture.test.tsx | 131 ++++++++++++--- .../KycDocumentCapture/KycDocumentCapture.tsx | 79 +++++++-- .../KycSelfieCapture/KycSelfieCapture.css | 15 ++ .../KycSelfieCapture.test.tsx | 119 ++++++++++++++ .../KycSelfieCapture/KycSelfieCapture.tsx | 84 ++++++++-- .../KycSelfieCapture/index.test.tsx | 33 ++++ .../kycCaptureRegression.test.tsx | 151 ++++++++++++++++++ 8 files changed, 583 insertions(+), 38 deletions(-) create mode 100644 src/components/KycSelfieCapture/kycCaptureRegression.test.tsx diff --git a/src/components/KycDocumentCapture/KycDocumentCapture.css b/src/components/KycDocumentCapture/KycDocumentCapture.css index 82772d57..243abd5f 100644 --- a/src/components/KycDocumentCapture/KycDocumentCapture.css +++ b/src/components/KycDocumentCapture/KycDocumentCapture.css @@ -443,6 +443,15 @@ background: #f1f5f9; } +.kdc-capture-error { + color: var(--error, #ef4444); + font-size: var(--font-size-sm); + text-align: center; + padding: var(--spacing-xs) var(--spacing-sm); + background: var(--error-bg, rgba(239, 68, 68, 0.1)); + border-radius: var(--radius-md); +} + .kdc-btn--full { width: 100%; } diff --git a/src/components/KycDocumentCapture/KycDocumentCapture.test.tsx b/src/components/KycDocumentCapture/KycDocumentCapture.test.tsx index 549079a5..eb52e7a8 100644 --- a/src/components/KycDocumentCapture/KycDocumentCapture.test.tsx +++ b/src/components/KycDocumentCapture/KycDocumentCapture.test.tsx @@ -140,9 +140,7 @@ describe('KycDocumentCapture', () => { it('shows denied state when camera permission is denied', async () => { mockGetUserMedia.mockRejectedValue( - Object.assign(new DOMException('Permission denied'), { - name: 'NotAllowedError', - }), + new DOMException('Permission denied', 'NotAllowedError'), ); render(); @@ -178,9 +176,7 @@ describe('KycDocumentCapture', () => { it('shows file upload option in denied state', async () => { mockGetUserMedia.mockRejectedValue( - Object.assign(new DOMException('Permission denied'), { - name: 'NotAllowedError', - }), + new DOMException('Permission denied', 'NotAllowedError'), ); render(); @@ -191,16 +187,7 @@ describe('KycDocumentCapture', () => { it('shows retry camera button in denied state', async () => { mockGetUserMedia.mockRejectedValue( - Object.assign(new DOMException('Permission denied'), { - name: 'NotAllowedError', - }), - ); - - // Make the retry succeed - mockGetUserMedia.mockResolvedValueOnce( - Object.assign(new DOMException('Permission denied'), { - name: 'NotAllowedError', - }), + new DOMException('Permission denied', 'NotAllowedError'), ); render(); @@ -219,9 +206,7 @@ describe('KycDocumentCapture', () => { it('has no axe violations on denied fallback', async () => { mockGetUserMedia.mockRejectedValue( - Object.assign(new DOMException('Permission denied'), { - name: 'NotAllowedError', - }), + new DOMException('Permission denied', 'NotAllowedError'), ); const { container } = render(); @@ -231,4 +216,112 @@ describe('KycDocumentCapture', () => { expect(await axe(container)).toHaveNoViolations(); }); }); + + describe('Camera stream attachment and capture flow', () => { + it('attaches stream to video.srcObject and calls play() after camera activation', async () => { + const mockTrack = { stop: vi.fn(), kind: 'video' }; + const fakeStream = { + getTracks: vi.fn().mockReturnValue([mockTrack]), + getVideoTracks: vi.fn().mockReturnValue([mockTrack]), + } as unknown as MediaStream; + + const playSpy = vi.fn().mockResolvedValue(undefined); + window.HTMLMediaElement.prototype.play = playSpy; + + mockGetUserMedia.mockResolvedValue(fakeStream); + + render(); + fireEvent.click(screen.getByText('Enable Camera')); + + const video = (await screen.findByTestId('camera-video')) as HTMLVideoElement; + expect(video).toBeTruthy(); + expect(video.srcObject).toBe(fakeStream); + expect(playSpy).toHaveBeenCalled(); + }); + + it('keeps capture button disabled until loadedmetadata fires with non-zero videoWidth', async () => { + const mockTrack = { stop: vi.fn(), kind: 'video' }; + const fakeStream = { + getTracks: vi.fn().mockReturnValue([mockTrack]), + getVideoTracks: vi.fn().mockReturnValue([mockTrack]), + } as unknown as MediaStream; + window.HTMLMediaElement.prototype.play = vi.fn().mockResolvedValue(undefined); + + mockGetUserMedia.mockResolvedValue(fakeStream); + + render(); + fireEvent.click(screen.getByText('Enable Camera')); + + const captureBtn = await screen.findByRole('button', { name: /take photo/i }); + const video = screen.getByTestId('camera-video') as HTMLVideoElement; + + // Initially disabled before metadata + expect(captureBtn).toBeDisabled(); + + // If loadedmetadata fires with videoWidth === 0, stays disabled + Object.defineProperty(video, 'videoWidth', { value: 0, configurable: true }); + fireEvent(video, new Event('loadedmetadata')); + expect(captureBtn).toBeDisabled(); + + // Once loadedmetadata fires with non-zero videoWidth, becomes enabled + Object.defineProperty(video, 'videoWidth', { value: 1280, configurable: true }); + Object.defineProperty(video, 'videoHeight', { value: 720, configurable: true }); + fireEvent(video, new Event('loadedmetadata')); + + expect(captureBtn).toBeEnabled(); + }); + + it('handles null blob from toBlob with a visible error and keeps stream running for retry', async () => { + const mockTrack = { stop: vi.fn(), kind: 'video' }; + const fakeStream = { + getTracks: vi.fn().mockReturnValue([mockTrack]), + getVideoTracks: vi.fn().mockReturnValue([mockTrack]), + } as unknown as MediaStream; + window.HTMLMediaElement.prototype.play = vi.fn().mockResolvedValue(undefined); + + mockGetUserMedia.mockResolvedValue(fakeStream); + + HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue({ + drawImage: vi.fn(), + }) as unknown as typeof HTMLCanvasElement.prototype.getContext; + + // First produce null blob + HTMLCanvasElement.prototype.toBlob = vi.fn((callback) => { + callback(null); + }); + + render(); + fireEvent.click(screen.getByText('Enable Camera')); + + const captureBtn = await screen.findByRole('button', { name: /take photo/i }); + const video = screen.getByTestId('camera-video') as HTMLVideoElement; + + Object.defineProperty(video, 'videoWidth', { value: 1280, configurable: true }); + Object.defineProperty(video, 'videoHeight', { value: 720, configurable: true }); + fireEvent(video, new Event('loadedmetadata')); + expect(captureBtn).toBeEnabled(); + + // Click capture with null blob + fireEvent.click(captureBtn); + + // Visible error shown + const errorMsg = await screen.findByRole('alert'); + expect(errorMsg).toHaveTextContent(/failed to capture document/i); + + // Stream is not stopped (kept running for retry) + expect(mockTrack.stop).not.toHaveBeenCalled(); + + // Now retry when toBlob produces a valid blob + global.URL.createObjectURL = vi.fn().mockReturnValue('blob:http://localhost/doc-ok'); + (HTMLCanvasElement.prototype.toBlob as ReturnType).mockImplementation((callback) => { + callback(new Blob(['document-data'], { type: 'image/jpeg' })); + }); + + fireEvent.click(captureBtn); + + // Transitions to preview and stops stream on success + await screen.findByText(/Use this photo/i); + expect(mockTrack.stop).toHaveBeenCalled(); + }); + }); }); diff --git a/src/components/KycDocumentCapture/KycDocumentCapture.tsx b/src/components/KycDocumentCapture/KycDocumentCapture.tsx index 77be014f..ad5f685d 100644 --- a/src/components/KycDocumentCapture/KycDocumentCapture.tsx +++ b/src/components/KycDocumentCapture/KycDocumentCapture.tsx @@ -190,26 +190,67 @@ function CameraActiveView({ onSwitchToUpload: () => void; }) { const canvasRef = useRef(null); + const [isVideoReady, setIsVideoReady] = useState(false); + const [isCapturing, setIsCapturing] = useState(false); + const [captureError, setCaptureError] = useState(null); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + const onLoadedMetadata = () => { + if (video.videoWidth > 0) { + setIsVideoReady(true); + } + }; + + if (video.videoWidth > 0) { + setIsVideoReady(true); + } + + video.addEventListener('loadedmetadata', onLoadedMetadata); + return () => { + video.removeEventListener('loadedmetadata', onLoadedMetadata); + }; + }, [videoRef]); + + const handleLoadedMetadata = useCallback((e: React.SyntheticEvent) => { + if (e.currentTarget.videoWidth > 0) { + setIsVideoReady(true); + } + }, []); const handleCapture = useCallback(() => { const video = videoRef.current; const canvas = canvasRef.current; - if (!video || !canvas) return; + if (!video || !canvas || !isVideoReady || isCapturing) return; + setIsCapturing(true); + setCaptureError(null); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext('2d'); - if (!ctx) return; + if (!ctx) { + setIsCapturing(false); + setCaptureError('Failed to capture document. Please try again.'); + return; + } ctx.drawImage(video, 0, 0); canvas.toBlob((blob) => { - if (!blob) return; + if (!blob) { + setCaptureError('Failed to capture document. Please try again.'); + setIsCapturing(false); + return; + } + setCaptureError(null); + setIsCapturing(false); const file = new File([blob], `document-capture-${Date.now()}.jpg`, { type: 'image/jpeg', }); onCapture(file); }, 'image/jpeg'); - }, [onCapture]); + }, [videoRef, isVideoReady, isCapturing, onCapture]); return (
@@ -221,10 +262,16 @@ function CameraActiveView({ muted className="kdc-video" data-testid="camera-video" + onLoadedMetadata={handleLoadedMetadata} />