mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-21 05:28:11 +00:00
fix(frontend): validate attachment limits before upload (#3900)
* fix(frontend): validate attachment limits before upload * fix(frontend): avoid duplicate upload limit toasts
This commit is contained in:
parent
70d53da787
commit
3748344303
@ -68,6 +68,13 @@ import { useSkills } from "@/core/skills/hooks";
|
||||
import { useSuggestionsConfig } from "@/core/suggestions/hooks";
|
||||
import type { AgentThreadContext } from "@/core/threads";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import {
|
||||
formatUploadSize,
|
||||
useUploadLimits,
|
||||
validateUploadLimits,
|
||||
type UploadLimits,
|
||||
type UploadLimitViolation,
|
||||
} from "@/core/uploads";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@ -216,8 +223,11 @@ export function InputBox({
|
||||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||||
const { models } = useModels();
|
||||
const { thread, isMock } = useThread();
|
||||
const { textInput } = usePromptInputController();
|
||||
const { attachments, textInput } = usePromptInputController();
|
||||
const attachmentParts = attachments.files;
|
||||
const removeAttachment = attachments.remove;
|
||||
const { skills } = useSkills();
|
||||
const { data: uploadLimits } = useUploadLimits(threadId);
|
||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const promptHistoryIndexRef = useRef<number | null>(null);
|
||||
@ -242,6 +252,66 @@ export function InputBox({
|
||||
null,
|
||||
);
|
||||
|
||||
const reportUploadLimitViolations = useCallback(
|
||||
(violations: UploadLimitViolation[]) => {
|
||||
for (const violation of violations) {
|
||||
if (violation.code === "max_file_size") {
|
||||
toast.error(
|
||||
t.uploads.filesTooLarge(
|
||||
violation.files.map((file) => file.name).join(", "),
|
||||
formatUploadSize(violation.limit),
|
||||
),
|
||||
);
|
||||
} else if (violation.code === "max_files") {
|
||||
toast.error(
|
||||
t.uploads.tooManyFiles(violation.files.length, violation.limit),
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t.uploads.totalSizeTooLarge(
|
||||
violation.files.length,
|
||||
formatUploadSize(violation.limit),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[t.uploads],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!uploadLimits) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attachmentEntries = attachmentParts.flatMap((attachment) =>
|
||||
attachment.file instanceof File
|
||||
? [{ id: attachment.id, file: attachment.file }]
|
||||
: [],
|
||||
);
|
||||
const validation = validateUploadLimits(
|
||||
[],
|
||||
attachmentEntries.map(({ file }) => file),
|
||||
uploadLimits,
|
||||
);
|
||||
if (validation.rejected.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rejected = new Set(validation.rejected);
|
||||
for (const entry of attachmentEntries) {
|
||||
if (rejected.has(entry.file)) {
|
||||
removeAttachment(entry.id);
|
||||
}
|
||||
}
|
||||
reportUploadLimitViolations(validation.violations);
|
||||
}, [
|
||||
attachmentParts,
|
||||
removeAttachment,
|
||||
reportUploadLimitViolations,
|
||||
uploadLimits,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (models.length === 0) {
|
||||
return;
|
||||
@ -374,6 +444,13 @@ export function InputBox({
|
||||
if (!message.text.trim() && message.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
const files = message.files.flatMap((file) =>
|
||||
file.file instanceof File ? [file.file] : [],
|
||||
);
|
||||
const uploadValidation = validateUploadLimits([], files, uploadLimits);
|
||||
if (uploadValidation.violations.length > 0) {
|
||||
return Promise.reject(new Error("Attachment limits exceeded."));
|
||||
}
|
||||
const placeholder = findSuggestionTemplatePlaceholder(message.text);
|
||||
if (placeholder) {
|
||||
toast.error(t.inputBox.suggestionPlaceholderRequired);
|
||||
@ -424,6 +501,7 @@ export function InputBox({
|
||||
selectedModel?.supports_thinking,
|
||||
status,
|
||||
t.inputBox.suggestionPlaceholderRequired,
|
||||
uploadLimits,
|
||||
],
|
||||
);
|
||||
|
||||
@ -888,7 +966,10 @@ export function InputBox({
|
||||
/>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu> */}
|
||||
<AddAttachmentsButton className="px-2!" />
|
||||
<AddAttachmentsButton
|
||||
className="px-2!"
|
||||
uploadLimits={uploadLimits}
|
||||
/>
|
||||
<PromptInputActionMenu>
|
||||
<ModeHoverGuide
|
||||
mode={
|
||||
@ -1330,13 +1411,28 @@ function SuggestionList({
|
||||
);
|
||||
}
|
||||
|
||||
function AddAttachmentsButton({ className }: { className?: string }) {
|
||||
function AddAttachmentsButton({
|
||||
className,
|
||||
uploadLimits,
|
||||
}: {
|
||||
className?: string;
|
||||
uploadLimits?: UploadLimits;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const attachments = usePromptInputAttachments();
|
||||
const tooltipContent = uploadLimits
|
||||
? t.uploads.limitsHint(
|
||||
uploadLimits.max_files,
|
||||
formatUploadSize(uploadLimits.max_file_size),
|
||||
formatUploadSize(uploadLimits.max_total_size),
|
||||
)
|
||||
: t.inputBox.addAttachments;
|
||||
return (
|
||||
<Tooltip content={t.inputBox.addAttachments}>
|
||||
<Tooltip content={<span className="block max-w-80">{tooltipContent}</span>}>
|
||||
<PromptInputButton
|
||||
aria-label={t.inputBox.addAttachments}
|
||||
className={cn("px-2!", className)}
|
||||
data-testid="add-attachments-button"
|
||||
onClick={() => attachments.openFileDialog()}
|
||||
>
|
||||
<PaperclipIcon className="size-3" />
|
||||
|
||||
@ -331,6 +331,14 @@ export const enUS: Translations = {
|
||||
uploads: {
|
||||
uploading: "Uploading...",
|
||||
uploadingFiles: "Uploading files, please wait...",
|
||||
limitsHint: (maxFiles: number, maxFileSize: string, maxTotalSize: string) =>
|
||||
`Add attachments (up to ${maxFiles} files, ${maxFileSize} each, ${maxTotalSize} total). Most regular file types are supported; compress macOS .app bundles first.`,
|
||||
filesTooLarge: (files: string, maxFileSize: string) =>
|
||||
`Files exceeding the ${maxFileSize} per-file limit were not added: ${files}.`,
|
||||
tooManyFiles: (count: number, maxFiles: number) =>
|
||||
`${count} file${count === 1 ? " was" : "s were"} not added. You can attach up to ${maxFiles} files at once.`,
|
||||
totalSizeTooLarge: (count: number, maxTotalSize: string) =>
|
||||
`${count} file${count === 1 ? " was" : "s were"} not added. Attachments can total up to ${maxTotalSize}.`,
|
||||
},
|
||||
|
||||
subtasks: {
|
||||
|
||||
@ -251,6 +251,14 @@ export interface Translations {
|
||||
uploads: {
|
||||
uploading: string;
|
||||
uploadingFiles: string;
|
||||
limitsHint: (
|
||||
maxFiles: number,
|
||||
maxFileSize: string,
|
||||
maxTotalSize: string,
|
||||
) => string;
|
||||
filesTooLarge: (files: string, maxFileSize: string) => string;
|
||||
tooManyFiles: (count: number, maxFiles: number) => string;
|
||||
totalSizeTooLarge: (count: number, maxTotalSize: string) => string;
|
||||
};
|
||||
|
||||
// Subtasks
|
||||
|
||||
@ -316,6 +316,14 @@ export const zhCN: Translations = {
|
||||
uploads: {
|
||||
uploading: "上传中...",
|
||||
uploadingFiles: "文件上传中,请稍候...",
|
||||
limitsHint: (maxFiles: number, maxFileSize: string, maxTotalSize: string) =>
|
||||
`添加附件(最多 ${maxFiles} 个,单文件不超过 ${maxFileSize},总计不超过 ${maxTotalSize})。支持常规文件类型;macOS .app 应先压缩。`,
|
||||
filesTooLarge: (files: string, maxFileSize: string) =>
|
||||
`${files} 超过单文件 ${maxFileSize} 的限制,未被添加。`,
|
||||
tooManyFiles: (count: number, maxFiles: number) =>
|
||||
`有 ${count} 个文件未被添加;一次最多添加 ${maxFiles} 个文件。`,
|
||||
totalSizeTooLarge: (count: number, maxTotalSize: string) =>
|
||||
`有 ${count} 个文件未被添加;附件总大小不能超过 ${maxTotalSize}。`,
|
||||
},
|
||||
|
||||
subtasks: {
|
||||
|
||||
@ -31,6 +31,12 @@ export interface ListFilesResponse {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface UploadLimits {
|
||||
max_files: number;
|
||||
max_file_size: number;
|
||||
max_total_size: number;
|
||||
}
|
||||
|
||||
async function readErrorDetail(
|
||||
response: Response,
|
||||
fallback: string,
|
||||
@ -67,6 +73,23 @@ export async function uploadFiles(
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the upload limits enforced by the gateway for a thread
|
||||
*/
|
||||
export async function getUploadLimits(threadId: string): Promise<UploadLimits> {
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/threads/${threadId}/uploads/limits`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readErrorDetail(response, "Failed to load upload limits"),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* List all uploaded files for a thread
|
||||
*/
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import type { UploadLimits } from "./api";
|
||||
|
||||
const MACOS_APP_BUNDLE_CONTENT_TYPES = new Set([
|
||||
"",
|
||||
"application/octet-stream",
|
||||
@ -32,3 +34,98 @@ export function splitUnsupportedUploadFiles(fileList: File[] | FileList) {
|
||||
message: rejected.length > 0 ? MACOS_APP_BUNDLE_UPLOAD_MESSAGE : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export type UploadLimitViolationCode =
|
||||
| "max_file_size"
|
||||
| "max_files"
|
||||
| "max_total_size";
|
||||
|
||||
export interface UploadLimitViolation {
|
||||
code: UploadLimitViolationCode;
|
||||
files: File[];
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface UploadLimitValidationResult {
|
||||
accepted: File[];
|
||||
rejected: File[];
|
||||
violations: UploadLimitViolation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate files against the same per-request limits enforced by the gateway.
|
||||
* Existing files keep priority and incoming files are accepted in selection order.
|
||||
*/
|
||||
export function validateUploadLimits(
|
||||
existingFiles: File[],
|
||||
incomingFiles: File[] | FileList,
|
||||
limits?: UploadLimits,
|
||||
): UploadLimitValidationResult {
|
||||
const incoming = Array.from(incomingFiles);
|
||||
if (!limits) {
|
||||
return { accepted: incoming, rejected: [], violations: [] };
|
||||
}
|
||||
|
||||
let fileCount = existingFiles.length;
|
||||
let totalSize = existingFiles.reduce((total, file) => total + file.size, 0);
|
||||
const accepted: File[] = [];
|
||||
const rejectedByCode: Record<UploadLimitViolationCode, File[]> = {
|
||||
max_file_size: [],
|
||||
max_files: [],
|
||||
max_total_size: [],
|
||||
};
|
||||
|
||||
for (const file of incoming) {
|
||||
if (file.size > limits.max_file_size) {
|
||||
rejectedByCode.max_file_size.push(file);
|
||||
continue;
|
||||
}
|
||||
if (fileCount >= limits.max_files) {
|
||||
rejectedByCode.max_files.push(file);
|
||||
continue;
|
||||
}
|
||||
if (totalSize + file.size > limits.max_total_size) {
|
||||
rejectedByCode.max_total_size.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
accepted.push(file);
|
||||
fileCount += 1;
|
||||
totalSize += file.size;
|
||||
}
|
||||
|
||||
const limitByCode: Record<UploadLimitViolationCode, number> = {
|
||||
max_file_size: limits.max_file_size,
|
||||
max_files: limits.max_files,
|
||||
max_total_size: limits.max_total_size,
|
||||
};
|
||||
const codes: UploadLimitViolationCode[] = [
|
||||
"max_file_size",
|
||||
"max_files",
|
||||
"max_total_size",
|
||||
];
|
||||
const violations = codes.flatMap((code) =>
|
||||
rejectedByCode[code].length > 0
|
||||
? [{ code, files: rejectedByCode[code], limit: limitByCode[code] }]
|
||||
: [],
|
||||
);
|
||||
|
||||
return {
|
||||
accepted,
|
||||
rejected: violations.flatMap((violation) => violation.files),
|
||||
violations,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatUploadSize(bytes: number): string {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${Number((bytes / 1024).toFixed(1))} KiB`;
|
||||
}
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return `${Number((bytes / (1024 * 1024)).toFixed(1))} MiB`;
|
||||
}
|
||||
return `${Number((bytes / (1024 * 1024 * 1024)).toFixed(1))} GiB`;
|
||||
}
|
||||
|
||||
@ -7,12 +7,27 @@ import { useCallback } from "react";
|
||||
|
||||
import {
|
||||
deleteUploadedFile,
|
||||
getUploadLimits,
|
||||
listUploadedFiles,
|
||||
uploadFiles,
|
||||
type UploadedFileInfo,
|
||||
type UploadResponse,
|
||||
} from "./api";
|
||||
|
||||
/**
|
||||
* Hook to load the gateway-enforced upload limits.
|
||||
* Callers intentionally degrade to server-side validation if this request fails.
|
||||
*/
|
||||
export function useUploadLimits(threadId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["uploads", "limits", threadId],
|
||||
queryFn: () => getUploadLimits(threadId),
|
||||
enabled: !!threadId,
|
||||
retry: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to upload files
|
||||
*/
|
||||
|
||||
@ -358,6 +358,113 @@ test.describe("Chat workspace", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("shows gateway upload limits on the attachment entry point", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/workspace/chats/new");
|
||||
|
||||
const addAttachments = page.getByTestId("add-attachments-button");
|
||||
await expect(addAttachments).toBeVisible({ timeout: 15_000 });
|
||||
await addAttachments.hover();
|
||||
|
||||
await expect(page.getByRole("tooltip")).toContainText("50 MiB");
|
||||
await expect(page.getByRole("tooltip")).toContainText("100 MiB");
|
||||
});
|
||||
|
||||
test("rejects an oversized attachment before upload", async ({ page }) => {
|
||||
let uploadCalled = false;
|
||||
await page.route("**/api/threads/*/uploads", (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
uploadCalled = true;
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
await page.route("**/api/threads/*/uploads/limits", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
max_files: 10,
|
||||
max_file_size: 5,
|
||||
max_total_size: 20,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/workspace/chats/new");
|
||||
const addAttachments = page.getByTestId("add-attachments-button");
|
||||
await addAttachments.hover();
|
||||
await expect(page.getByRole("tooltip")).toContainText("5 B");
|
||||
|
||||
await page.getByLabel("Upload files").setInputFiles({
|
||||
name: "too-large.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("123456"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.locator("[data-sonner-toast]").filter({ hasText: "too-large.txt" }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator("form").getByText("too-large.txt")).toBeHidden();
|
||||
|
||||
const textarea = page.locator('textarea[name="message"]');
|
||||
await textarea.fill("Continue without the rejected attachment");
|
||||
await textarea.press("Enter");
|
||||
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
expect(uploadCalled).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps valid attachments in order when the total limit is exceeded", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/threads/*/uploads/limits", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
max_files: 3,
|
||||
max_file_size: 10,
|
||||
max_total_size: 5,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/workspace/chats/new");
|
||||
const addAttachments = page.getByTestId("add-attachments-button");
|
||||
await addAttachments.hover();
|
||||
await expect(page.getByRole("tooltip")).toContainText("5 B");
|
||||
|
||||
await page.getByLabel("Upload files").setInputFiles([
|
||||
{
|
||||
name: "first.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("1234"),
|
||||
},
|
||||
{
|
||||
name: "over-total.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("12"),
|
||||
},
|
||||
{
|
||||
name: "second.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("1"),
|
||||
},
|
||||
]);
|
||||
|
||||
const promptForm = page.locator("form").filter({
|
||||
has: page.locator('textarea[name="message"]'),
|
||||
});
|
||||
await expect(promptForm.getByText("first.txt")).toBeVisible();
|
||||
await expect(promptForm.getByText("second.txt")).toBeVisible();
|
||||
await expect(promptForm.getByText("over-total.txt")).toBeHidden();
|
||||
await expect(
|
||||
page.locator("[data-sonner-toast]").filter({ hasText: "5 B" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps attachments visible while upload submit is pending", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@ -55,6 +55,11 @@ export type MockAPIOptions = {
|
||||
threads?: MockThread[];
|
||||
agents?: MockAgent[];
|
||||
skills?: MockSkill[];
|
||||
uploadLimits?: {
|
||||
max_files: number;
|
||||
max_file_size: number;
|
||||
max_total_size: number;
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_SKILLS: MockSkill[] = [
|
||||
@ -106,6 +111,11 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
let threads = [...(options?.threads ?? [])];
|
||||
const agents = options?.agents ?? [];
|
||||
const skills = options?.skills ?? DEFAULT_SKILLS;
|
||||
const uploadLimits = options?.uploadLimits ?? {
|
||||
max_files: 10,
|
||||
max_file_size: 50 * 1024 * 1024,
|
||||
max_total_size: 100 * 1024 * 1024,
|
||||
};
|
||||
|
||||
const upsertThread = (thread: MockThread) => {
|
||||
threads = [
|
||||
@ -283,6 +293,17 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
void page.route("**/api/threads/*/uploads/limits", (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(uploadLimits),
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
// Thread history — useStream fetches state history on mount
|
||||
void page.route("**/api/langgraph/threads/*/history", (route) => {
|
||||
const url = route.request().url();
|
||||
|
||||
@ -2,10 +2,18 @@ import { expect, test } from "@rstest/core";
|
||||
|
||||
import {
|
||||
MACOS_APP_BUNDLE_UPLOAD_MESSAGE,
|
||||
formatUploadSize,
|
||||
isLikelyMacOSAppBundle,
|
||||
splitUnsupportedUploadFiles,
|
||||
validateUploadLimits,
|
||||
} from "@/core/uploads/file-validation";
|
||||
|
||||
const limits = {
|
||||
max_files: 2,
|
||||
max_file_size: 5,
|
||||
max_total_size: 7,
|
||||
};
|
||||
|
||||
test("identifies Finder-style .app bundle uploads as unsupported", () => {
|
||||
expect(
|
||||
isLikelyMacOSAppBundle({
|
||||
@ -51,3 +59,66 @@ test("returns no message when every file is supported", () => {
|
||||
expect(result.rejected.length).toBe(0);
|
||||
expect(result.message).toBeUndefined();
|
||||
});
|
||||
|
||||
test("accepts a file at the per-file limit and rejects one byte over", () => {
|
||||
const atLimit = new File(["12345"], "at-limit.txt");
|
||||
const overLimit = new File(["123456"], "over-limit.txt");
|
||||
|
||||
const result = validateUploadLimits([], [atLimit, overLimit], limits);
|
||||
|
||||
expect(result.accepted).toEqual([atLimit]);
|
||||
expect(result.rejected).toEqual([overLimit]);
|
||||
expect(result.violations).toEqual([
|
||||
{ code: "max_file_size", files: [overLimit], limit: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("counts existing files and their size before incoming files", () => {
|
||||
const existing = new File(["12"], "existing.txt");
|
||||
const accepted = new File(["12345"], "accepted.txt");
|
||||
const overCount = new File(["1"], "over-count.txt");
|
||||
|
||||
const result = validateUploadLimits(
|
||||
[existing],
|
||||
[accepted, overCount],
|
||||
limits,
|
||||
);
|
||||
|
||||
expect(result.accepted).toEqual([accepted]);
|
||||
expect(result.rejected).toEqual([overCount]);
|
||||
expect(result.violations[0]?.code).toBe("max_files");
|
||||
});
|
||||
|
||||
test("keeps selection order and aggregates each violation category", () => {
|
||||
const first = new File(["1234"], "first.txt");
|
||||
const overSize = new File(["123456"], "over-size.txt");
|
||||
const overTotal = new File(["1234"], "over-total.txt");
|
||||
const second = new File(["1"], "second.txt");
|
||||
|
||||
const result = validateUploadLimits(
|
||||
[],
|
||||
[first, overSize, overTotal, second],
|
||||
limits,
|
||||
);
|
||||
|
||||
expect(result.accepted).toEqual([first, second]);
|
||||
expect(result.violations).toEqual([
|
||||
{ code: "max_file_size", files: [overSize], limit: 5 },
|
||||
{ code: "max_total_size", files: [overTotal], limit: 7 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not block files when upload limits are unavailable", () => {
|
||||
const file = new File(["123456"], "fallback.txt");
|
||||
|
||||
const result = validateUploadLimits([], [file]);
|
||||
|
||||
expect(result.accepted).toEqual([file]);
|
||||
expect(result.rejected).toEqual([]);
|
||||
expect(result.violations).toEqual([]);
|
||||
});
|
||||
|
||||
test("formats binary upload limits for display", () => {
|
||||
expect(formatUploadSize(50 * 1024 * 1024)).toBe("50 MiB");
|
||||
expect(formatUploadSize(1536)).toBe("1.5 KiB");
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user