Skip to content

Commit 9833762

Browse files
committed
Adds upload collection flow test coverage
Introduces shared upload fixtures to keep tests consistent and easier to extend. Expands coverage for item validation, direct collection submission, and two-step collection creation so library-only and mixed uploads behave reliably. Verifies retry and recovery paths after interrupted or failed collection creation, helping prevent silent post-upload errors.
1 parent 7662a3b commit 9833762

4 files changed

Lines changed: 388 additions & 47 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type { UploadCollectionConfig } from "@/composables/upload/collectionTypes";
2+
import type {
3+
LibraryDatasetUploadItem,
4+
LocalFileUploadItem,
5+
PastedContentUploadItem,
6+
RemoteFileUploadItem,
7+
UrlUploadItem,
8+
} from "@/composables/upload/uploadItemTypes";
9+
10+
export function makePastedItem(overrides: Partial<PastedContentUploadItem> = {}): PastedContentUploadItem {
11+
return {
12+
uploadMode: "paste-content",
13+
name: "file.txt",
14+
content: "hello world",
15+
size: 11,
16+
targetHistoryId: "hist_1",
17+
dbkey: "?",
18+
extension: "auto",
19+
spaceToTab: false,
20+
toPosixLines: false,
21+
deferred: false,
22+
...overrides,
23+
};
24+
}
25+
26+
export function makeUrlItem(overrides: Partial<UrlUploadItem> = {}): UrlUploadItem {
27+
return {
28+
uploadMode: "paste-links",
29+
name: "file.txt",
30+
url: "http://example.com/file.txt",
31+
size: 0,
32+
targetHistoryId: "hist_1",
33+
dbkey: "?",
34+
extension: "auto",
35+
spaceToTab: false,
36+
toPosixLines: false,
37+
deferred: false,
38+
...overrides,
39+
};
40+
}
41+
42+
export function makeRemoteFilesItem(overrides: Partial<RemoteFileUploadItem> = {}): RemoteFileUploadItem {
43+
return {
44+
uploadMode: "remote-files",
45+
name: "file.txt",
46+
url: "ftp://server/file.txt",
47+
size: 0,
48+
targetHistoryId: "hist_1",
49+
dbkey: "?",
50+
extension: "auto",
51+
spaceToTab: false,
52+
toPosixLines: false,
53+
deferred: false,
54+
...overrides,
55+
};
56+
}
57+
58+
export function makeLibraryItem(overrides: Partial<LibraryDatasetUploadItem> = {}): LibraryDatasetUploadItem {
59+
return {
60+
uploadMode: "data-library",
61+
name: "library.txt",
62+
size: 0,
63+
targetHistoryId: "hist_1",
64+
dbkey: "?",
65+
extension: "auto",
66+
spaceToTab: false,
67+
toPosixLines: false,
68+
deferred: false,
69+
libraryId: "lib_1",
70+
folderId: "folder_1",
71+
lddaId: "ldda_1",
72+
url: "/api/libraries/lib_1/datasets/ldda_1",
73+
...overrides,
74+
};
75+
}
76+
77+
export function makeLocalFileItem(overrides: Partial<LocalFileUploadItem> = {}): LocalFileUploadItem {
78+
const file = new File(["content"], "test.txt");
79+
return {
80+
uploadMode: "local-file",
81+
name: "test.txt",
82+
size: file.size,
83+
targetHistoryId: "hist_1",
84+
dbkey: "?",
85+
extension: "auto",
86+
spaceToTab: false,
87+
toPosixLines: false,
88+
deferred: false,
89+
fileData: file,
90+
...overrides,
91+
};
92+
}
93+
94+
export function makeCollectionConfig(overrides: Partial<UploadCollectionConfig> = {}): UploadCollectionConfig {
95+
return {
96+
name: "My Collection",
97+
type: "list",
98+
historyId: "hist_1",
99+
hideSourceItems: false,
100+
...overrides,
101+
};
102+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import {
4+
makeLibraryItem,
5+
makeLocalFileItem,
6+
makePastedItem,
7+
makeRemoteFilesItem,
8+
makeUrlItem,
9+
} from "@/composables/upload/testHelpers/uploadFixtures";
10+
import type { NewUploadItem } from "@/composables/upload/uploadItemTypes";
11+
12+
import { validateUploadItem } from "./uploadItemTypes";
13+
14+
describe("validateUploadItem", () => {
15+
it.each([
16+
["paste-content", makePastedItem()],
17+
["paste-links", makeUrlItem()],
18+
["remote-files", makeRemoteFilesItem()],
19+
["data-library", makeLibraryItem()],
20+
["local-file", makeLocalFileItem()],
21+
] as [string, NewUploadItem][])("accepts a valid %s item", (_mode, item) => {
22+
expect(validateUploadItem(item)).toBeUndefined();
23+
});
24+
25+
it("rejects paste-content with empty content", () => {
26+
expect(validateUploadItem(makePastedItem({ content: " " }))).toMatch(/No content provided/);
27+
});
28+
29+
it("rejects paste-links with missing URL", () => {
30+
expect(validateUploadItem(makeUrlItem({ url: "" }))).toMatch(/No URL provided/);
31+
});
32+
33+
it("rejects remote-files with missing URL", () => {
34+
expect(validateUploadItem(makeRemoteFilesItem({ url: " " }))).toMatch(/No URL provided/);
35+
});
36+
37+
it("rejects data-library with no lddaId", () => {
38+
expect(validateUploadItem(makeLibraryItem({ lddaId: "" }))).toMatch(/No library dataset ID/);
39+
});
40+
41+
it("rejects local-file with no file data", () => {
42+
const item: NewUploadItem = {
43+
uploadMode: "local-file",
44+
name: "missing.txt",
45+
size: 0,
46+
targetHistoryId: "hist_1",
47+
dbkey: "?",
48+
extension: "auto",
49+
spaceToTab: false,
50+
toPosixLines: false,
51+
deferred: false,
52+
};
53+
expect(validateUploadItem(item)).toMatch(/No file selected/);
54+
});
55+
56+
it("rejects local-file with an empty file", () => {
57+
const emptyFile = new File([], "empty.txt");
58+
const item: NewUploadItem = {
59+
uploadMode: "local-file",
60+
name: "empty.txt",
61+
size: 0,
62+
targetHistoryId: "hist_1",
63+
dbkey: "?",
64+
extension: "auto",
65+
spaceToTab: false,
66+
toPosixLines: false,
67+
deferred: false,
68+
fileData: emptyFile,
69+
};
70+
expect(validateUploadItem(item)).toMatch(/is empty/);
71+
});
72+
73+
it("rejects an unknown upload mode", () => {
74+
const item = { ...makePastedItem(), uploadMode: "unknown-mode" } as unknown as NewUploadItem;
75+
expect(validateUploadItem(item)).toMatch(/Unknown upload mode/);
76+
});
77+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { suppressExpectedErrorMessages } from "@tests/vitest/helpers";
2+
import flushPromises from "flush-promises";
3+
import { http, HttpResponse } from "msw";
4+
import { createPinia, setActivePinia } from "pinia";
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6+
7+
import { useServerMock } from "@/api/client/__mocks__";
8+
import { useUploadState } from "@/components/Panels/Upload/uploadState";
9+
import { makeCollectionConfig, makePastedItem, makeUrlItem } from "@/composables/upload/testHelpers/uploadFixtures";
10+
11+
import { useUploadBatchOperations } from "./useUploadBatchOperations";
12+
13+
const { server } = useServerMock();
14+
15+
describe("useUploadBatchOperations", () => {
16+
beforeEach(() => {
17+
setActivePinia(createPinia());
18+
useUploadState().clearAll();
19+
});
20+
21+
afterEach(() => {
22+
vi.restoreAllMocks();
23+
useUploadState().clearAll();
24+
});
25+
26+
it("processes the direct collection path atomically", async () => {
27+
server.use(
28+
http.get("/api/configuration", () => HttpResponse.json({ chunk_upload_size: 42 })),
29+
http.post("/api/tools/fetch", async ({ request }) => {
30+
const body = await request.json();
31+
expect(body).toMatchObject({
32+
history_id: "hist_1",
33+
targets: [
34+
{
35+
destination: { type: "hdca" },
36+
collection_type: "list",
37+
name: "My Collection",
38+
},
39+
],
40+
});
41+
return HttpResponse.json({ outputs: [{ id: "hdca_1", src: "hdca" }] });
42+
}),
43+
);
44+
45+
const state = useUploadState();
46+
const operations = useUploadBatchOperations({ autoRecover: false });
47+
const first = makeUrlItem({ name: "a.txt" });
48+
const second = makeUrlItem({ name: "b.txt" });
49+
const batchId = state.addBatch(makeCollectionConfig(), [], true);
50+
const id1 = state.addUploadItem(first, batchId);
51+
const id2 = state.addUploadItem(second, batchId);
52+
state.getBatch(batchId)!.uploadIds = [id1, id2];
53+
54+
await operations.processDirectBatch(batchId, [id1, id2], [first, second]);
55+
56+
expect(state.getBatch(batchId)?.status).toBe("completed");
57+
expect(state.activeItems.value.every((item) => item.status === "completed")).toBe(true);
58+
});
59+
60+
it("retries collection creation after an earlier two-step failure", async () => {
61+
suppressExpectedErrorMessages(["Temporary error"]);
62+
server.use(http.post("/api/dataset_collections", () => HttpResponse.json({ id: "col_retried" })));
63+
64+
const state = useUploadState();
65+
const operations = useUploadBatchOperations({ autoRecover: false });
66+
const uploadId = state.addUploadItem(makePastedItem());
67+
state.updateProgress(uploadId, 100);
68+
69+
const batchId = state.addBatch(makeCollectionConfig(), [uploadId], false);
70+
state.addBatchDatasetId(batchId, "ds_1");
71+
state.setBatchError(batchId, "Temporary error");
72+
73+
const item = state.activeItems.value.find((entry) => entry.id === uploadId);
74+
if (item) {
75+
item.error = "Uploaded successfully, but collection creation failed";
76+
}
77+
78+
await operations.retryCollectionCreation(batchId);
79+
80+
expect(state.getBatch(batchId)?.status).toBe("completed");
81+
expect(state.getBatch(batchId)?.collectionId).toBe("col_retried");
82+
expect(state.activeItems.value.find((entry) => entry.id === uploadId)?.error).toBeUndefined();
83+
});
84+
85+
it("recovers interrupted two-step collection creation from persisted state", async () => {
86+
server.use(http.post("/api/dataset_collections", () => HttpResponse.json({ id: "col_recovered" })));
87+
88+
const state = useUploadState();
89+
const itemId = state.addUploadItem(makePastedItem({ name: "recovered.txt" }));
90+
state.setStatus(itemId, "uploading");
91+
state.updateProgress(itemId, 100);
92+
93+
const batchId = state.addBatch(
94+
{ name: "Recovery Collection", type: "list", hideSourceItems: false, historyId: "hist_1" },
95+
[itemId],
96+
false,
97+
);
98+
state.addBatchDatasetId(batchId, "ds_recovered");
99+
100+
const operations = useUploadBatchOperations({ autoRecover: false });
101+
operations.recoverIncompleteBatches();
102+
await flushPromises();
103+
104+
expect(state.getBatch(batchId)?.collectionId).toBe("col_recovered");
105+
expect(state.getBatch(batchId)?.status).toBe("completed");
106+
});
107+
});

0 commit comments

Comments
 (0)