229 lines
9.8 KiB
TypeScript
229 lines
9.8 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mocks = vi.hoisted(() => ({ upload: vi.fn(), waitForScan: vi.fn() }));
|
|
|
|
vi.mock("~/utils/files/uploadFileClient", () => ({
|
|
uploadFileInChunks: (...args: unknown[]) => mocks.upload(...args),
|
|
waitForChatAttachmentScan: (...args: unknown[]) => mocks.waitForScan(...args),
|
|
}));
|
|
|
|
import { useChatAttachmentUploads } from "./useChatAttachmentUploads";
|
|
|
|
describe("useChatAttachmentUploads", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.useRealTimers();
|
|
mocks.waitForScan.mockResolvedValue(undefined);
|
|
});
|
|
|
|
it("shows an optimistic upload, reports progress, and keeps only the returned File id", async () => {
|
|
let finishUpload: (() => void) | undefined;
|
|
const uploadBarrier = new Promise<void>((resolve) => {
|
|
finishUpload = resolve;
|
|
});
|
|
mocks.upload.mockImplementation(async (input: { onProgress?: (progress: number) => void; onProcessing?: () => void }) => {
|
|
input.onProgress?.(50);
|
|
input.onProcessing?.();
|
|
await uploadBarrier;
|
|
return { id: "file_1", displayName: "photo.jpg" };
|
|
});
|
|
const { result } = renderHook(() => useChatAttachmentUploads({ tenantSlug: "acme", enabled: true }));
|
|
|
|
act(() => result.current.addFiles([new File(["image"], "camera.jpg", { type: "image/jpeg" })]));
|
|
|
|
expect(result.current.attachments).toHaveLength(1);
|
|
expect(result.current.attachments[0]).toMatchObject({ kind: "image", status: "processing", fileId: null });
|
|
await act(async () => finishUpload?.());
|
|
await waitFor(() => expect(result.current.attachments[0]).toMatchObject({ status: "ready", progress: 100, fileId: "file_1" }));
|
|
expect(mocks.upload).toHaveBeenCalledWith(expect.objectContaining({ tenantSlug: "acme", file: expect.any(File), signal: expect.any(AbortSignal) }));
|
|
expect(mocks.waitForScan).toHaveBeenCalledWith(expect.objectContaining({ tenantSlug: "acme", fileId: "file_1" }));
|
|
|
|
act(() => result.current.clearReady(["file_1"]));
|
|
expect(result.current.attachments).toEqual([]);
|
|
});
|
|
|
|
it("keeps failed files retryable and removes them from the queue on demand", async () => {
|
|
mocks.upload
|
|
.mockRejectedValueOnce(Object.assign(new Error("failed"), { errorKey: "files.error.mimeBlocked" }))
|
|
.mockResolvedValueOnce({ id: "file_2", displayName: "notes.txt" });
|
|
const { result } = renderHook(() => useChatAttachmentUploads({ tenantSlug: "acme", enabled: true }));
|
|
|
|
act(() => result.current.addFiles([new File(["notes"], "notes.txt", { type: "text/plain" })]));
|
|
await waitFor(() => expect(result.current.attachments[0]).toMatchObject({ status: "error", errorKey: "files.error.mimeBlocked" }));
|
|
const clientId = result.current.attachments[0]!.clientId;
|
|
|
|
act(() => result.current.retry(clientId));
|
|
await waitFor(() => expect(result.current.attachments[0]).toMatchObject({ status: "ready", fileId: "file_2" }));
|
|
expect(mocks.upload).toHaveBeenCalledTimes(2);
|
|
|
|
act(() => result.current.remove(clientId));
|
|
expect(result.current.attachments).toEqual([]);
|
|
});
|
|
|
|
it("reports an oversized queue selection instead of silently truncating it", () => {
|
|
const { result } = renderHook(() => useChatAttachmentUploads({ tenantSlug: "acme", enabled: true }));
|
|
const files = Array.from({ length: 21 }, (_, index) => new File([String(index)], `file-${index}.txt`, { type: "text/plain" }));
|
|
|
|
act(() => result.current.addFiles(files));
|
|
|
|
expect(result.current.attachments).toEqual([]);
|
|
expect(result.current.errorKey).toBe("chat.attachments.tooMany");
|
|
expect(mocks.upload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reserves the 20 attachment slots atomically across same-tick selections", () => {
|
|
mocks.upload.mockImplementation(() => new Promise(() => undefined));
|
|
const { result } = renderHook(() => useChatAttachmentUploads({ tenantSlug: "acme", enabled: true }));
|
|
const first = Array.from({ length: 11 }, (_, index) => new File([String(index)], `first-${index}.txt`, { type: "text/plain" }));
|
|
const second = Array.from({ length: 10 }, (_, index) => new File([String(index)], `second-${index}.txt`, { type: "text/plain" }));
|
|
|
|
act(() => {
|
|
result.current.addFiles(first);
|
|
result.current.addFiles(second);
|
|
});
|
|
|
|
expect(result.current.attachments).toHaveLength(11);
|
|
expect(result.current.errorKey).toBe("chat.attachments.tooMany");
|
|
expect(mocks.upload).toHaveBeenCalledTimes(11);
|
|
});
|
|
|
|
it("surfaces unsupported voice recording instead of failing silently", async () => {
|
|
vi.stubGlobal("MediaRecorder", undefined);
|
|
const { result } = renderHook(() => useChatAttachmentUploads({ tenantSlug: "acme", enabled: true }));
|
|
|
|
await act(() => result.current.startRecording());
|
|
|
|
expect(result.current.errorKey).toBe("chat.attachments.recordingUnsupported");
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("shows recording duration and discards a cancelled voice note before upload", async () => {
|
|
vi.useFakeTimers();
|
|
const stopTrack = vi.fn();
|
|
class FakeMediaRecorder {
|
|
static isTypeSupported(): boolean {
|
|
return true;
|
|
}
|
|
state: RecordingState = "inactive";
|
|
mimeType = "audio/webm";
|
|
ondataavailable: ((event: BlobEvent) => void) | null = null;
|
|
onstop: (() => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
start(): void {
|
|
this.state = "recording";
|
|
}
|
|
stop(): void {
|
|
this.state = "inactive";
|
|
this.ondataavailable?.({ data: new Blob(["voice"], { type: this.mimeType }) } as BlobEvent);
|
|
this.onstop?.();
|
|
}
|
|
}
|
|
vi.stubGlobal("MediaRecorder", FakeMediaRecorder);
|
|
Object.defineProperty(navigator, "mediaDevices", {
|
|
configurable: true,
|
|
value: { getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [{ stop: stopTrack }] }) },
|
|
});
|
|
const { result } = renderHook(() => useChatAttachmentUploads({ tenantSlug: "acme", enabled: true }));
|
|
|
|
await act(() => result.current.startRecording());
|
|
expect(result.current.isRecording).toBe(true);
|
|
act(() => vi.advanceTimersByTime(1_250));
|
|
expect(result.current.recordingDurationSeconds).toBe(1);
|
|
|
|
act(() => result.current.cancelRecording());
|
|
|
|
expect(result.current.isRecording).toBe(false);
|
|
expect(result.current.recordingDurationSeconds).toBe(0);
|
|
expect(result.current.attachments).toEqual([]);
|
|
expect(mocks.upload).not.toHaveBeenCalled();
|
|
expect(stopTrack).toHaveBeenCalledOnce();
|
|
vi.unstubAllGlobals();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("does not request the microphone twice while the first permission request is pending", async () => {
|
|
let resolveStream: ((stream: MediaStream) => void) | undefined;
|
|
const streamPromise = new Promise<MediaStream>((resolve) => {
|
|
resolveStream = resolve;
|
|
});
|
|
const getUserMedia = vi.fn(() => streamPromise);
|
|
class FakeMediaRecorder {
|
|
static isTypeSupported(): boolean {
|
|
return true;
|
|
}
|
|
state: RecordingState = "inactive";
|
|
mimeType = "audio/webm;codecs=opus";
|
|
ondataavailable: ((event: BlobEvent) => void) | null = null;
|
|
onstop: (() => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
start(): void {
|
|
this.state = "recording";
|
|
}
|
|
stop(): void {
|
|
this.state = "inactive";
|
|
this.onstop?.();
|
|
}
|
|
}
|
|
vi.stubGlobal("MediaRecorder", FakeMediaRecorder);
|
|
Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { getUserMedia } });
|
|
const stopTrack = vi.fn();
|
|
const { result } = renderHook(() => useChatAttachmentUploads({ tenantSlug: "acme", enabled: true }));
|
|
|
|
let firstStart: Promise<void> | undefined;
|
|
await act(async () => {
|
|
firstStart = result.current.startRecording();
|
|
await result.current.startRecording();
|
|
});
|
|
expect(result.current.isStartingRecording).toBe(true);
|
|
expect(getUserMedia).toHaveBeenCalledOnce();
|
|
|
|
resolveStream?.({ getTracks: () => [{ stop: stopTrack }] } as unknown as MediaStream);
|
|
await act(async () => firstStart);
|
|
expect(result.current.isStartingRecording).toBe(false);
|
|
expect(result.current.isRecording).toBe(true);
|
|
act(() => result.current.cancelRecording());
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("canonicalizes codec MIME parameters and keeps the voice-note duration in the optimistic item", async () => {
|
|
vi.useFakeTimers();
|
|
mocks.upload.mockImplementation(async (input: { file: File }) => ({ id: "voice-1", displayName: input.file.name }));
|
|
class FakeMediaRecorder {
|
|
static isTypeSupported(): boolean {
|
|
return true;
|
|
}
|
|
state: RecordingState = "inactive";
|
|
mimeType = "audio/webm;codecs=opus";
|
|
ondataavailable: ((event: BlobEvent) => void) | null = null;
|
|
onstop: (() => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
start(): void {
|
|
this.state = "recording";
|
|
}
|
|
stop(): void {
|
|
this.state = "inactive";
|
|
this.ondataavailable?.({ data: new Blob(["voice"], { type: this.mimeType }) } as BlobEvent);
|
|
this.onstop?.();
|
|
}
|
|
}
|
|
vi.stubGlobal("MediaRecorder", FakeMediaRecorder);
|
|
Object.defineProperty(navigator, "mediaDevices", {
|
|
configurable: true,
|
|
value: { getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [{ stop: vi.fn() }] }) },
|
|
});
|
|
const { result } = renderHook(() => useChatAttachmentUploads({ tenantSlug: "acme", enabled: true }));
|
|
|
|
await act(() => result.current.startRecording());
|
|
act(() => vi.advanceTimersByTime(2_100));
|
|
act(() => result.current.stopRecording());
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => expect(result.current.attachments[0]?.status).toBe("ready"));
|
|
expect(result.current.attachments[0]).toMatchObject({ mimeType: "audio/webm", durationSeconds: 2, fileId: "voice-1" });
|
|
expect(mocks.upload.mock.calls.at(-1)?.[0].file.type).toBe("audio/webm");
|
|
vi.unstubAllGlobals();
|
|
});
|
|
});
|