Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/chat/client/useChatAttachmentUploads.test.ts
T

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();
});
});