mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
The Open File button in the Files section did nothing due to a race condition where the home page reset the file store on mount before files from handleOpenFile could render. Upload on the files page used fetch with no timeout, progress, or retry, causing silent failures on mobile and slow connections. SSE connections for job progress had no keepalive pings, allowing reverse proxies to kill idle streams.
397 lines
12 KiB
TypeScript
397 lines
12 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// Bypass zustand persist middleware
|
|
vi.mock("zustand/middleware", async (importOriginal) => {
|
|
const actual: Record<string, unknown> = await importOriginal();
|
|
return {
|
|
...actual,
|
|
persist: (config: unknown) => config,
|
|
};
|
|
});
|
|
|
|
// Global mocks
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const storageMap = new Map<string, string>();
|
|
vi.stubGlobal("localStorage", {
|
|
getItem: vi.fn((key: string) => storageMap.get(key) ?? null),
|
|
setItem: vi.fn((key: string, val: string) => storageMap.set(key, val)),
|
|
removeItem: vi.fn((key: string) => storageMap.delete(key)),
|
|
clear: vi.fn(() => storageMap.clear()),
|
|
get length() {
|
|
return storageMap.size;
|
|
},
|
|
key: vi.fn((_i: number) => null),
|
|
});
|
|
|
|
vi.stubGlobal(
|
|
"matchMedia",
|
|
vi.fn().mockReturnValue({
|
|
matches: false,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
}),
|
|
);
|
|
|
|
// Mock the api module
|
|
vi.mock("@/lib/api", () => ({
|
|
apiListFiles: vi.fn(),
|
|
apiUploadUserFiles: vi.fn(),
|
|
apiDeleteUserFiles: vi.fn(),
|
|
apiGet: vi.fn(),
|
|
apiPost: vi.fn(),
|
|
apiPut: vi.fn(),
|
|
apiDelete: vi.fn(),
|
|
formatHeaders: vi.fn(() => new Headers()),
|
|
}));
|
|
|
|
import { apiDeleteUserFiles, apiListFiles, apiUploadUserFiles } from "@/lib/api";
|
|
import { useFilesPageStore } from "@/stores/files-page-store";
|
|
|
|
const mockApiListFiles = vi.mocked(apiListFiles);
|
|
const mockApiUploadUserFiles = vi.mocked(apiUploadUserFiles);
|
|
const mockApiDeleteUserFiles = vi.mocked(apiDeleteUserFiles);
|
|
|
|
// ==========================================================================
|
|
// FilesPageStore
|
|
// ==========================================================================
|
|
describe("useFilesPageStore", () => {
|
|
beforeEach(() => {
|
|
mockApiListFiles.mockReset();
|
|
mockApiUploadUserFiles.mockReset();
|
|
mockApiDeleteUserFiles.mockReset();
|
|
useFilesPageStore.setState({
|
|
files: [],
|
|
total: 0,
|
|
selectedFileId: null,
|
|
checkedIds: new Set(),
|
|
activeTab: "recent",
|
|
searchQuery: "",
|
|
loading: false,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
// -- Initial state -------------------------------------------------------
|
|
|
|
it("has correct initial state", () => {
|
|
const s = useFilesPageStore.getState();
|
|
expect(s.files).toEqual([]);
|
|
expect(s.total).toBe(0);
|
|
expect(s.selectedFileId).toBeNull();
|
|
expect(s.checkedIds.size).toBe(0);
|
|
expect(s.activeTab).toBe("recent");
|
|
expect(s.searchQuery).toBe("");
|
|
expect(s.loading).toBe(false);
|
|
expect(s.error).toBeNull();
|
|
});
|
|
|
|
// -- selectFile -----------------------------------------------------------
|
|
|
|
it("selectFile sets selected file ID", () => {
|
|
useFilesPageStore.getState().selectFile("file-1");
|
|
expect(useFilesPageStore.getState().selectedFileId).toBe("file-1");
|
|
});
|
|
|
|
it("selectFile with null clears selection", () => {
|
|
useFilesPageStore.getState().selectFile("file-1");
|
|
useFilesPageStore.getState().selectFile(null);
|
|
expect(useFilesPageStore.getState().selectedFileId).toBeNull();
|
|
});
|
|
|
|
// -- setSearchQuery -------------------------------------------------------
|
|
|
|
it("setSearchQuery updates search query", () => {
|
|
useFilesPageStore.getState().setSearchQuery("vacation");
|
|
expect(useFilesPageStore.getState().searchQuery).toBe("vacation");
|
|
});
|
|
|
|
// -- setActiveTab ---------------------------------------------------------
|
|
|
|
it("setActiveTab switches to upload tab", () => {
|
|
useFilesPageStore.getState().setActiveTab("upload");
|
|
expect(useFilesPageStore.getState().activeTab).toBe("upload");
|
|
});
|
|
|
|
it("setActiveTab switches to recent tab", () => {
|
|
useFilesPageStore.getState().setActiveTab("upload");
|
|
useFilesPageStore.getState().setActiveTab("recent");
|
|
expect(useFilesPageStore.getState().activeTab).toBe("recent");
|
|
});
|
|
|
|
// -- toggleChecked --------------------------------------------------------
|
|
|
|
it("toggleChecked adds an ID to the checked set", () => {
|
|
useFilesPageStore.getState().toggleChecked("file-1");
|
|
expect(useFilesPageStore.getState().checkedIds.has("file-1")).toBe(true);
|
|
});
|
|
|
|
it("toggleChecked removes an already-checked ID", () => {
|
|
useFilesPageStore.getState().toggleChecked("file-1");
|
|
useFilesPageStore.getState().toggleChecked("file-1");
|
|
expect(useFilesPageStore.getState().checkedIds.has("file-1")).toBe(false);
|
|
});
|
|
|
|
it("toggleChecked handles multiple IDs", () => {
|
|
useFilesPageStore.getState().toggleChecked("file-1");
|
|
useFilesPageStore.getState().toggleChecked("file-2");
|
|
expect(useFilesPageStore.getState().checkedIds.size).toBe(2);
|
|
expect(useFilesPageStore.getState().checkedIds.has("file-1")).toBe(true);
|
|
expect(useFilesPageStore.getState().checkedIds.has("file-2")).toBe(true);
|
|
});
|
|
|
|
// -- toggleCheckAll -------------------------------------------------------
|
|
|
|
it("toggleCheckAll selects all files when none are selected", () => {
|
|
const files = [
|
|
{
|
|
id: "f1",
|
|
originalName: "a.png",
|
|
mimeType: "image/png",
|
|
size: 100,
|
|
width: 10,
|
|
height: 10,
|
|
version: 1,
|
|
toolChain: [],
|
|
createdAt: "",
|
|
},
|
|
{
|
|
id: "f2",
|
|
originalName: "b.png",
|
|
mimeType: "image/png",
|
|
size: 200,
|
|
width: 20,
|
|
height: 20,
|
|
version: 1,
|
|
toolChain: [],
|
|
createdAt: "",
|
|
},
|
|
];
|
|
useFilesPageStore.setState({ files });
|
|
useFilesPageStore.getState().toggleCheckAll();
|
|
const checked = useFilesPageStore.getState().checkedIds;
|
|
expect(checked.size).toBe(2);
|
|
expect(checked.has("f1")).toBe(true);
|
|
expect(checked.has("f2")).toBe(true);
|
|
});
|
|
|
|
it("toggleCheckAll deselects all when all are already selected", () => {
|
|
const files = [
|
|
{
|
|
id: "f1",
|
|
originalName: "a.png",
|
|
mimeType: "image/png",
|
|
size: 100,
|
|
width: 10,
|
|
height: 10,
|
|
version: 1,
|
|
toolChain: [],
|
|
createdAt: "",
|
|
},
|
|
{
|
|
id: "f2",
|
|
originalName: "b.png",
|
|
mimeType: "image/png",
|
|
size: 200,
|
|
width: 20,
|
|
height: 20,
|
|
version: 1,
|
|
toolChain: [],
|
|
createdAt: "",
|
|
},
|
|
];
|
|
useFilesPageStore.setState({ files, checkedIds: new Set(["f1", "f2"]) });
|
|
useFilesPageStore.getState().toggleCheckAll();
|
|
expect(useFilesPageStore.getState().checkedIds.size).toBe(0);
|
|
});
|
|
|
|
it("toggleCheckAll selects all when only some are checked", () => {
|
|
const files = [
|
|
{
|
|
id: "f1",
|
|
originalName: "a.png",
|
|
mimeType: "image/png",
|
|
size: 100,
|
|
width: 10,
|
|
height: 10,
|
|
version: 1,
|
|
toolChain: [],
|
|
createdAt: "",
|
|
},
|
|
{
|
|
id: "f2",
|
|
originalName: "b.png",
|
|
mimeType: "image/png",
|
|
size: 200,
|
|
width: 20,
|
|
height: 20,
|
|
version: 1,
|
|
toolChain: [],
|
|
createdAt: "",
|
|
},
|
|
{
|
|
id: "f3",
|
|
originalName: "c.png",
|
|
mimeType: "image/png",
|
|
size: 300,
|
|
width: 30,
|
|
height: 30,
|
|
version: 1,
|
|
toolChain: [],
|
|
createdAt: "",
|
|
},
|
|
];
|
|
useFilesPageStore.setState({ files, checkedIds: new Set(["f1"]) });
|
|
useFilesPageStore.getState().toggleCheckAll();
|
|
expect(useFilesPageStore.getState().checkedIds.size).toBe(3);
|
|
});
|
|
|
|
// -- fetchFiles -----------------------------------------------------------
|
|
|
|
it("fetchFiles loads files from API", async () => {
|
|
const files = [
|
|
{
|
|
id: "f1",
|
|
originalName: "a.png",
|
|
mimeType: "image/png",
|
|
size: 100,
|
|
width: 10,
|
|
height: 10,
|
|
version: 1,
|
|
toolChain: [],
|
|
createdAt: "",
|
|
},
|
|
];
|
|
mockApiListFiles.mockResolvedValueOnce({ files, total: 1 });
|
|
|
|
await useFilesPageStore.getState().fetchFiles();
|
|
|
|
const s = useFilesPageStore.getState();
|
|
expect(s.files).toEqual(files);
|
|
expect(s.total).toBe(1);
|
|
expect(s.loading).toBe(false);
|
|
expect(s.error).toBeNull();
|
|
});
|
|
|
|
it("fetchFiles passes searchQuery to API", async () => {
|
|
useFilesPageStore.getState().setSearchQuery("sunset");
|
|
mockApiListFiles.mockResolvedValueOnce({ files: [], total: 0 });
|
|
|
|
await useFilesPageStore.getState().fetchFiles();
|
|
|
|
expect(mockApiListFiles).toHaveBeenCalledWith({ search: "sunset", limit: 200 });
|
|
});
|
|
|
|
it("fetchFiles passes undefined search when searchQuery is empty", async () => {
|
|
mockApiListFiles.mockResolvedValueOnce({ files: [], total: 0 });
|
|
|
|
await useFilesPageStore.getState().fetchFiles();
|
|
|
|
expect(mockApiListFiles).toHaveBeenCalledWith({ search: undefined, limit: 200 });
|
|
});
|
|
|
|
it("fetchFiles sets error on API failure", async () => {
|
|
mockApiListFiles.mockRejectedValueOnce(new Error("Network error"));
|
|
|
|
await useFilesPageStore.getState().fetchFiles();
|
|
|
|
const s = useFilesPageStore.getState();
|
|
expect(s.error).toBe("Network error");
|
|
expect(s.loading).toBe(false);
|
|
});
|
|
|
|
it("fetchFiles sets generic error for non-Error throws", async () => {
|
|
mockApiListFiles.mockRejectedValueOnce("string error");
|
|
|
|
await useFilesPageStore.getState().fetchFiles();
|
|
|
|
expect(useFilesPageStore.getState().error).toBe("Failed to load files");
|
|
});
|
|
|
|
it("fetchFiles sets loading true during request", async () => {
|
|
let loadingDuringCall = false;
|
|
mockApiListFiles.mockImplementation(async () => {
|
|
loadingDuringCall = useFilesPageStore.getState().loading;
|
|
return { files: [], total: 0 };
|
|
});
|
|
|
|
await useFilesPageStore.getState().fetchFiles();
|
|
expect(loadingDuringCall).toBe(true);
|
|
});
|
|
|
|
// -- uploadFiles ----------------------------------------------------------
|
|
|
|
it("uploadFiles calls API then refreshes files", async () => {
|
|
const file = new File(["content"], "photo.png", { type: "image/png" });
|
|
mockApiUploadUserFiles.mockResolvedValueOnce({ files: [] });
|
|
mockApiListFiles.mockResolvedValueOnce({ files: [], total: 0 });
|
|
|
|
await useFilesPageStore.getState().uploadFiles([file]);
|
|
|
|
expect(mockApiUploadUserFiles).toHaveBeenCalledWith([file], expect.any(Function));
|
|
expect(mockApiListFiles).toHaveBeenCalled();
|
|
expect(useFilesPageStore.getState().activeTab).toBe("recent");
|
|
});
|
|
|
|
it("uploadFiles sets error on failure", async () => {
|
|
const file = new File(["content"], "photo.png", { type: "image/png" });
|
|
mockApiUploadUserFiles.mockRejectedValueOnce(new Error("Too large"));
|
|
|
|
await useFilesPageStore.getState().uploadFiles([file]);
|
|
|
|
expect(useFilesPageStore.getState().error).toBe("Too large");
|
|
expect(useFilesPageStore.getState().loading).toBe(false);
|
|
});
|
|
|
|
it("uploadFiles sets generic error for non-Error throws", async () => {
|
|
const file = new File(["content"], "photo.png", { type: "image/png" });
|
|
mockApiUploadUserFiles.mockRejectedValueOnce(42);
|
|
|
|
await useFilesPageStore.getState().uploadFiles([file]);
|
|
|
|
expect(useFilesPageStore.getState().error).toBe("Upload failed");
|
|
});
|
|
|
|
// -- deleteChecked --------------------------------------------------------
|
|
|
|
it("deleteChecked calls API with checked IDs and refreshes", async () => {
|
|
useFilesPageStore.setState({ checkedIds: new Set(["f1", "f2"]) });
|
|
mockApiDeleteUserFiles.mockResolvedValueOnce({ deleted: 2 });
|
|
mockApiListFiles.mockResolvedValueOnce({ files: [], total: 0 });
|
|
|
|
await useFilesPageStore.getState().deleteChecked();
|
|
|
|
expect(mockApiDeleteUserFiles).toHaveBeenCalledWith(expect.arrayContaining(["f1", "f2"]));
|
|
expect(useFilesPageStore.getState().checkedIds.size).toBe(0);
|
|
expect(useFilesPageStore.getState().selectedFileId).toBeNull();
|
|
});
|
|
|
|
it("deleteChecked is a no-op when no IDs are checked", async () => {
|
|
await useFilesPageStore.getState().deleteChecked();
|
|
|
|
expect(mockApiDeleteUserFiles).not.toHaveBeenCalled();
|
|
expect(useFilesPageStore.getState().loading).toBe(false);
|
|
});
|
|
|
|
it("deleteChecked sets error on failure", async () => {
|
|
useFilesPageStore.setState({ checkedIds: new Set(["f1"]) });
|
|
mockApiDeleteUserFiles.mockRejectedValueOnce(new Error("Permission denied"));
|
|
|
|
await useFilesPageStore.getState().deleteChecked();
|
|
|
|
expect(useFilesPageStore.getState().error).toBe("Permission denied");
|
|
expect(useFilesPageStore.getState().loading).toBe(false);
|
|
});
|
|
|
|
it("deleteChecked sets generic error for non-Error throws", async () => {
|
|
useFilesPageStore.setState({ checkedIds: new Set(["f1"]) });
|
|
mockApiDeleteUserFiles.mockRejectedValueOnce("boom");
|
|
|
|
await useFilesPageStore.getState().deleteChecked();
|
|
|
|
expect(useFilesPageStore.getState().error).toBe("Delete failed");
|
|
});
|
|
});
|