mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 08:48:10 +08:00
402 lines
13 KiB
TypeScript
402 lines
13 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
BulkDocumentUploadTooLargeError,
|
|
BulkDocumentUploadValidationError,
|
|
DEFAULT_BULK_DOCUMENT_UPLOAD_MAX_BYTES,
|
|
DEFAULT_BULK_DOCUMENT_UPLOAD_MAX_FILES,
|
|
DEFAULT_DOCUMENT_UPLOAD_MAX_BYTES,
|
|
DocumentUploadTooLargeError,
|
|
DocumentUploadValidationError,
|
|
HARD_BULK_DOCUMENT_UPLOAD_MAX_BYTES,
|
|
HARD_BULK_DOCUMENT_UPLOAD_MAX_FILES,
|
|
HARD_DOCUMENT_UPLOAD_MAX_BYTES,
|
|
createDocumentAssetStatusUrl,
|
|
createLogicalDocumentTaskStatusUrl,
|
|
readBulkDocumentUpload,
|
|
readBulkDocumentUploadWithAdmission,
|
|
readDocumentUpload,
|
|
sha256Hex,
|
|
} from "./document-upload-utils";
|
|
|
|
describe("document upload utilities", () => {
|
|
it("reads a single multipart file with bounded bytes and optional source id", async () => {
|
|
const file = new File(["hello"], "note.md", { type: "text/markdown" });
|
|
|
|
const upload = await readDocumentUpload(
|
|
{
|
|
parseBody: async () => ({ file, sourceId: "source-1" }),
|
|
},
|
|
10,
|
|
);
|
|
|
|
expect(upload.file).toBe(file);
|
|
expect(upload.mimeType).toBe("text/markdown");
|
|
expect(upload.sourceId).toBe("source-1");
|
|
expect(new TextDecoder().decode(upload.body)).toBe("hello");
|
|
});
|
|
|
|
it("rejects malformed or oversized single uploads", async () => {
|
|
await expect(readDocumentUpload({ parseBody: async () => ({}) }, 10)).rejects.toThrow(
|
|
DocumentUploadValidationError,
|
|
);
|
|
|
|
await expect(
|
|
readDocumentUpload(
|
|
{
|
|
parseBody: async () => ({ file: new File(["too large"], "large.txt") }),
|
|
},
|
|
3,
|
|
),
|
|
).rejects.toThrow(DocumentUploadTooLargeError);
|
|
});
|
|
|
|
it("requires the complete explicit CAS tuple for a single-file revision upload", async () => {
|
|
const file = new File(["revision"], "revision.md", { type: "text/markdown" });
|
|
const documentId = "018f0d60-7a49-7cc2-9c1b-5b36f18f2d01";
|
|
await expect(
|
|
readDocumentUpload(
|
|
{
|
|
parseBody: async () => ({
|
|
documentId,
|
|
expectedActiveRevision: "2",
|
|
expectedDocumentRowVersion: "3",
|
|
file,
|
|
}),
|
|
},
|
|
20,
|
|
),
|
|
).resolves.toMatchObject({
|
|
documentId,
|
|
expectedActiveRevision: 2,
|
|
expectedDocumentRowVersion: 3,
|
|
});
|
|
|
|
await expect(
|
|
readDocumentUpload(
|
|
{ parseBody: async () => ({ documentId, expectedActiveRevision: "2", file }) },
|
|
20,
|
|
),
|
|
).rejects.toThrow(
|
|
"Document revision upload requires expectedActiveRevision and expectedDocumentRowVersion",
|
|
);
|
|
});
|
|
|
|
it("reads bulk uploads, computes hashes, and enforces aggregate limits", async () => {
|
|
const first = new File(["a"], "a.txt", { type: "text/plain" });
|
|
const second = new File(["bc"], "b.txt", { type: "text/plain" });
|
|
|
|
const uploads = await readBulkDocumentUpload(
|
|
{ parseBody: async () => ({ files: [first, second] }) },
|
|
10,
|
|
2,
|
|
3,
|
|
);
|
|
|
|
expect(uploads.map((upload) => upload.sha256)).toEqual([
|
|
await sha256Hex(new TextEncoder().encode("a")),
|
|
await sha256Hex(new TextEncoder().encode("bc")),
|
|
]);
|
|
|
|
await expect(
|
|
readBulkDocumentUpload({ parseBody: async () => ({ files: [first, second] }) }, 10, 2, 2),
|
|
).rejects.toThrow(BulkDocumentUploadTooLargeError);
|
|
});
|
|
|
|
it("rejects malformed bulk uploads and builds document status urls", async () => {
|
|
await expect(
|
|
readBulkDocumentUpload({ parseBody: async () => ({ files: [] }) }, 10, 2, 10),
|
|
).rejects.toThrow(BulkDocumentUploadValidationError);
|
|
|
|
expect(
|
|
createDocumentAssetStatusUrl({
|
|
documentAssetId: "doc-1",
|
|
knowledgeSpaceId: "space-1",
|
|
}),
|
|
).toBe("/knowledge-spaces/space-1/documents/doc-1");
|
|
expect(
|
|
createLogicalDocumentTaskStatusUrl({
|
|
documentId: "logical-1",
|
|
knowledgeSpaceId: "space-1",
|
|
taskId: "task-1",
|
|
}),
|
|
).toBe("/knowledge-spaces/space-1/documents/logical-1/processing-tasks/task-1");
|
|
});
|
|
|
|
it("keeps product defaults below the explicit hard upload boundaries", () => {
|
|
expect(DEFAULT_DOCUMENT_UPLOAD_MAX_BYTES).toBe(15 * 1024 * 1024);
|
|
expect(DEFAULT_BULK_DOCUMENT_UPLOAD_MAX_BYTES).toBe(50 * 1024 * 1024);
|
|
expect(DEFAULT_BULK_DOCUMENT_UPLOAD_MAX_FILES).toBe(20);
|
|
expect(HARD_DOCUMENT_UPLOAD_MAX_BYTES).toBe(50 * 1024 * 1024);
|
|
expect(HARD_BULK_DOCUMENT_UPLOAD_MAX_FILES).toBe(25);
|
|
expect(HARD_BULK_DOCUMENT_UPLOAD_MAX_BYTES).toBe(
|
|
HARD_DOCUMENT_UPLOAD_MAX_BYTES * HARD_BULK_DOCUMENT_UPLOAD_MAX_FILES,
|
|
);
|
|
});
|
|
|
|
it("admits valid files independently from malformed, unsupported, and over-limit entries", async () => {
|
|
const result = await readBulkDocumentUploadWithAdmission(
|
|
{
|
|
parseBody: async () => ({
|
|
files: [
|
|
new File(["good"], "good.md", { type: "text/markdown" }),
|
|
"not-a-file",
|
|
new File(["bad"], "bad.exe", { type: "application/octet-stream" }),
|
|
new File(["large"], "large.txt", { type: "text/plain" }),
|
|
],
|
|
}),
|
|
},
|
|
{
|
|
maxAcceptedBytesByQuota: null,
|
|
maxBulkUploadBytes: 100,
|
|
maxBulkUploadFiles: 20,
|
|
maxUploadBytes: 4,
|
|
},
|
|
);
|
|
|
|
expect(result.accepted.map((item) => item.filename)).toEqual(["good.md"]);
|
|
expect(result.items.map((item) => item.status)).toEqual([
|
|
"accepted",
|
|
"excluded",
|
|
"excluded",
|
|
"excluded",
|
|
]);
|
|
expect(result.excluded.map((item) => item.reason)).toEqual([
|
|
"invalid_file",
|
|
"unsupported_mime_type",
|
|
"file_too_large",
|
|
]);
|
|
});
|
|
|
|
it("maps bulk revision targets only by original file index and carries the complete CAS tuple", async () => {
|
|
const first = new File(["new"], "same-name.md", { type: "text/markdown" });
|
|
const second = new File(["revision"], "same-name.md", { type: "text/markdown" });
|
|
const documentId = "018f0d60-7a49-7cc2-9c1b-5b36f18f2d01";
|
|
const result = await readBulkDocumentUploadWithAdmission(
|
|
{
|
|
parseBody: async () => ({
|
|
files: [first, second],
|
|
targets: JSON.stringify([
|
|
{
|
|
documentId,
|
|
expectedActiveRevision: 4,
|
|
expectedDocumentRowVersion: 5,
|
|
index: 1,
|
|
},
|
|
]),
|
|
}),
|
|
},
|
|
{
|
|
maxAcceptedBytesByQuota: null,
|
|
maxBulkUploadBytes: 100,
|
|
maxBulkUploadFiles: 20,
|
|
maxUploadBytes: 100,
|
|
},
|
|
);
|
|
|
|
expect(result.accepted[0]?.upload).not.toHaveProperty("documentId");
|
|
expect(result.accepted[1]?.upload).toMatchObject({
|
|
documentId,
|
|
expectedActiveRevision: 4,
|
|
expectedDocumentRowVersion: 5,
|
|
});
|
|
});
|
|
|
|
it("rejects duplicate, ambiguous, incomplete, and out-of-range bulk revision targets", async () => {
|
|
const files = [
|
|
new File(["one"], "one.md", { type: "text/markdown" }),
|
|
new File(["two"], "two.md", { type: "text/markdown" }),
|
|
];
|
|
const firstDocumentId = "018f0d60-7a49-7cc2-9c1b-5b36f18f2d01";
|
|
const secondDocumentId = "018f0d60-7a49-7cc2-9c1b-5b36f18f2d02";
|
|
const target = {
|
|
documentId: firstDocumentId,
|
|
expectedActiveRevision: 1,
|
|
expectedDocumentRowVersion: 1,
|
|
index: 0,
|
|
};
|
|
const invalidBodies: ReadonlyArray<{
|
|
readonly body: Record<string, unknown>;
|
|
readonly message: string;
|
|
}> = [
|
|
{
|
|
body: {
|
|
files,
|
|
targets: JSON.stringify([target, { ...target, documentId: secondDocumentId }]),
|
|
},
|
|
message: "duplicate index 0",
|
|
},
|
|
{
|
|
body: { files, targets: JSON.stringify([target, { ...target, index: 1 }]) },
|
|
message: `duplicate documentId ${firstDocumentId}`,
|
|
},
|
|
{
|
|
body: { files, targets: JSON.stringify([{ ...target, index: 2 }]) },
|
|
message: "index 2 is outside the files sequence",
|
|
},
|
|
{
|
|
body: {
|
|
files,
|
|
targets: JSON.stringify([
|
|
{ documentId: firstDocumentId, expectedActiveRevision: 1, index: 0 },
|
|
]),
|
|
},
|
|
message:
|
|
"requires index, documentId, expectedActiveRevision, and expectedDocumentRowVersion",
|
|
},
|
|
{
|
|
body: {
|
|
documentId: firstDocumentId,
|
|
expectedActiveRevision: "1",
|
|
expectedDocumentRowVersion: "1",
|
|
files,
|
|
targets: JSON.stringify([target]),
|
|
},
|
|
message: "top-level document CAS is ambiguous",
|
|
},
|
|
{
|
|
body: { files, targets: [JSON.stringify([target]), JSON.stringify([target])] },
|
|
message: "targets must be provided exactly once",
|
|
},
|
|
];
|
|
|
|
for (const invalid of invalidBodies) {
|
|
await expect(
|
|
readBulkDocumentUploadWithAdmission(
|
|
{ parseBody: async () => invalid.body },
|
|
{
|
|
maxAcceptedBytesByQuota: null,
|
|
maxBulkUploadBytes: 100,
|
|
maxBulkUploadFiles: 20,
|
|
maxUploadBytes: 100,
|
|
},
|
|
),
|
|
).rejects.toThrow(invalid.message);
|
|
}
|
|
});
|
|
|
|
it("accepts JSONL MIME aliases and extension inference", async () => {
|
|
for (const type of [
|
|
"application/x-ndjson",
|
|
"application/jsonl",
|
|
"application/ndjson",
|
|
"application/json",
|
|
"application/octet-stream",
|
|
]) {
|
|
const result = await readBulkDocumentUploadWithAdmission(
|
|
{
|
|
parseBody: async () => ({
|
|
files: [new File(['{"id":1}\n'], "events.jsonl", { type })],
|
|
}),
|
|
},
|
|
{
|
|
maxAcceptedBytesByQuota: null,
|
|
maxBulkUploadBytes: 100,
|
|
maxBulkUploadFiles: 20,
|
|
maxUploadBytes: 100,
|
|
},
|
|
);
|
|
expect(result.accepted).toHaveLength(1);
|
|
expect(result.accepted[0]?.mimeType).toBe(
|
|
type === "application/octet-stream" ? "application/x-ndjson" : type,
|
|
);
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
["README.markdown", "text/markdown"],
|
|
["component.mdx", "text/mdx"],
|
|
["captions.vtt", "text/vtt"],
|
|
["application.properties", "text/x-java-properties"],
|
|
["formatted.rtf", "text/rtf"],
|
|
["feed.xml", "application/xml"],
|
|
["manual.odt", "application/vnd.oasis.opendocument.text"],
|
|
["message.eml", "message/rfc822"],
|
|
["message.msg", "application/vnd.ms-outlook"],
|
|
])("accepts legacy-compatible document upload %s", async (filename, declaredMimeType) => {
|
|
for (const type of [declaredMimeType, "application/octet-stream"]) {
|
|
const result = await readBulkDocumentUploadWithAdmission(
|
|
{
|
|
parseBody: async () => ({
|
|
files: [new File(["content"], filename, { type })],
|
|
}),
|
|
},
|
|
{
|
|
maxAcceptedBytesByQuota: null,
|
|
maxBulkUploadBytes: 100,
|
|
maxBulkUploadFiles: 20,
|
|
maxUploadBytes: 100,
|
|
},
|
|
);
|
|
|
|
expect(result.accepted).toHaveLength(1);
|
|
expect(result.accepted[0]?.filename).toBe(filename);
|
|
}
|
|
});
|
|
|
|
it("rejects a supported extension paired with an unrelated MIME type", async () => {
|
|
await expect(
|
|
readDocumentUpload(
|
|
{
|
|
parseBody: async () => ({
|
|
file: new File(["%PDF-1.7"], "report.pdf", { type: "text/plain" }),
|
|
}),
|
|
},
|
|
100,
|
|
),
|
|
).rejects.toThrow(DocumentUploadValidationError);
|
|
});
|
|
|
|
it("reports quota, aggregate-byte, and count exclusions without discarding earlier files", async () => {
|
|
const files = [
|
|
new File(["aa"], "a.txt", { type: "text/plain" }),
|
|
new File(["bb"], "b.txt", { type: "text/plain" }),
|
|
new File(["cc"], "c.txt", { type: "text/plain" }),
|
|
];
|
|
const quota = await readBulkDocumentUploadWithAdmission(
|
|
{ parseBody: async () => ({ files }) },
|
|
{
|
|
maxAcceptedBytesByQuota: 3,
|
|
maxBulkUploadBytes: 100,
|
|
maxBulkUploadFiles: 20,
|
|
maxUploadBytes: 10,
|
|
},
|
|
);
|
|
expect(quota.items.map((item) => item.reason ?? "accepted")).toEqual([
|
|
"accepted",
|
|
"quota_exceeded",
|
|
"quota_exceeded",
|
|
]);
|
|
|
|
const aggregate = await readBulkDocumentUploadWithAdmission(
|
|
{ parseBody: async () => ({ files }) },
|
|
{
|
|
maxAcceptedBytesByQuota: null,
|
|
maxBulkUploadBytes: 3,
|
|
maxBulkUploadFiles: 20,
|
|
maxUploadBytes: 10,
|
|
},
|
|
);
|
|
expect(aggregate.items.map((item) => item.reason ?? "accepted")).toEqual([
|
|
"accepted",
|
|
"batch_byte_limit_exceeded",
|
|
"batch_byte_limit_exceeded",
|
|
]);
|
|
|
|
const count = await readBulkDocumentUploadWithAdmission(
|
|
{ parseBody: async () => ({ files }) },
|
|
{
|
|
maxAcceptedBytesByQuota: null,
|
|
maxBulkUploadBytes: 100,
|
|
maxBulkUploadFiles: 1,
|
|
maxUploadBytes: 10,
|
|
},
|
|
);
|
|
expect(count.items.map((item) => item.reason ?? "accepted")).toEqual([
|
|
"accepted",
|
|
"file_count_limit_exceeded",
|
|
"file_count_limit_exceeded",
|
|
]);
|
|
});
|
|
});
|