diff --git a/app/Http/Resources/Api/Client/ServerResource.php b/app/Http/Resources/Api/Client/ServerResource.php index 3fce731..70f3574 100644 --- a/app/Http/Resources/Api/Client/ServerResource.php +++ b/app/Http/Resources/Api/Client/ServerResource.php @@ -70,6 +70,7 @@ class ServerResource extends ClientResource 'name' => $server->name, 'node' => $server->node->name, 'is_node_under_maintenance' => $server->node->isUnderMaintenance(), + 'upload_size' => $server->node->upload_size, 'sftp_details' => [ 'ip' => $server->node->fqdn, 'port' => $server->node->daemonSFTP, diff --git a/packages/sdk/types/api/generated/types.gen.d.ts b/packages/sdk/types/api/generated/types.gen.d.ts index 27492bc..271c926 100644 --- a/packages/sdk/types/api/generated/types.gen.d.ts +++ b/packages/sdk/types/api/generated/types.gen.d.ts @@ -1231,6 +1231,7 @@ export type ClientServerAttributes = { name: string; node: string; is_node_under_maintenance: boolean; + upload_size: number; sftp_details: { ip: string; port: number; diff --git a/resources/scripts/api/generated/types.gen.ts b/resources/scripts/api/generated/types.gen.ts index 2c57aa8..57df179 100644 --- a/resources/scripts/api/generated/types.gen.ts +++ b/resources/scripts/api/generated/types.gen.ts @@ -1364,6 +1364,7 @@ export type ClientServerAttributes = { name: string; node: string; is_node_under_maintenance: boolean; + upload_size: number; sftp_details: { ip: string; port: number; diff --git a/resources/scripts/components/server/files/useFileUploader.spec.tsx b/resources/scripts/components/server/files/useFileUploader.spec.tsx new file mode 100644 index 0000000..a3739be --- /dev/null +++ b/resources/scripts/components/server/files/useFileUploader.spec.tsx @@ -0,0 +1,97 @@ +/** @vitest-environment jsdom */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, cleanup, renderHook } from '@testing-library/react'; +import type { PropsWithChildren } from 'react'; +import { toast } from 'sonner'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import type * as FileQueries from '@/api/server/files/queries'; +import { serverQueryOptions } from '@/api/server/queries'; +import type * as RouterParams from '@/router/params'; +import type * as ServerState from '@/state/server'; +import { Provider } from '@/state/server'; +import { createTestServer } from '@/views/fixtures'; +import useFileUploader from './useFileUploader'; + +const upload = vi.hoisted(() => vi.fn()); + +vi.mock('@/router/params', async (importOriginal) => ({ + ...(await importOriginal()), + useServerRouteId: () => 'test-server', +})); +vi.mock('@/state/server', async (importOriginal) => ({ + ...(await importOriginal()), + useServerDirectory: () => '/', +})); +vi.mock('@/api/server/files/queries', async (importOriginal) => ({ + ...(await importOriginal()), + useUploadFiles: () => ({ mutateAsync: upload }), +})); + +const mebibyte = 1024 * 1024; +const server = createTestServer(); +let client: QueryClient; + +const file = (name: string, size: number) => new File([new Uint8Array(size)], name, { type: 'application/zip' }); + +const renderUploader = () => + renderHook(() => useFileUploader(), { + wrapper: ({ children }: PropsWithChildren) => ( + + {children} + + ), + }); + +beforeEach(() => { + client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } }); + client.setQueryData(serverQueryOptions('test-server').queryKey, { + ...server, + attributes: { ...server.attributes, upload_size: 1 }, + }); + upload.mockReset().mockResolvedValue({ uploaded: [], cancelled: [], failed: [] }); +}); + +afterEach(() => { + cleanup(); + client.clear(); + vi.restoreAllMocks(); +}); + +it('refuses a file over the node upload limit before sending anything', async () => { + const error = vi.spyOn(toast, 'error'); + const { result } = renderUploader(); + + await act(async () => { + await expect(result.current([file('small.zip', mebibyte), file('world.zip', mebibyte + 1)])).rejects.toThrow( + 'world.zip is larger than the 1 MiB upload limit.' + ); + }); + + expect(error).toHaveBeenCalledExactlyOnceWith('world.zip is larger than the 1 MiB upload limit.'); + expect(upload).not.toHaveBeenCalled(); +}); + +it('counts every file over the limit', async () => { + const error = vi.spyOn(toast, 'error'); + const { result } = renderUploader(); + + await act(async () => { + await expect( + result.current([file('a.zip', 2 * mebibyte), file('b.zip', 10), file('c.zip', 3 * mebibyte)]) + ).rejects.toThrow('2 files are larger than the 1 MiB upload limit.'); + }); + + expect(error).toHaveBeenCalledExactlyOnceWith('2 files are larger than the 1 MiB upload limit.'); + expect(upload).not.toHaveBeenCalled(); +}); + +it('uploads files up to the limit', async () => { + const error = vi.spyOn(toast, 'error'); + const files = [file('a.zip', mebibyte), file('b.zip', 10)]; + const { result } = renderUploader(); + + await act(() => result.current(files)); + + expect(error).not.toHaveBeenCalled(); + expect(upload).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ uuid: server.attributes.uuid, files })); +}); diff --git a/resources/scripts/components/server/files/useFileUploader.ts b/resources/scripts/components/server/files/useFileUploader.ts index 2323f74..74e47a8 100644 --- a/resources/scripts/components/server/files/useFileUploader.ts +++ b/resources/scripts/components/server/files/useFileUploader.ts @@ -1,16 +1,19 @@ import { useCallback, useEffect, useRef } from 'react'; import { toast } from 'sonner'; -import { useCurrentServerUuid } from '@/api/server/queries'; +import { useCurrentServer, useCurrentServerUuid, type Server } from '@/api/server/queries'; import { useUploadFiles } from '@/api/server/files/queries'; import { useFileUploadActions, useServerDirectory } from '@/state/server'; const UPLOADED_DISPLAY_MS = 500; +const selectUploadSize = (server: Server) => server.attributes.upload_size; + /** Uploads into the current directory; rejects when any file failed, after its error toast. */ export default function useFileUploader(): (files: readonly File[]) => Promise { const removals = useRef(new Map>()); const { mutateAsync } = useUploadFiles(); const uuid = useCurrentServerUuid()!; + const uploadSize = useCurrentServer(selectUploadSize)!; const directory = useServerDirectory(); const { removeFileUpload, pushFileUpload, setUploadProgress } = useFileUploadActions(); @@ -34,6 +37,18 @@ export default function useFileUploader(): (files: readonly File[]) => Promise file.size > uploadSize * 1024 * 1024); + + if (oversized.length > 0) { + const message = + oversized.length === 1 + ? `${oversized[0].name} is larger than the ${uploadSize} MiB upload limit.` + : `${oversized.length} files are larger than the ${uploadSize} MiB upload limit.`; + + toast.error(message); + throw new Error(message); + } + const { failed } = await mutateAsync({ uuid, directory, @@ -68,6 +83,6 @@ export default function useFileUploader(): (files: readonly File[]) => Promise