diff --git a/AGENTS.md b/AGENTS.md index ab6eadd97..01160bada 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -441,14 +441,16 @@ re-exports them so its own call sites are undisturbed. **A domain's wire shapes belong in `@virtool/contracts`, not in `data.ts`.** What a server function returns is read by both sides, so -`data.ts` imports those types from the package and the client feature's -`types.ts` re-exports them (`references/types.ts` is the worked example). -A client `types.ts` must never `import type ... from "@server/*"` — that -points the client at the server's emitted declarations for a shape the -server does not own, and drags a data-layer module into the browser's -type graph to get it. `data.ts` still owns what only it uses: its -`*Values` and `*Options` argument types, its `AppError` subclasses, and -its row mappers. +`data.ts` imports those types from the package and components import the +same names straight from `@virtool/contracts` — no feature `types.ts` +re-export (`references/types.ts` and `samples/types.ts` are the worked +examples; each keeps only its genuinely client-only shapes). A client +`types.ts` must never `import type ... from "@server/*"` — that points +the client at the server's emitted declarations for a shape the server +does not own, and drags a data-layer module into the browser's type +graph to get it. `data.ts` still owns what only it uses: its `*Values` +and `*Options` argument types, its `AppError` subclasses, and its row +mappers. ### Every server function declares an authorization policy @@ -456,7 +458,7 @@ Every server function names who may call it, as middleware, from `@server/auth/policy`: ```ts -export const deleteGroup = createServerFn({ method: "POST" }) +export const deleteGroupFn = createServerFn({ method: "POST" }) .middleware([adminRole("base")]) .validator(groupIdSchema) .handler(async ({ context, data }) => { ... }); @@ -632,8 +634,11 @@ The basics: string literal unions over `enum`. - **JSDoc:** Every exported `type` gets a one-line `/** ... */`. - **Naming:** `is`/`has`/`get` for pure reads; `check`/`validate`/ - `assert` for may-throw. Don't suffix exports with their layer - (`Fn`, `Core`, `Handler`, `Impl`). + `assert` for may-throw. A `createServerFn` export gets an `Fn` suffix + (`loginFn`, `getSampleFn`) — it's an RPC call, not a plain function, + and the suffix marks that at every call site. The domain function it + wraps keeps the plain name (`login`, `getSample`) and never crosses + the network. - **Comments:** Default to none. Document *why* when non-obvious, not *what*. - **Concurrency:** Independent awaits go in `Promise.all` — don't pay diff --git a/apps/web/src/account/account.ts b/apps/web/src/account/account.ts index f46bab0b6..8a25cce40 100644 --- a/apps/web/src/account/account.ts +++ b/apps/web/src/account/account.ts @@ -1,6 +1,6 @@ import { accountQueryKeys } from "@account/keys"; import type { Account } from "@account/types"; -import { getAccount } from "@server/users/functions"; +import { getAccountFn } from "@server/users/functions"; import { queryOptions, useQuery } from "@tanstack/react-query"; /** @@ -21,7 +21,7 @@ import { queryOptions, useQuery } from "@tanstack/react-query"; export function accountQueryOptions() { return queryOptions({ queryKey: accountQueryKeys.all(), - queryFn: () => getAccount(), + queryFn: () => getAccountFn(), }); } diff --git a/apps/web/src/account/components/__tests__/AccountProfile.test.tsx b/apps/web/src/account/components/__tests__/AccountProfile.test.tsx index 1ef1fd610..cc5591e41 100644 --- a/apps/web/src/account/components/__tests__/AccountProfile.test.tsx +++ b/apps/web/src/account/components/__tests__/AccountProfile.test.tsx @@ -170,7 +170,7 @@ describe("", () => { expect( await screen.findByText("Please specify a username"), ).toBeInTheDocument(); - expect(userServerFnMocks.updateAccountHandle).not.toHaveBeenCalled(); + expect(userServerFnMocks.updateAccountHandleFn).not.toHaveBeenCalled(); }); it("should handle password changes", async () => { diff --git a/apps/web/src/account/queries.ts b/apps/web/src/account/queries.ts index 585b8d8ff..f7ab09208 100644 --- a/apps/web/src/account/queries.ts +++ b/apps/web/src/account/queries.ts @@ -5,13 +5,13 @@ import { resetClient } from "@app/utils"; import type { Permissions } from "@groups/types"; import * as Sentry from "@sentry/tanstackstart-react"; import { - createApiKey, - deleteApiKey, - findApiKeys, - updateApiKey, + createApiKeyFn, + deleteApiKeyFn, + findApiKeysFn, + updateApiKeyFn, } from "@server/account/functions"; import { logoutFn } from "@server/auth/functions"; -import { updateAccountHandle } from "@server/users/functions"; +import { updateAccountHandleFn } from "@server/users/functions"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { User } from "@users/types"; import type { ErrorResponse } from "@/types/api"; @@ -50,11 +50,11 @@ export function useUpdateHandle() { const queryClient = useQueryClient(); return useMutation< - Awaited>, + Awaited>, Error, { handle: string } >({ - mutationFn: ({ handle }) => updateAccountHandle({ data: { handle } }), + mutationFn: ({ handle }) => updateAccountHandleFn({ data: { handle } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: accountQueryKeys.all() }); }, @@ -93,7 +93,7 @@ export function useChangePassword() { export function useFetchApiKeys() { return useQuery({ queryKey: accountQueryKeys.apiKeys(), - queryFn: () => findApiKeys(), + queryFn: () => findApiKeysFn(), }); } @@ -106,12 +106,12 @@ export function useCreateApiKey() { const queryClient = useQueryClient(); return useMutation< - Awaited>, + Awaited>, Error, { name: string; permissions: Permissions } >({ mutationFn: ({ name, permissions }) => - createApiKey({ data: { name, permissions } }), + createApiKeyFn({ data: { name, permissions } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: accountQueryKeys.apiKeys() }); }, @@ -127,12 +127,12 @@ export function useUpdateApiKey() { const queryClient = useQueryClient(); return useMutation< - Awaited>, + Awaited>, Error, { keyId: number; permissions: Permissions } >({ mutationFn: ({ keyId, permissions }) => - updateApiKey({ data: { keyId, permissions } }), + updateApiKeyFn({ data: { keyId, permissions } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: accountQueryKeys.apiKeys() }); }, @@ -148,7 +148,7 @@ export function useRemoveApiKey() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ keyId }) => deleteApiKey({ data: { keyId } }), + mutationFn: ({ keyId }) => deleteApiKeyFn({ data: { keyId } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: accountQueryKeys.apiKeys() }); }, diff --git a/apps/web/src/administration/components/__tests__/Banners.test.tsx b/apps/web/src/administration/components/__tests__/Banners.test.tsx index 75967227c..7104e58f6 100644 --- a/apps/web/src/administration/components/__tests__/Banners.test.tsx +++ b/apps/web/src/administration/components/__tests__/Banners.test.tsx @@ -12,13 +12,13 @@ const updateMessage = vi.fn(); const deleteMessage = vi.fn(); vi.mock("@server/messages/functions", () => ({ - findMessage: vi.fn(), - findMessages: (...args: unknown[]) => findMessages(...args), - setActiveMessage: (...args: unknown[]) => setActiveMessage(...args), - clearActiveMessage: (...args: unknown[]) => clearActiveMessage(...args), - createMessage: (...args: unknown[]) => createMessage(...args), - updateMessage: (...args: unknown[]) => updateMessage(...args), - deleteMessage: (...args: unknown[]) => deleteMessage(...args), + findMessageFn: vi.fn(), + findMessagesFn: (...args: unknown[]) => findMessages(...args), + setActiveMessageFn: (...args: unknown[]) => setActiveMessage(...args), + clearActiveMessageFn: (...args: unknown[]) => clearActiveMessage(...args), + createMessageFn: (...args: unknown[]) => createMessage(...args), + updateMessageFn: (...args: unknown[]) => updateMessage(...args), + deleteMessageFn: (...args: unknown[]) => deleteMessage(...args), })); const { default: Banners } = await import("../Banners"); diff --git a/apps/web/src/administration/queries.ts b/apps/web/src/administration/queries.ts index d4058cab1..546af0e25 100644 --- a/apps/web/src/administration/queries.ts +++ b/apps/web/src/administration/queries.ts @@ -3,8 +3,8 @@ import { roleQueryKeys, settingsQueryKeys, } from "@administration/keys"; -import { getSettings, updateSettings } from "@server/settings/functions"; -import { listAdministratorRoles } from "@server/users/functions"; +import { getSettingsFn, updateSettingsFn } from "@server/settings/functions"; +import { listAdministratorRolesFn } from "@server/users/functions"; import { queryOptions, useMutation, @@ -34,7 +34,7 @@ export type SettingsUpdate = { export function settingsQueryOptions() { return queryOptions({ queryKey: settingsQueryKeys.all(), - queryFn: () => getSettings(), + queryFn: () => getSettingsFn(), }); } @@ -67,7 +67,7 @@ export function useUpdateSettings() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (update) => updateSettings({ data: update }), + mutationFn: (update) => updateSettingsFn({ data: update }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: settingsQueryKeys.all(), @@ -87,7 +87,7 @@ export function useUpdateSettings() { export function administratorRolesQueryOptions() { return queryOptions({ queryKey: roleQueryKeys.all(), - queryFn: () => listAdministratorRoles(), + queryFn: () => listAdministratorRolesFn(), }); } diff --git a/apps/web/src/analyses/components/Create/QuickAnalyze.tsx b/apps/web/src/analyses/components/Create/QuickAnalyze.tsx index cad3f9839..0200c4b1f 100644 --- a/apps/web/src/analyses/components/Create/QuickAnalyze.tsx +++ b/apps/web/src/analyses/components/Create/QuickAnalyze.tsx @@ -1,7 +1,7 @@ import { Dialog, DialogTitle } from "@base/Dialog"; import QueryError from "@base/QueryError"; import { useListHmms } from "@hmm/queries"; -import type { SampleMinimal } from "@samples/types"; +import type { SampleMinimal } from "@virtool/contracts"; import HmmAlert from "../HmmAlert"; import CreateAnalysisDialogContent from "./CreateAnalysisDialogContent"; import CreateAnalysisForm from "./CreateAnalysisForm"; diff --git a/apps/web/src/analyses/components/Create/SelectedSamples.tsx b/apps/web/src/analyses/components/Create/SelectedSamples.tsx index 7dc32918b..3e77644c8 100644 --- a/apps/web/src/analyses/components/Create/SelectedSamples.tsx +++ b/apps/web/src/analyses/components/Create/SelectedSamples.tsx @@ -1,7 +1,7 @@ import { cn } from "@app/cn"; import Badge from "@base/Badge"; import BoxGroupSection from "@base/BoxGroupSection"; -import type { SampleMinimal } from "@samples/types"; +import type { SampleMinimal } from "@virtool/contracts"; import CreateAnalysisFieldTitle from "./CreateAnalysisFieldTitle"; type SelectedSamplesProps = { diff --git a/apps/web/src/analyses/components/Create/__tests__/CreateAnalysisForm.test.tsx b/apps/web/src/analyses/components/Create/__tests__/CreateAnalysisForm.test.tsx index 78cd00a41..1be3255dd 100644 --- a/apps/web/src/analyses/components/Create/__tests__/CreateAnalysisForm.test.tsx +++ b/apps/web/src/analyses/components/Create/__tests__/CreateAnalysisForm.test.tsx @@ -2,10 +2,10 @@ import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { mockApiCreateAnalysis } from "@tests/api/analyses"; import { mockApiListIndexes } from "@tests/api/indexes"; -import { mockApiGetSampleDetail } from "@tests/api/samples"; import { createFakeAnalysisMinimal } from "@tests/fake/analyses"; import { createFakeIndexMinimal } from "@tests/fake/indexes"; import { createFakeSample } from "@tests/fake/samples"; +import { mockGetSample } from "@tests/server-fn/samples"; import { mockListSubtractionsShortlist } from "@tests/server-fn/subtractions"; import { renderWithRouter } from "@tests/setup"; import { describe, expect, it, vi } from "vitest"; @@ -23,7 +23,7 @@ async function renderForm(indexId?: number) { mockApiListIndexes([index]); mockListSubtractionsShortlist([]); - mockApiGetSampleDetail(sample); + mockGetSample(sample); await renderWithRouter( ", () => { it("should show analysis creation when user is full admin", async () => { mockGetAccount(createFakeAccount({ administrator_role: "full" })); - mockApiGetSampleDetail(sample); + mockGetSample(sample); renderList(); expect(await screen.findByText("Create")).toBeInTheDocument(); @@ -45,7 +45,7 @@ describe("", () => { const account = createFakeAccount({ administrator_role: null }); sample.user.id = account.id; mockGetAccount(account); - mockApiGetSampleDetail(sample); + mockGetSample(sample); renderList(); expect(await screen.findByText("Create")).toBeInTheDocument(); @@ -54,9 +54,9 @@ describe("", () => { it("should show analysis creation when user is in the correct group and write is enabled", async () => { const account = createFakeAccount({ administrator_role: null }); sample.group = at(account.groups, 0); - sample.group_write = true; + sample.groupWrite = true; mockGetAccount(account); - mockApiGetSampleDetail(sample); + mockGetSample(sample); renderList(); expect(await screen.findByText("Create")).toBeInTheDocument(); @@ -64,19 +64,19 @@ describe("", () => { it("should show analysis creation when all users editing a sample is permitted", async () => { const account = createFakeAccount({ administrator_role: null }); - sample.all_write = true; + sample.allWrite = true; mockGetAccount(account); - mockApiGetSampleDetail(sample); + mockGetSample(sample); renderList(); expect(await screen.findByText("Create")).toBeInTheDocument(); }); it("should not render analysis creation option when user has no permissions", async () => { - sample.all_write = false; - sample.group_write = false; + sample.allWrite = false; + sample.groupWrite = false; mockGetAccount(createFakeAccount({ administrator_role: null })); - mockApiGetSampleDetail(sample); + mockGetSample(sample); renderList(); expect(await screen.findByText("Pathoscope")).toBeInTheDocument(); diff --git a/apps/web/src/banner/queries.ts b/apps/web/src/banner/queries.ts index b75f3228f..8f545ddad 100644 --- a/apps/web/src/banner/queries.ts +++ b/apps/web/src/banner/queries.ts @@ -1,12 +1,12 @@ import { bannerQueryKeys } from "@banner/keys"; import { - clearActiveMessage, - createMessage, - deleteMessage, - findMessage, - findMessages, - setActiveMessage, - updateMessage, + clearActiveMessageFn, + createMessageFn, + deleteMessageFn, + findMessageFn, + findMessagesFn, + setActiveMessageFn, + updateMessageFn, } from "@server/messages/functions"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Banner, BannerColor } from "./types"; @@ -17,7 +17,7 @@ import type { Banner, BannerColor } from "./types"; export function useFetchBanner() { return useQuery({ queryKey: bannerQueryKeys.active(), - queryFn: () => findMessage(), + queryFn: () => findMessageFn(), }); } @@ -27,7 +27,7 @@ export function useFetchBanner() { export function useFetchBanners() { return useQuery({ queryKey: bannerQueryKeys.lists(), - queryFn: () => findMessages(), + queryFn: () => findMessagesFn(), }); } @@ -38,7 +38,7 @@ export function useCreateBanner() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ message, color }) => - createMessage({ data: { message, color } }), + createMessageFn({ data: { message, color } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: bannerQueryKeys.all() }); }, @@ -56,7 +56,7 @@ export function useUpdateBanner() { { id: number; message?: string; color?: BannerColor } >({ mutationFn: async ({ id, message, color }) => { - const banner = await updateMessage({ data: { id, message, color } }); + const banner = await updateMessageFn({ data: { id, message, color } }); if (!banner) { throw new Error("Failed to update banner"); } @@ -75,7 +75,7 @@ export function useDeleteBanner() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ id }) => { - await deleteMessage({ data: { id } }); + await deleteMessageFn({ data: { id } }); return null; }, onSuccess: () => { @@ -92,7 +92,7 @@ export function useSetActiveBanner() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ id }) => { - const banner = await setActiveMessage({ data: { id } }); + const banner = await setActiveMessageFn({ data: { id } }); if (!banner) { throw new Error("Failed to activate banner"); } @@ -110,7 +110,7 @@ export function useSetActiveBanner() { export function useClearActiveBanner() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: () => clearActiveMessage(), + mutationFn: () => clearActiveMessageFn(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: bannerQueryKeys.all() }); }, diff --git a/apps/web/src/groups/queries.ts b/apps/web/src/groups/queries.ts index ae31d7924..cb92ee00d 100644 --- a/apps/web/src/groups/queries.ts +++ b/apps/web/src/groups/queries.ts @@ -1,11 +1,11 @@ import { groupQueryKeys } from "@groups/keys"; import { - createGroup, - deleteGroup, - findGroups, - getGroup, - listGroups, - updateGroup, + createGroupFn, + deleteGroupFn, + findGroupsFn, + getGroupFn, + listGroupsFn, + updateGroupFn, } from "@server/groups/functions"; import { keepPreviousData, @@ -33,7 +33,7 @@ export function useInfiniteFindGroups(per_page: number, term: string) { return useInfiniteQuery({ queryKey: groupQueryKeys.infiniteList([per_page, term]), queryFn: ({ pageParam }) => - findGroups({ + findGroupsFn({ data: { term, page: pageParam as number, per_page }, }) as Promise, initialPageParam: 1, @@ -55,7 +55,7 @@ export function useInfiniteFindGroups(per_page: number, term: string) { export function useListGroups() { return useQuery({ queryKey: groupQueryKeys.lists(), - queryFn: () => listGroups() as Promise, + queryFn: () => listGroupsFn() as Promise, }); } @@ -69,7 +69,7 @@ export function useFetchGroup(id: string | number) { return useQuery({ queryKey: groupQueryKeys.detail(id), queryFn: () => - getGroup({ data: { groupId: Number(id) } }) as Promise, + getGroupFn({ data: { groupId: Number(id) } }) as Promise, enabled: Boolean(id), placeholderData: keepPreviousData, }); @@ -92,7 +92,7 @@ export function useUpdateGroup() { } >({ mutationFn: ({ id, name, permissions }) => - updateGroup({ + updateGroupFn({ data: { groupId: Number(id), name, permissions }, }) as Promise, onSuccess: (data) => { @@ -111,7 +111,7 @@ export function useRemoveGroup() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ id }) => - deleteGroup({ data: { groupId: Number(id) } }) as Promise, + deleteGroupFn({ data: { groupId: Number(id) } }) as Promise, onSuccess: () => { queryClient.invalidateQueries({ queryKey: groupQueryKeys.all() }); }, @@ -126,7 +126,8 @@ export function useRemoveGroup() { export function useCreateGroup() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ name }) => createGroup({ data: { name } }) as Promise, + mutationFn: ({ name }) => + createGroupFn({ data: { name } }) as Promise, onSuccess: () => { queryClient.invalidateQueries({ queryKey: groupQueryKeys.lists() }); }, diff --git a/apps/web/src/hmm/queries.ts b/apps/web/src/hmm/queries.ts index 7e3501565..8edf20fd1 100644 --- a/apps/web/src/hmm/queries.ts +++ b/apps/web/src/hmm/queries.ts @@ -1,5 +1,5 @@ import { hmmQueryKeys } from "@hmm/keys"; -import { findHmms, getHmm, installHmm } from "@server/hmm/functions"; +import { findHmmsFn, getHmmFn, installHmmFn } from "@server/hmm/functions"; import { queryOptions, useMutation, @@ -25,7 +25,7 @@ export function hmmsQueryOptions( return queryOptions({ queryKey: hmmQueryKeys.list([page, per_page, term]), queryFn: () => - findHmms({ data: { page, perPage: per_page, term: term ?? "" } }).then( + findHmmsFn({ data: { page, perPage: per_page, term: term ?? "" } }).then( ({ documents, ...rest }) => ({ ...rest, items: documents }), ), }); @@ -68,7 +68,7 @@ export function useSuspenseHmms(page: number, per_page: number, term?: string) { export function useFetchHmm(hmmId: number) { return useQuery({ queryKey: hmmQueryKeys.detail(hmmId), - queryFn: () => getHmm({ data: { hmmId } }), + queryFn: () => getHmmFn({ data: { hmmId } }), }); } @@ -81,7 +81,7 @@ export function useInstallHmm() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: () => installHmm(), + mutationFn: () => installHmmFn(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: hmmQueryKeys.lists() }); }, diff --git a/apps/web/src/jobs/__tests__/refresh.test.ts b/apps/web/src/jobs/__tests__/refresh.test.ts index df8b59f3d..382167b5a 100644 --- a/apps/web/src/jobs/__tests__/refresh.test.ts +++ b/apps/web/src/jobs/__tests__/refresh.test.ts @@ -89,7 +89,7 @@ describe("createJobRefreshQueue", () => { await vi.advanceTimersByTimeAsync(FLUSH_MS); - expect(jobServerFnMocks.getJobs).toHaveBeenCalledExactlyOnceWith({ + expect(jobServerFnMocks.getJobsFn).toHaveBeenCalledExactlyOnceWith({ data: { jobIds: [1, 2, 3] }, }); }); @@ -126,7 +126,7 @@ describe("createJobRefreshQueue", () => { await vi.advanceTimersByTimeAsync(FLUSH_MS); - expect(jobServerFnMocks.getJobs).not.toHaveBeenCalled(); + expect(jobServerFnMocks.getJobsFn).not.toHaveBeenCalled(); }); // React Query holds a detail's data for the whole gcTime after its row @@ -141,7 +141,7 @@ describe("createJobRefreshQueue", () => { await vi.advanceTimersByTimeAsync(FLUSH_MS); - expect(jobServerFnMocks.getJobs).not.toHaveBeenCalled(); + expect(jobServerFnMocks.getJobsFn).not.toHaveBeenCalled(); }); it("marks a cached jobs list stale so its counts and ordering catch up", async () => { @@ -168,16 +168,16 @@ describe("createJobRefreshQueue", () => { await vi.advanceTimersByTimeAsync(FLUSH_MS); - expect(jobServerFnMocks.getJobs).toHaveBeenCalledTimes(2); + expect(jobServerFnMocks.getJobsFn).toHaveBeenCalledTimes(2); expect( - jobServerFnMocks.getJobs.mock.calls.map( + jobServerFnMocks.getJobsFn.mock.calls.map( ([{ data }]) => data.jobIds.length, ), ).toEqual([100, 50]); }); it("falls back to refetching each job when the batch fails", async () => { - jobServerFnMocks.getJobs.mockRejectedValue(new Error("boom")); + jobServerFnMocks.getJobsFn.mockRejectedValue(new Error("boom")); watch(1, 2); const queue = createJobRefreshQueue(queryClient); @@ -198,7 +198,7 @@ describe("createJobRefreshQueue", () => { watch(1); let resolveFirst: (jobs: ServerJob[]) => void = () => undefined; - jobServerFnMocks.getJobs + jobServerFnMocks.getJobsFn .mockImplementationOnce( () => new Promise((resolve) => { @@ -211,17 +211,17 @@ describe("createJobRefreshQueue", () => { queue(1); await vi.advanceTimersByTimeAsync(FLUSH_MS); - expect(jobServerFnMocks.getJobs).toHaveBeenCalledTimes(1); + expect(jobServerFnMocks.getJobsFn).toHaveBeenCalledTimes(1); queue(1); await vi.advanceTimersByTimeAsync(FLUSH_MS * 3); - expect(jobServerFnMocks.getJobs).toHaveBeenCalledTimes(1); + expect(jobServerFnMocks.getJobsFn).toHaveBeenCalledTimes(1); resolveFirst([createJob(1, { progress: 10 })]); await vi.advanceTimersByTimeAsync(0); - expect(jobServerFnMocks.getJobs).toHaveBeenCalledTimes(2); + expect(jobServerFnMocks.getJobsFn).toHaveBeenCalledTimes(2); expect(queryClient.getQueryData(jobQueryKeys.detail(1))).toMatchObject({ progress: 90, }); @@ -239,8 +239,8 @@ describe("createJobRefreshQueue", () => { queue(2); await vi.advanceTimersByTimeAsync(FLUSH_MS); - expect(jobServerFnMocks.getJobs).toHaveBeenCalledTimes(2); - expect(jobServerFnMocks.getJobs).toHaveBeenNthCalledWith(2, { + expect(jobServerFnMocks.getJobsFn).toHaveBeenCalledTimes(2); + expect(jobServerFnMocks.getJobsFn).toHaveBeenNthCalledWith(2, { data: { jobIds: [2] }, }); }); @@ -256,7 +256,7 @@ describe("createJobRefreshQueue", () => { queue(2); await vi.advanceTimersByTimeAsync(FLUSH_MS / 2); - expect(jobServerFnMocks.getJobs).toHaveBeenCalledExactlyOnceWith({ + expect(jobServerFnMocks.getJobsFn).toHaveBeenCalledExactlyOnceWith({ data: { jobIds: [1, 2] }, }); }); diff --git a/apps/web/src/jobs/queries.ts b/apps/web/src/jobs/queries.ts index 78bbd9f2c..7fc70cdb9 100644 --- a/apps/web/src/jobs/queries.ts +++ b/apps/web/src/jobs/queries.ts @@ -1,5 +1,5 @@ import { jobQueryKeys } from "@jobs/keys"; -import { findJobs, getJob } from "@server/jobs/functions"; +import { findJobsFn, getJobFn } from "@server/jobs/functions"; import { queryOptions, useQuery, @@ -27,7 +27,7 @@ export function jobsQueryOptions( ) { return queryOptions({ queryKey: jobQueryKeys.list([page, per_page, ...states]), - queryFn: () => findJobs({ data: { page, perPage: per_page, states } }), + queryFn: () => findJobsFn({ data: { page, perPage: per_page, states } }), select: JobSearchResultSchema.parse, }); } @@ -82,7 +82,7 @@ function getJobSeed(job: ServerJobNested): ServerJob { export function useFetchJob(jobId: number, seed?: ServerJobNested) { return useQuery({ queryKey: jobQueryKeys.detail(jobId), - queryFn: () => getJob({ data: { jobId } }), + queryFn: () => getJobFn({ data: { jobId } }), select: JobSchema.parse, enabled: Number.isInteger(jobId), initialData: seed ? getJobSeed(seed) : undefined, diff --git a/apps/web/src/jobs/refresh.ts b/apps/web/src/jobs/refresh.ts index b86cb6bec..5b634d4b8 100644 --- a/apps/web/src/jobs/refresh.ts +++ b/apps/web/src/jobs/refresh.ts @@ -1,6 +1,6 @@ import { jobQueryKeys } from "@jobs/keys"; import * as Sentry from "@sentry/tanstackstart-react"; -import { getJobs } from "@server/jobs/functions"; +import { getJobsFn } from "@server/jobs/functions"; import type { QueryClient } from "@tanstack/react-query"; /** @@ -13,7 +13,7 @@ import type { QueryClient } from "@tanstack/react-query"; */ const FLUSH_MS = 500; -/** Ids per request, matching the cap `getJobs` validates. */ +/** Ids per request, matching the cap `getJobsFn` validates. */ const BATCH_SIZE = 100; function chunk(ids: number[], size: number): number[][] { @@ -31,7 +31,7 @@ function chunk(ids: number[], size: number): number[][] { * * Every on-screen job mounts its own `detail(id)` query, so invalidating each * frame's detail fires one request per running job per progress wave. The queue - * collects the ids instead and reads them in a single `getJobs` call, writing + * collects the ids instead and reads them in a single `getJobsFn` call, writing * each result straight into its detail cache. * * The queue holds its own buffer, so callers get one per `QueryClient`. @@ -45,7 +45,7 @@ export function createJobRefreshQueue( async function refreshBatch(jobIds: number[]): Promise { try { - const jobs = await getJobs({ data: { jobIds } }); + const jobs = await getJobsFn({ data: { jobIds } }); for (const job of jobs) { queryClient.setQueryData(jobQueryKeys.detail(job.id), job); diff --git a/apps/web/src/labels/queries.ts b/apps/web/src/labels/queries.ts index 7cd1d6108..4ee58151c 100644 --- a/apps/web/src/labels/queries.ts +++ b/apps/web/src/labels/queries.ts @@ -1,9 +1,9 @@ import { labelQueryKeys } from "@labels/keys"; import { - createLabel, - deleteLabel, - findLabels, - updateLabel, + createLabelFn, + deleteLabelFn, + findLabelsFn, + updateLabelFn, } from "@server/labels/functions"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Label } from "./types"; @@ -16,7 +16,7 @@ import type { Label } from "./types"; export function useFetchLabels() { return useQuery({ queryKey: labelQueryKeys.lists(), - queryFn: () => findLabels(), + queryFn: () => findLabelsFn(), }); } @@ -34,7 +34,7 @@ export function useCreateLabel() { { name: string; description: string; color: string } >({ mutationFn: ({ name, description, color }) => - createLabel({ data: { color, description, name } }), + createLabelFn({ data: { color, description, name } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: labelQueryKeys.lists() }); }, @@ -55,7 +55,7 @@ export function useUpdateLabel() { { labelId: number; name: string; description: string; color: string } >({ mutationFn: ({ labelId, name, description, color }) => - updateLabel({ data: { color, description, labelId, name } }), + updateLabelFn({ data: { color, description, labelId, name } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: labelQueryKeys.lists() }); }, @@ -71,7 +71,7 @@ export function useRemoveLabel() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ labelId }) => deleteLabel({ data: { labelId } }), + mutationFn: ({ labelId }) => deleteLabelFn({ data: { labelId } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: labelQueryKeys.lists() }); }, diff --git a/apps/web/src/nav/queries.ts b/apps/web/src/nav/queries.ts index 100f77c29..ecd4c8244 100644 --- a/apps/web/src/nav/queries.ts +++ b/apps/web/src/nav/queries.ts @@ -1,11 +1,11 @@ -import { getRoot } from "@server/root/functions"; +import { getRootFn } from "@server/root/functions"; import { queryOptions, useQuery } from "@tanstack/react-query"; import { rootQueryKeys } from "@wall/keys"; /** * Query options for the instance root document. * - * Backed by the `getRoot` server function, so this module imports no + * Backed by the `getRootFn` server function, so this module imports no * `@app/api`. The `_authenticated` guard reads it before any session exists to * decide first-user setup, and the whole module must stay HTTP-client-free to * keep superagent off the login wall. @@ -13,7 +13,7 @@ import { rootQueryKeys } from "@wall/keys"; export function rootQueryOptions() { return queryOptions({ queryKey: rootQueryKeys.all(), - queryFn: () => getRoot(), + queryFn: () => getRootFn(), }); } diff --git a/apps/web/src/otus/components/CurrentOtuContext.tsx b/apps/web/src/otus/components/CurrentOtuContext.tsx index 65ee9203d..e06933668 100644 --- a/apps/web/src/otus/components/CurrentOtuContext.tsx +++ b/apps/web/src/otus/components/CurrentOtuContext.tsx @@ -3,7 +3,7 @@ import QueryError from "@base/QueryError"; import { useFetchOtu } from "@otus/queries"; import type { Otu } from "@otus/types"; import { useFetchReference } from "@references/queries"; -import type { Reference } from "@references/types"; +import type { Reference } from "@virtool/contracts"; import { createContext, type ReactNode, useContext } from "react"; type CurrentOtuContextValue = { diff --git a/apps/web/src/references/components/CloneReference.tsx b/apps/web/src/references/components/CloneReference.tsx index e92b445ce..59eee7931 100644 --- a/apps/web/src/references/components/CloneReference.tsx +++ b/apps/web/src/references/components/CloneReference.tsx @@ -8,7 +8,7 @@ import InputLabel from "@base/InputLabel"; import InputSimple from "@base/InputSimple"; import SaveButton from "@base/SaveButton"; import { useCloneReference } from "@references/queries"; -import type { ReferenceMinimal } from "@references/types"; +import type { ReferenceMinimal } from "@virtool/contracts"; import { useForm } from "react-hook-form"; type FormValues = { diff --git a/apps/web/src/references/components/Detail/AddReferenceGroup.tsx b/apps/web/src/references/components/Detail/AddReferenceGroup.tsx index 4e126201c..406e57689 100644 --- a/apps/web/src/references/components/Detail/AddReferenceGroup.tsx +++ b/apps/web/src/references/components/Detail/AddReferenceGroup.tsx @@ -10,7 +10,7 @@ import SearchToolbar from "@base/SearchToolbar"; import { useInfiniteFindGroups } from "@groups/queries"; import type { GroupMinimal } from "@groups/types"; import { useAddReferenceMember } from "@references/queries"; -import type { ReferenceGroup } from "@references/types"; +import type { ReferenceGroup } from "@virtool/contracts"; import { Users } from "lucide-react"; import { useState } from "react"; diff --git a/apps/web/src/references/components/Detail/AddReferenceUser.tsx b/apps/web/src/references/components/Detail/AddReferenceUser.tsx index 200223eac..3215984fc 100644 --- a/apps/web/src/references/components/Detail/AddReferenceUser.tsx +++ b/apps/web/src/references/components/Detail/AddReferenceUser.tsx @@ -8,9 +8,9 @@ import InitialIcon from "@base/InitialIcon"; import QueryError from "@base/QueryError"; import SearchToolbar from "@base/SearchToolbar"; import { useAddReferenceMember } from "@references/queries"; -import type { ReferenceUser } from "@references/types"; import { useInfiniteFindUsers } from "@users/queries"; import type { User } from "@users/types"; +import type { ReferenceUser } from "@virtool/contracts"; import { Users } from "lucide-react"; import { useState } from "react"; diff --git a/apps/web/src/references/components/Detail/ArchiveReference.tsx b/apps/web/src/references/components/Detail/ArchiveReference.tsx index b30aea8ee..543787854 100644 --- a/apps/web/src/references/components/Detail/ArchiveReference.tsx +++ b/apps/web/src/references/components/Detail/ArchiveReference.tsx @@ -11,7 +11,7 @@ import { useArchiveReference, useUnarchiveReference, } from "@references/queries"; -import type { Reference } from "@references/types"; +import type { Reference } from "@virtool/contracts"; import { AlertCircle, Archive, ArchiveRestore } from "lucide-react"; import { useState } from "react"; diff --git a/apps/web/src/references/components/Detail/EditMember.tsx b/apps/web/src/references/components/Detail/EditMember.tsx index 9b3ef0c0b..a2e7a0b7f 100644 --- a/apps/web/src/references/components/Detail/EditMember.tsx +++ b/apps/web/src/references/components/Detail/EditMember.tsx @@ -4,12 +4,12 @@ import { type ReferenceMemberNoun, useUpdateReferenceMember, } from "@references/queries"; +import { useQueryClient } from "@tanstack/react-query"; import type { ReferenceGroup, ReferenceRights, ReferenceUser, -} from "@references/types"; -import { useQueryClient } from "@tanstack/react-query"; +} from "@virtool/contracts"; import { ReferenceRight } from "./ReferenceRight"; const rights: (keyof ReferenceRights)[] = ["modifyOtu", "build", "modify"]; diff --git a/apps/web/src/references/components/Detail/EditReference.tsx b/apps/web/src/references/components/Detail/EditReference.tsx index b86d9443c..959021724 100644 --- a/apps/web/src/references/components/Detail/EditReference.tsx +++ b/apps/web/src/references/components/Detail/EditReference.tsx @@ -2,7 +2,7 @@ import { Dialog, DialogContent, DialogFooter, DialogTitle } from "@base/Dialog"; import IconButton from "@base/IconButton"; import SaveButton from "@base/SaveButton"; import { useUpdateReference } from "@references/queries"; -import type { Reference } from "@references/types"; +import type { Reference } from "@virtool/contracts"; import { Pencil } from "lucide-react"; import { useState } from "react"; import { useForm } from "react-hook-form"; diff --git a/apps/web/src/references/components/Detail/LatestBuild.tsx b/apps/web/src/references/components/Detail/LatestBuild.tsx index f98d8c099..f9f81ef7e 100644 --- a/apps/web/src/references/components/Detail/LatestBuild.tsx +++ b/apps/web/src/references/components/Detail/LatestBuild.tsx @@ -2,7 +2,7 @@ import BoxGroupSection from "@base/BoxGroupSection"; import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "@base/Empty"; import Link from "@base/Link"; import RelativeTime from "@base/RelativeTime"; -import type { ReferenceBuild } from "@references/types"; +import type { ReferenceBuild } from "@virtool/contracts"; import { Boxes } from "lucide-react"; type LatestBuildProps = { diff --git a/apps/web/src/references/components/Detail/ReferenceDetailHeader.tsx b/apps/web/src/references/components/Detail/ReferenceDetailHeader.tsx index caf48e9e1..18913e43f 100644 --- a/apps/web/src/references/components/Detail/ReferenceDetailHeader.tsx +++ b/apps/web/src/references/components/Detail/ReferenceDetailHeader.tsx @@ -4,8 +4,8 @@ import ViewHeaderAttribution from "@base/ViewHeaderAttribution"; import ViewHeaderIcons from "@base/ViewHeaderIcons"; import ViewHeaderTitle from "@base/ViewHeaderTitle"; import { useCheckReferenceRight } from "@references/hooks"; -import type { Reference } from "@references/types"; import { useLocation } from "@tanstack/react-router"; +import type { Reference } from "@virtool/contracts"; import ArchiveReference from "./ArchiveReference"; import EditReference from "./EditReference"; diff --git a/apps/web/src/references/components/Detail/ReferenceMembers.tsx b/apps/web/src/references/components/Detail/ReferenceMembers.tsx index c62741472..289a28514 100644 --- a/apps/web/src/references/components/Detail/ReferenceMembers.tsx +++ b/apps/web/src/references/components/Detail/ReferenceMembers.tsx @@ -8,7 +8,7 @@ import { type ReferenceMemberNoun, useRemoveReferenceUser, } from "@references/queries"; -import type { ReferenceGroup, ReferenceUser } from "@references/types"; +import type { ReferenceGroup, ReferenceUser } from "@virtool/contracts"; import { Users } from "lucide-react"; import AddReferenceGroup from "./AddReferenceGroup"; import AddReferenceUser from "./AddReferenceUser"; diff --git a/apps/web/src/references/components/Detail/ReferenceRight.tsx b/apps/web/src/references/components/Detail/ReferenceRight.tsx index 6abcce04e..c3b30a6e1 100644 --- a/apps/web/src/references/components/Detail/ReferenceRight.tsx +++ b/apps/web/src/references/components/Detail/ReferenceRight.tsx @@ -1,5 +1,5 @@ import Checkbox from "@base/Checkbox"; -import type { ReferenceRights } from "@references/types"; +import type { ReferenceRights } from "@virtool/contracts"; const descriptions: Record = { build: "Can build new indexes for the reference.", diff --git a/apps/web/src/references/components/Detail/__tests__/ArchiveReference.test.tsx b/apps/web/src/references/components/Detail/__tests__/ArchiveReference.test.tsx index 5c98729d6..43fed3a38 100644 --- a/apps/web/src/references/components/Detail/__tests__/ArchiveReference.test.tsx +++ b/apps/web/src/references/components/Detail/__tests__/ArchiveReference.test.tsx @@ -32,7 +32,7 @@ describe("", () => { expect(getDialogTitle("Archive", detail.name)).toBeNull(); }); - expect(referenceServerFnMocks.archiveReference).toHaveBeenCalledWith({ + expect(referenceServerFnMocks.archiveReferenceFn).toHaveBeenCalledWith({ data: { referenceId: detail.id }, }); }); @@ -52,14 +52,14 @@ describe("", () => { expect(getDialogTitle("Unarchive", detail.name)).toBeNull(); }); - expect(referenceServerFnMocks.unarchiveReference).toHaveBeenCalledWith({ + expect(referenceServerFnMocks.unarchiveReferenceFn).toHaveBeenCalledWith({ data: { referenceId: detail.id }, }); }); it("should surface the server error message when the mutation fails", async () => { const detail = createFakeReference({ archived: false }); - referenceServerFnMocks.archiveReference.mockRejectedValue( + referenceServerFnMocks.archiveReferenceFn.mockRejectedValue( new Error("Reference not found."), ); @@ -74,7 +74,7 @@ describe("", () => { it("should fall back to a generic message when the error has none", async () => { const detail = createFakeReference({ archived: false }); - referenceServerFnMocks.archiveReference.mockRejectedValue(new Error("")); + referenceServerFnMocks.archiveReferenceFn.mockRejectedValue(new Error("")); await renderWithRouter(); @@ -98,6 +98,6 @@ describe("", () => { await waitFor(() => { expect(getDialogTitle("Archive", detail.name)).toBeNull(); }); - expect(referenceServerFnMocks.archiveReference).not.toHaveBeenCalled(); + expect(referenceServerFnMocks.archiveReferenceFn).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/references/components/ReferenceItem.tsx b/apps/web/src/references/components/ReferenceItem.tsx index 0401412ad..cb5497e02 100644 --- a/apps/web/src/references/components/ReferenceItem.tsx +++ b/apps/web/src/references/components/ReferenceItem.tsx @@ -5,8 +5,8 @@ import BoxGroupSection from "@base/BoxGroupSection"; import IconButton from "@base/IconButton"; import Link from "@base/Link"; import ProgressCircle from "@base/ProgressCircle"; -import type { ReferenceMinimal } from "@references/types"; import { useFetchTask } from "@tasks/queries"; +import type { ReferenceMinimal } from "@virtool/contracts"; import { Copy } from "lucide-react"; import type { ReactNode } from "react"; diff --git a/apps/web/src/references/components/__tests__/ReferenceList.test.tsx b/apps/web/src/references/components/__tests__/ReferenceList.test.tsx index 737c575f7..70a3e09a7 100644 --- a/apps/web/src/references/components/__tests__/ReferenceList.test.tsx +++ b/apps/web/src/references/components/__tests__/ReferenceList.test.tsx @@ -108,7 +108,7 @@ describe("", () => { }); it("should refetch with archived=true when the Archived toggle is clicked", async () => { - referenceServerFnMocks.findReferences.mockImplementation( + referenceServerFnMocks.findReferencesFn.mockImplementation( async ({ data }: { data: { archived?: boolean } }) => data.archived ? { diff --git a/apps/web/src/references/hooks.ts b/apps/web/src/references/hooks.ts index 35bebcb46..7fd0848ae 100644 --- a/apps/web/src/references/hooks.ts +++ b/apps/web/src/references/hooks.ts @@ -1,5 +1,5 @@ import { useFetchAccount } from "@account/account"; -import type { ReferenceRight } from "@references/types"; +import type { ReferenceRight } from "@virtool/contracts"; import { useFetchReference } from "./queries"; /** diff --git a/apps/web/src/references/queries.ts b/apps/web/src/references/queries.ts index d65bf5b1f..13d752a7d 100644 --- a/apps/web/src/references/queries.ts +++ b/apps/web/src/references/queries.ts @@ -2,20 +2,20 @@ import { settingsQueryKeys } from "@administration/keys"; import type { Settings } from "@administration/types"; import { referenceQueryKeys } from "@references/keys"; import { - addReferenceGroup, - addReferenceUser, - archiveReference, - createReference, - findReferences, - getReference, - removeReferenceGroup, - removeReferenceUser, - unarchiveReference, - updateReference, - updateReferenceGroup, - updateReferenceUser, + addReferenceGroupFn, + addReferenceUserFn, + archiveReferenceFn, + createReferenceFn, + findReferencesFn, + getReferenceFn, + removeReferenceGroupFn, + removeReferenceUserFn, + unarchiveReferenceFn, + updateReferenceFn, + updateReferenceGroupFn, + updateReferenceUserFn, } from "@server/references/functions"; -import { updateSettings } from "@server/settings/functions"; +import { updateSettingsFn } from "@server/settings/functions"; import { queryOptions, useMutation, @@ -24,8 +24,6 @@ import { useSuspenseQuery, } from "@tanstack/react-query"; import { postUpload } from "@uploads/uploader"; -import { useState } from "react"; -import type { ErrorResponse } from "@/types/api"; import type { Reference, ReferenceGroup, @@ -33,7 +31,9 @@ import type { ReferenceSearchResult, ReferenceUpdateRequest, ReferenceUser, -} from "./types"; +} from "@virtool/contracts"; +import { useState } from "react"; +import type { ErrorResponse } from "@/types/api"; /** A reference member is either a user or a group. */ export type ReferenceMemberNoun = "user" | "group"; @@ -55,7 +55,7 @@ export function referencesQueryOptions( return queryOptions({ queryKey: referenceQueryKeys.list([page, per_page, term, archived]), queryFn: () => - findReferences({ + findReferencesFn({ data: { page, per_page, term, archived }, }) as Promise, }); @@ -93,7 +93,7 @@ export function useCloneReference() { { name: string; description: string; refId: number } >({ mutationFn: ({ name, description, refId }) => - createReference({ + createReferenceFn({ data: { name, description, cloneFrom: refId }, }) as Promise, }); @@ -111,7 +111,7 @@ export function useImportReference() { { name: string; description: string; importFrom: number } >({ mutationFn: ({ name, description, importFrom }) => - createReference({ + createReferenceFn({ data: { name, description, importFrom }, }) as Promise, }); @@ -157,7 +157,7 @@ export function useCreateReference() { { name: string; description: string; organism: string } >({ mutationFn: ({ name, description, organism }) => - createReference({ + createReferenceFn({ data: { name, description, organism }, }) as Promise, }); @@ -173,7 +173,7 @@ export function useUpdateReference(refId: number, onSuccess?: () => void) { const mutation = useMutation({ mutationFn: (data) => - updateReference({ + updateReferenceFn({ data: { referenceId: refId, ...data }, }) as Promise, onSuccess: () => { @@ -199,7 +199,7 @@ export function useUpdateReferenceSourceTypes(refId: number) { return useMutation({ mutationFn: (sourceTypes) => - updateReference({ + updateReferenceFn({ data: { referenceId: refId, sourceTypes }, }) as Promise, onSuccess: () => { @@ -220,7 +220,7 @@ export function useUpdateDefaultSourceTypes() { return useMutation({ mutationFn: (sourceTypes) => - updateSettings({ data: { defaultSourceTypes: sourceTypes } }), + updateSettingsFn({ data: { defaultSourceTypes: sourceTypes } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: settingsQueryKeys.all() }); }, @@ -243,10 +243,10 @@ export function useAddReferenceMember( return useMutation({ mutationFn: ({ id }) => noun === "user" - ? (addReferenceUser({ + ? (addReferenceUserFn({ data: { referenceId: refId, userId: id }, }) as Promise) - : (addReferenceGroup({ + : (addReferenceGroupFn({ data: { referenceId: refId, groupId: id }, }) as Promise), onSuccess: () => { @@ -271,10 +271,10 @@ export function useUpdateReferenceMember(noun: ReferenceMemberNoun) { >({ mutationFn: ({ refId, id, update }) => noun === "user" - ? (updateReferenceUser({ + ? (updateReferenceUserFn({ data: { referenceId: refId, userId: id, ...update }, }) as Promise) - : (updateReferenceGroup({ + : (updateReferenceGroupFn({ data: { referenceId: refId, groupId: id, ...update }, }) as Promise), }); @@ -296,10 +296,10 @@ export function useRemoveReferenceUser( return useMutation({ mutationFn: ({ id }) => noun === "user" - ? (removeReferenceUser({ + ? (removeReferenceUserFn({ data: { referenceId: refId, userId: id }, }) as Promise) - : (removeReferenceGroup({ + : (removeReferenceGroupFn({ data: { referenceId: refId, groupId: id }, }) as Promise), onSuccess: () => { @@ -314,7 +314,7 @@ export function referenceQueryOptions(refId: number) { return queryOptions({ queryKey: referenceQueryKeys.detail(refId), queryFn: () => - getReference({ data: { referenceId: refId } }) as Promise, + getReferenceFn({ data: { referenceId: refId } }) as Promise, }); } @@ -352,7 +352,7 @@ export function useArchiveReference(refId: number) { return useMutation({ mutationFn: () => - archiveReference({ + archiveReferenceFn({ data: { referenceId: refId }, }) as Promise, onSuccess: () => { @@ -377,7 +377,7 @@ export function useUnarchiveReference(refId: number) { return useMutation({ mutationFn: () => - unarchiveReference({ + unarchiveReferenceFn({ data: { referenceId: refId }, }) as Promise, onSuccess: () => { diff --git a/apps/web/src/references/types.ts b/apps/web/src/references/types.ts index 549704854..eaf03d959 100644 --- a/apps/web/src/references/types.ts +++ b/apps/web/src/references/types.ts @@ -1,21 +1,3 @@ -/** - * The reference wire shapes live in `@virtool/contracts`, where the server data - * layer that produces them and the components that render them can both reach - * them. They are re-exported here so the feature's call sites import from one - * place. - */ -export type { - Reference, - ReferenceBuild, - ReferenceGroup, - ReferenceMinimal, - ReferenceRight, - ReferenceRights, - ReferenceSearchResult, - ReferenceUpdateRequest, - ReferenceUser, -} from "@virtool/contracts"; - /** * A reference reduced to the fields embedded in other resources (OTUs, indexes, * analyses, jobs). These still come from the Python API, so this shape keeps its diff --git a/apps/web/src/routes/_authenticated/samples/$sampleId.tsx b/apps/web/src/routes/_authenticated/samples/$sampleId.tsx index 3b74456ce..f908559d2 100644 --- a/apps/web/src/routes/_authenticated/samples/$sampleId.tsx +++ b/apps/web/src/routes/_authenticated/samples/$sampleId.tsx @@ -12,6 +12,7 @@ import DeleteSample from "@samples/components/Detail/DeleteSample"; import EditSample from "@samples/components/EditSample"; import { useCheckCanEditSample } from "@samples/hooks"; import { useSuspenseSample } from "@samples/queries"; +import { toServerJobNested } from "@samples/utils"; import { createFileRoute, notFound, @@ -54,8 +55,8 @@ function SampleDetailLayout() { const { hasPermission: canModify } = useCheckCanEditSample(numericSampleId); const [editOpen, setEditOpen] = useState(false); - const { created_at, name, user } = data; - const job = data.job && JobNestedSchema.parse(data.job); + const { createdAt, name, user } = data; + const job = data.job && JobNestedSchema.parse(toServerJobNested(data.job)); return ( <> @@ -81,7 +82,7 @@ function SampleDetailLayout() { )} - + diff --git a/apps/web/src/samples/__tests__/queries.test.tsx b/apps/web/src/samples/__tests__/queries.test.tsx index 0ba8d5057..31471bcfe 100644 --- a/apps/web/src/samples/__tests__/queries.test.tsx +++ b/apps/web/src/samples/__tests__/queries.test.tsx @@ -1,30 +1,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderHook, waitFor } from "@testing-library/react"; -import { mockApiCreateSample } from "@tests/api/samples"; +import { mockCreateSample } from "@tests/server-fn/samples"; import { fileQueryKeys } from "@uploads/keys"; -import nock from "nock"; import type { ReactNode } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { useCreateSample } from "../queries"; describe("useCreateSample()", () => { - afterEach(() => nock.cleanAll()); - it("invalidates the reads selector on success so reserved files leave it", async () => { const queryClient = new QueryClient(); const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); - const scope = mockApiCreateSample( - "Sample A", - "", - "", - "", - "normal", - [1], - [], - [], - null, - ); + mockCreateSample(); function wrapper({ children }: { children: ReactNode }) { return ( @@ -53,7 +40,5 @@ describe("useCreateSample()", () => { expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: [...fileQueryKeys.infiniteLists(), "reads"], }); - - scope.done(); }); }); diff --git a/apps/web/src/samples/components/Create/CreateSample.tsx b/apps/web/src/samples/components/Create/CreateSample.tsx index 6731dd225..5c26e952b 100644 --- a/apps/web/src/samples/components/Create/CreateSample.tsx +++ b/apps/web/src/samples/components/Create/CreateSample.tsx @@ -29,10 +29,10 @@ import ViewHeaderTitle from "@base/ViewHeaderTitle"; import { useListGroups } from "@groups/queries"; import type { Label } from "@labels/types"; import { useCreateSample } from "@samples/queries"; -import type { Sample } from "@samples/types"; import { getCreateSampleRequest, getSampleNameFromReads } from "@samples/utils"; import { useNavigate } from "@tanstack/react-router"; import { useInfiniteFindFiles } from "@uploads/queries"; +import type { Sample } from "@virtool/contracts"; import { WandSparkles } from "lucide-react"; import { useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; @@ -174,7 +174,7 @@ export default function CreateSample({ labels }: CreateSampleProps) { Create Sample - {mutation.isError && mutation.error.response?.body.message} + {mutation.isError && mutation.error.message} diff --git a/apps/web/src/samples/components/Create/CreateSampleFromFile.tsx b/apps/web/src/samples/components/Create/CreateSampleFromFile.tsx index 769e593df..b753e087d 100644 --- a/apps/web/src/samples/components/Create/CreateSampleFromFile.tsx +++ b/apps/web/src/samples/components/Create/CreateSampleFromFile.tsx @@ -145,7 +145,7 @@ function CreateSampleFromFileForm({ Create Sample - {mutation.isError && mutation.error.response?.body.message} + {mutation.isError && mutation.error.message} {(isErrorGroups && !groups) || (isErrorAccount && !account) ? ( diff --git a/apps/web/src/samples/components/Create/__tests__/CreateSample.test.tsx b/apps/web/src/samples/components/Create/__tests__/CreateSample.test.tsx index 5c9bfb67d..25be5ed73 100644 --- a/apps/web/src/samples/components/Create/__tests__/CreateSample.test.tsx +++ b/apps/web/src/samples/components/Create/__tests__/CreateSample.test.tsx @@ -1,11 +1,11 @@ -import { screen, within } from "@testing-library/react"; +import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { mockApiCreateSample } from "@tests/api/samples"; import { createFakeAccount } from "@tests/fake/account"; import { createFakeFile } from "@tests/fake/files"; import { createFakeLabel } from "@tests/fake/labels"; import { createFakeShortlistSubtraction } from "@tests/fake/subtractions"; import { mockListGroups } from "@tests/server-fn/groups"; +import { mockCreateSample } from "@tests/server-fn/samples"; import { mockListSubtractionsShortlist } from "@tests/server-fn/subtractions"; import { mockFindUploads, uploadServerFnMocks } from "@tests/server-fn/uploads"; import { mockGetAccount } from "@tests/server-fn/users"; @@ -69,7 +69,7 @@ describe("", () => { }); it("should show an error when the read files fail to load", async () => { - uploadServerFnMocks.findUploads.mockRejectedValue(new Error("failed")); + uploadServerFnMocks.findUploadsFn.mockRejectedValue(new Error("failed")); await renderWithRouter(); expect( @@ -107,17 +107,7 @@ describe("", () => { mockFindUploads([file]); mockListSubtractionsShortlist([]); - const scope = mockApiCreateSample( - "Sample A", - "", - "", - "", - "normal", - [file.id], - [], - [], - null, - ); + const createSample = mockCreateSample(); await renderPage(); @@ -130,7 +120,18 @@ describe("", () => { await submitForm(); - scope.done(); + await waitFor(() => + expect(createSample).toHaveBeenCalledWith({ + data: expect.objectContaining({ + name: "Sample A", + libraryType: "normal", + files: [file.id], + labels: [], + subtractions: [], + group: null, + }), + }), + ); }); it("should submit when all form fields complete", async () => { @@ -141,17 +142,7 @@ describe("", () => { mockFindUploads(files); mockListSubtractionsShortlist([subtractionShortlist]); - const scope = mockApiCreateSample( - "Sample T", - "Clone AB", - "Apple", - "Earth", - "normal", - [firstFile.id, secondFile.id], - [firstLabel.id], - [subtractionShortlist.id], - null, - ); + const createSample = mockCreateSample(); await renderPage(); @@ -194,7 +185,21 @@ describe("", () => { // Submit. await submitForm(); - scope.done(); + await waitFor(() => + expect(createSample).toHaveBeenCalledWith({ + data: expect.objectContaining({ + name: "Sample T", + isolate: "Clone AB", + host: "Apple", + locale: "Earth", + libraryType: "normal", + files: [firstFile.id, secondFile.id], + labels: [firstLabel.id], + subtractions: [subtractionShortlist.id], + group: null, + }), + }), + ); }); it("should show and hide the metadata fields", async () => { @@ -349,17 +354,7 @@ describe("", () => { mockFindUploads([file]); mockListSubtractionsShortlist([]); - const scope = mockApiCreateSample( - "Sample A", - "", - "", - "", - "normal", - [file.id], - [], - [], - null, - ); + mockCreateSample(); await renderPage(); @@ -372,8 +367,6 @@ describe("", () => { expect(await screen.findByText("Sample created")).toBeInTheDocument(); expect(screen.getByLabelText("Name")).toHaveValue(""); - - scope.done(); }); it("should be able to swap read orientation", async () => { diff --git a/apps/web/src/samples/components/Create/__tests__/CreateSampleFromFile.test.tsx b/apps/web/src/samples/components/Create/__tests__/CreateSampleFromFile.test.tsx index 50ea302fe..3cd34f336 100644 --- a/apps/web/src/samples/components/Create/__tests__/CreateSampleFromFile.test.tsx +++ b/apps/web/src/samples/components/Create/__tests__/CreateSampleFromFile.test.tsx @@ -1,11 +1,11 @@ -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { mockApiCreateSample } from "@tests/api/samples"; import { createFakeAccount } from "@tests/fake/account"; import { createFakeFile } from "@tests/fake/files"; import { createFakeLabel } from "@tests/fake/labels"; import { createFakeShortlistSubtraction } from "@tests/fake/subtractions"; import { mockListGroups } from "@tests/server-fn/groups"; +import { mockCreateSample } from "@tests/server-fn/samples"; import { mockListSubtractionsShortlist } from "@tests/server-fn/subtractions"; import { mockGetAccount } from "@tests/server-fn/users"; import { renderWithRouter } from "@tests/setup"; @@ -80,17 +80,7 @@ describe("", () => { const left = createFakeFile({ name: "sample_one_R1.fastq.gz" }); const right = createFakeFile({ name: "sample_one_R2.fastq.gz" }); - const scope = mockApiCreateSample( - "sample_one", - "", - "", - "", - "normal", - [left.id, right.id], - [], - [], - null, - ); + const createSample = mockCreateSample(); await renderDialog(right, [left, right]); @@ -103,23 +93,20 @@ describe("", () => { await submitForm(); - scope.done(); + await waitFor(() => + expect(createSample).toHaveBeenCalledWith({ + data: expect.objectContaining({ + name: "sample_one", + files: [left.id, right.id], + }), + }), + ); }); it("should submit with labels and subtractions", async () => { const file = createFakeFile({ name: "sample_one.fastq.gz" }); - const scope = mockApiCreateSample( - "Sample A", - "Clone AB", - "Apple", - "Earth", - "normal", - [file.id], - [label.id], - [subtractionShortlist.id], - null, - ); + const createSample = mockCreateSample(); await renderDialog(file, [file]); @@ -148,6 +135,18 @@ describe("", () => { await submitForm(); - scope.done(); + await waitFor(() => + expect(createSample).toHaveBeenCalledWith({ + data: expect.objectContaining({ + name: "Sample A", + isolate: "Clone AB", + host: "Apple", + locale: "Earth", + files: [file.id], + labels: [label.id], + subtractions: [subtractionShortlist.id], + }), + }), + ); }); }); diff --git a/apps/web/src/samples/components/Detail/SampleDetailGeneral.tsx b/apps/web/src/samples/components/Detail/SampleDetailGeneral.tsx index 1ab206d10..368979747 100644 --- a/apps/web/src/samples/components/Detail/SampleDetailGeneral.tsx +++ b/apps/web/src/samples/components/Detail/SampleDetailGeneral.tsx @@ -8,7 +8,7 @@ import JobItem from "@jobs/components/JobItem"; import { useFetchJob } from "@jobs/queries"; import type { Label } from "@labels/types"; import { useSuspenseSample } from "@samples/queries"; -import { getLibraryTypeDisplayName } from "@samples/utils"; +import { getLibraryTypeDisplayName, toServerJobNested } from "@samples/utils"; /** * The general view in sample details */ @@ -34,7 +34,10 @@ export default function SampleDetailGeneral({ }: SampleDetailGeneralProps) { const sampleId = Number(routeApi.useParams().sampleId); const { data } = useSuspenseSample(sampleId); - const { data: job } = useFetchJob(data.job?.id ?? Number.NaN, data.job); + const { data: job } = useFetchJob( + data.job?.id ?? Number.NaN, + data.job && toServerJobNested(data.job), + ); const { quality } = data; @@ -97,7 +100,7 @@ export default function SampleDetailGeneral({ Library Type - {getLibraryTypeDisplayName(data.library_type)} + {getLibraryTypeDisplayName(data.libraryType)} Length Range diff --git a/apps/web/src/samples/components/Detail/SampleFileSizeWarning.tsx b/apps/web/src/samples/components/Detail/SampleFileSizeWarning.tsx index 19294ce91..a16821b35 100644 --- a/apps/web/src/samples/components/Detail/SampleFileSizeWarning.tsx +++ b/apps/web/src/samples/components/Detail/SampleFileSizeWarning.tsx @@ -1,7 +1,7 @@ import Alert from "@base/Alert"; import Link from "@base/Link"; -import type { Read } from "@samples/types"; import { useLocation } from "@tanstack/react-router"; +import type { Read } from "@virtool/contracts"; import { AlertTriangle } from "lucide-react"; type SampleFileSizeWarningProps = { diff --git a/apps/web/src/samples/components/Detail/SampleRights.tsx b/apps/web/src/samples/components/Detail/SampleRights.tsx index 772b88e6c..63fd6e35d 100644 --- a/apps/web/src/samples/components/Detail/SampleRights.tsx +++ b/apps/web/src/samples/components/Detail/SampleRights.tsx @@ -75,7 +75,7 @@ export default function SampleRights({ sampleId }: SampleRightsProps) { const canModifyRights = hasPermission || sample.user.id === account.id; - const { group, group_read, group_write, all_read, all_write } = sample; + const { group, groupRead, groupWrite, allRead, allWrite } = sample; function handleChangeGroup(value: string) { const group = value === "" ? null : parseInt(value, 10); @@ -95,8 +95,8 @@ export default function SampleRights({ sampleId }: SampleRightsProps) { mutation.mutate( { update: { - [`${scope}_read`]: value.includes("r"), - [`${scope}_write`]: value.includes("w"), + [`${scope}Read`]: value.includes("r"), + [`${scope}Write`]: value.includes("w"), }, }, { @@ -113,8 +113,8 @@ export default function SampleRights({ sampleId }: SampleRightsProps) { return Not allowed; } - const groupRights = (group_read ? "r" : "") + (group_write ? "w" : ""); - const allRights = (all_read ? "r" : "") + (all_write ? "w" : ""); + const groupRights = (groupRead ? "r" : "") + (groupWrite ? "w" : ""); + const allRights = (allRead ? "r" : "") + (allWrite ? "w" : ""); const selectedGroupId: string = group ? group.id.toString() : ""; diff --git a/apps/web/src/samples/components/Detail/__tests__/DeleteSample.test.tsx b/apps/web/src/samples/components/Detail/__tests__/DeleteSample.test.tsx index 2cc974e6b..429977c89 100644 --- a/apps/web/src/samples/components/Detail/__tests__/DeleteSample.test.tsx +++ b/apps/web/src/samples/components/Detail/__tests__/DeleteSample.test.tsx @@ -1,7 +1,7 @@ -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { mockApiRemoveSample } from "@tests/api/samples"; import { createFakeJobNested } from "@tests/fake/jobs"; +import { mockDeleteSample } from "@tests/server-fn/samples"; import { renderWithRouter } from "@tests/setup"; import type { ComponentProps } from "react"; import { beforeEach, describe, expect, it } from "vitest"; @@ -49,7 +49,7 @@ describe("", () => { }); it("should handle submit when confirm button is clicked", async () => { - const scope = mockApiRemoveSample(props.id); + const deleteSample = mockDeleteSample(); await renderWithRouter(); await userEvent.click(screen.getByRole("button")); @@ -57,6 +57,6 @@ describe("", () => { await userEvent.click(screen.getByText("Confirm")); - scope.done(); + await waitFor(() => expect(deleteSample).toHaveBeenCalled()); }); }); diff --git a/apps/web/src/samples/components/Detail/__tests__/SampleRights.test.tsx b/apps/web/src/samples/components/Detail/__tests__/SampleRights.test.tsx index a4ba3a94c..6534b8311 100644 --- a/apps/web/src/samples/components/Detail/__tests__/SampleRights.test.tsx +++ b/apps/web/src/samples/components/Detail/__tests__/SampleRights.test.tsx @@ -1,18 +1,17 @@ import SampleRights from "@samples/components/Detail/SampleRights"; -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { - mockApiGetSampleDetail, - mockApiUpdateSampleRights, -} from "@tests/api/samples"; import { createFakeAccount } from "@tests/fake/account"; import { createFakeGroup } from "@tests/fake/groups"; import { createFakeSample } from "@tests/fake/samples"; import { mockListGroups } from "@tests/server-fn/groups"; +import { + mockGetSample, + mockUpdateSampleRights, +} from "@tests/server-fn/samples"; import { mockGetAccount } from "@tests/server-fn/users"; import { renderWithProviders } from "@tests/setup"; -import nock from "nock"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; describe("", () => { let sample: ReturnType; @@ -20,18 +19,16 @@ describe("", () => { beforeEach(() => { sample = createFakeSample({ - all_read: false, - all_write: false, - group_read: false, - group_write: false, + allRead: false, + allWrite: false, + groupRead: false, + groupWrite: false, }); group = createFakeGroup(); - mockApiGetSampleDetail(sample); + mockGetSample(sample); mockListGroups([group]); }); - afterEach(() => nock.cleanAll()); - it("should render", async () => { mockGetAccount(createFakeAccount({ administrator_role: "full" })); renderWithProviders(); @@ -51,43 +48,49 @@ describe("", () => { it("should handle group change when input is changed", async () => { mockGetAccount(createFakeAccount({ administrator_role: "full" })); - const scope = mockApiUpdateSampleRights(sample, { group: group.id }); + const updateSampleRights = mockUpdateSampleRights(sample); renderWithProviders(); expect(await screen.findByText("Sample Rights")).toBeInTheDocument(); await userEvent.click(screen.getByLabelText("Group")); await userEvent.click(screen.getByRole("option", { name: group.name })); - await scope.done(); + await waitFor(() => + expect(updateSampleRights).toHaveBeenCalledWith({ + data: expect.objectContaining({ group: group.id }), + }), + ); }); it("should handle group rights change when input is changed", async () => { mockGetAccount(createFakeAccount({ administrator_role: "full" })); - const scope = mockApiUpdateSampleRights(sample, { - group_read: true, - group_write: true, - }); + const updateSampleRights = mockUpdateSampleRights(sample); renderWithProviders(); expect(await screen.findByText("Sample Rights")).toBeInTheDocument(); await userEvent.click(screen.getByLabelText("Group Rights")); await userEvent.click(screen.getByRole("option", { name: "Read & write" })); - await scope.done(); + await waitFor(() => + expect(updateSampleRights).toHaveBeenCalledWith({ + data: expect.objectContaining({ groupRead: true, groupWrite: true }), + }), + ); }); it("should handle all users' rights change when input is changed", async () => { mockGetAccount(createFakeAccount({ administrator_role: "full" })); - const scope = mockApiUpdateSampleRights(sample, { - all_read: true, - all_write: true, - }); + const updateSampleRights = mockUpdateSampleRights(sample); renderWithProviders(); expect(await screen.findByText("Sample Rights")).toBeInTheDocument(); await userEvent.click(screen.getByLabelText("All Users' Rights")); await userEvent.click(screen.getByRole("option", { name: "Read & write" })); - await scope.done(); + await waitFor(() => + expect(updateSampleRights).toHaveBeenCalledWith({ + data: expect.objectContaining({ allRead: true, allWrite: true }), + }), + ); }); }); diff --git a/apps/web/src/samples/components/EditSample.tsx b/apps/web/src/samples/components/EditSample.tsx index 622b572ca..8cc46bc84 100644 --- a/apps/web/src/samples/components/EditSample.tsx +++ b/apps/web/src/samples/components/EditSample.tsx @@ -5,10 +5,10 @@ import InputLabel from "@base/InputLabel"; import InputSimple from "@base/InputSimple"; import SaveButton from "@base/SaveButton"; import TextArea from "@base/TextArea"; +import type { Sample } from "@virtool/contracts"; import { pick } from "es-toolkit/object"; import { useForm } from "react-hook-form"; import { useUpdateSample } from "../queries"; -import type { Sample } from "../types"; type EditSampleProps = { open?: boolean; @@ -71,8 +71,7 @@ export default function EditSample({ {...register("name")} /> - {mutation.isError && - (mutation.error.response.body.message || "Required Field")} + {mutation.isError && (mutation.error.message || "Required Field")} diff --git a/apps/web/src/samples/components/Files/ReadItem.tsx b/apps/web/src/samples/components/Files/ReadItem.tsx index 3645d5ec8..841e55189 100644 --- a/apps/web/src/samples/components/Files/ReadItem.tsx +++ b/apps/web/src/samples/components/Files/ReadItem.tsx @@ -10,7 +10,7 @@ function sanitizeFileName(name: string): string { } type ReadItemProps = { - download_url: string; + downloadUrl: string; sampleName: string; side: number; /** The size of the read file in bytes */ @@ -21,7 +21,7 @@ type ReadItemProps = { * A condensed read item for use in a list of reads */ export default function ReadItem({ - download_url, + downloadUrl, sampleName, side, size, @@ -32,7 +32,7 @@ export default function ReadItem({
diff --git a/apps/web/src/samples/components/Files/SampleDetailFiles.tsx b/apps/web/src/samples/components/Files/SampleDetailFiles.tsx index 43cfaacfc..f6e5fac15 100644 --- a/apps/web/src/samples/components/Files/SampleDetailFiles.tsx +++ b/apps/web/src/samples/components/Files/SampleDetailFiles.tsx @@ -17,7 +17,7 @@ export default function SampleDetailFiles() { return ( - + ); diff --git a/apps/web/src/samples/components/Files/SampleReads.tsx b/apps/web/src/samples/components/Files/SampleReads.tsx index b4caaea68..f67791c6c 100644 --- a/apps/web/src/samples/components/Files/SampleReads.tsx +++ b/apps/web/src/samples/components/Files/SampleReads.tsx @@ -1,6 +1,6 @@ import BoxGroup from "@base/BoxGroup"; import BoxGroupHeader from "@base/BoxGroupHeader"; -import type { Read } from "@samples/types"; +import type { Read } from "@virtool/contracts"; import ReadItem from "./ReadItem"; type SampleReadsProps = { @@ -25,7 +25,7 @@ export default function SampleReads({ reads, sampleName }: SampleReadsProps) { const fileComponents = reads.map((file) => ( {sample.name} - +
{sample.ready && ( @@ -67,7 +71,7 @@ export default function SampleItem({ />
- + {sample.labels.map((label) => ( ))} diff --git a/apps/web/src/samples/components/Label/SampleLibraryTypeLabel.tsx b/apps/web/src/samples/components/Label/SampleLibraryTypeLabel.tsx index 883f95644..9292c3dc7 100644 --- a/apps/web/src/samples/components/Label/SampleLibraryTypeLabel.tsx +++ b/apps/web/src/samples/components/Label/SampleLibraryTypeLabel.tsx @@ -1,6 +1,6 @@ import Icon from "@base/Icon"; -import type { LibraryType } from "@samples/types"; import { getLibraryTypeDisplayName } from "@samples/utils"; +import type { LibraryType } from "@virtool/contracts"; import { Dna } from "lucide-react"; import { BaseSampleLabel } from "./BaseSampleLabel"; diff --git a/apps/web/src/samples/components/SampleLabelsSelector.tsx b/apps/web/src/samples/components/SampleLabelsSelector.tsx index 0f73be68b..ca74fd291 100644 --- a/apps/web/src/samples/components/SampleLabelsSelector.tsx +++ b/apps/web/src/samples/components/SampleLabelsSelector.tsx @@ -9,7 +9,7 @@ import Input from "@base/Input"; import type { Label } from "@labels/types"; import { getHexColor, getSelectedLabels } from "@samples/labels"; import { useUpdateLabel } from "@samples/queries"; -import type { Sample, SampleMinimal } from "@samples/types"; +import type { Sample, SampleMinimal } from "@virtool/contracts"; import { Tag } from "lucide-react"; import { useState } from "react"; diff --git a/apps/web/src/samples/components/SampleListHeader.tsx b/apps/web/src/samples/components/SampleListHeader.tsx index 0749260a7..f55e8e75e 100644 --- a/apps/web/src/samples/components/SampleListHeader.tsx +++ b/apps/web/src/samples/components/SampleListHeader.tsx @@ -2,7 +2,7 @@ import Button from "@base/Button"; import Checkbox from "@base/Checkbox"; import Icon from "@base/Icon"; import type { Label } from "@labels/types"; -import type { Sample, SampleMinimal } from "@samples/types"; +import type { Sample, SampleMinimal } from "@virtool/contracts"; import { AreaChart } from "lucide-react"; import SampleLabelsSelector from "./SampleLabelsSelector"; diff --git a/apps/web/src/samples/components/SampleQuality.tsx b/apps/web/src/samples/components/SampleQuality.tsx index 47e623d13..c1d68e07b 100644 --- a/apps/web/src/samples/components/SampleQuality.tsx +++ b/apps/web/src/samples/components/SampleQuality.tsx @@ -14,7 +14,7 @@ export default function SampleQuality() { return (
- + {data.quality && ( ; } - const { found_count, items, page, page_count } = samples; + const { foundCount, items, page, pageCount } = samples; // An empty list means "nothing created yet" only when nothing is narrowing // it. Otherwise the samples exist and the filters are hiding them. @@ -242,13 +242,13 @@ export default function SamplesList({ setSearch({ page })} rowsClassName="pb-0 rounded-sm border-1 border-gray-300 overflow-hidden [&>*:not(:first-child)]:border-t-1 [&>*:not(:first-child)]:border-gray-300" > selection.toggleVisible(items)} diff --git a/apps/web/src/samples/components/Tag/WorkflowTag.tsx b/apps/web/src/samples/components/Tag/WorkflowTag.tsx index 4adf2863e..57abb6493 100644 --- a/apps/web/src/samples/components/Tag/WorkflowTag.tsx +++ b/apps/web/src/samples/components/Tag/WorkflowTag.tsx @@ -1,5 +1,5 @@ import Icon from "@base/Icon"; -import type { WorkflowState } from "@samples/types"; +import type { WorkflowState } from "@virtool/contracts"; import { workflowStateIcons } from "../Filter/workflowStateIcons"; import { BaseWorkflowTag } from "./BaseWorkflowTag"; diff --git a/apps/web/src/samples/components/Tag/WorkflowTags.tsx b/apps/web/src/samples/components/Tag/WorkflowTags.tsx index 79c022e74..2aa9ad2aa 100644 --- a/apps/web/src/samples/components/Tag/WorkflowTags.tsx +++ b/apps/web/src/samples/components/Tag/WorkflowTags.tsx @@ -1,6 +1,6 @@ import { getWorkflowDisplayName } from "@app/utils"; import Link from "@base/Link"; -import type { SampleWorkflows } from "@samples/types"; +import type { SampleWorkflows } from "@virtool/contracts"; import { BaseWorkflowTag } from "./BaseWorkflowTag"; import WorkflowTag from "./WorkflowTag"; diff --git a/apps/web/src/samples/components/__tests__/EditSample.test.tsx b/apps/web/src/samples/components/__tests__/EditSample.test.tsx index 9b6d7a85a..5a65afb60 100644 --- a/apps/web/src/samples/components/__tests__/EditSample.test.tsx +++ b/apps/web/src/samples/components/__tests__/EditSample.test.tsx @@ -1,7 +1,7 @@ -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { mockApiEditSample } from "@tests/api/samples"; import { createFakeSample } from "@tests/fake/samples"; +import { mockUpdateSample } from "@tests/server-fn/samples"; import { renderWithRouter } from "@tests/setup"; import type { ComponentProps } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -60,14 +60,13 @@ describe("", () => { }); it("should update sample when form is submitted", async () => { - const scope = mockApiEditSample( - sample, - "newName", - "newIsolate", - "newHost", - "newLocale", - "newNotes", - ); + const updateSample = mockUpdateSample(sample, { + name: "newName", + isolate: "newIsolate", + host: "newHost", + locale: "newLocale", + notes: "newNotes", + }); await renderWithRouter(); const nameInput = screen.getByLabelText("Name"); @@ -91,6 +90,6 @@ describe("", () => { await userEvent.type(notesInput, "newNotes"); await userEvent.click(screen.getByText("Save")); - scope.done(); + await waitFor(() => expect(updateSample).toHaveBeenCalled()); }); }); diff --git a/apps/web/src/samples/components/__tests__/SampleLabelsSelector.test.tsx b/apps/web/src/samples/components/__tests__/SampleLabelsSelector.test.tsx index efb4ffa19..871e961fc 100644 --- a/apps/web/src/samples/components/__tests__/SampleLabelsSelector.test.tsx +++ b/apps/web/src/samples/components/__tests__/SampleLabelsSelector.test.tsx @@ -1,27 +1,18 @@ import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createFakeSampleMinimal } from "@tests/fake/samples"; +import { sampleServerFnMocks } from "@tests/server-fn/samples"; import { renderWithRouter } from "@tests/setup"; -import nock from "nock"; import type { ComponentProps } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import SampleLabelsSelector from "../SampleLabelsSelector"; -/** - * Mocks the sample-update endpoint and captures the request body so tests can - * assert which labels were sent for each selected sample. - */ -function mockApiUpdateSampleLabels(sampleId: number) { - const captured: { body?: { labels: number[] } } = {}; - - nock("http://localhost") - .patch(`/api/samples/${sampleId}`) - .reply(200, (_uri, body: { labels: number[] }) => { - captured.body = body; - return {}; - }); - - return captured; +/** The labels sent to `updateSample` for the given sample, if it was called. */ +function labelsSentFor(sampleId: number): number[] | undefined { + const call = sampleServerFnMocks.updateSampleFn.mock.calls.find( + ([arg]) => arg?.data?.sampleId === sampleId, + ); + return call?.[0]?.data?.labels; } describe("", () => { @@ -85,9 +76,11 @@ describe("", () => { }); describe("toggling a label with mixed labels across the selection", () => { - afterEach(() => nock.cleanAll()); - beforeEach(() => { + // The bulk toggle patches each sample through the updateSample server + // function; let it resolve so the mutation settles. + sampleServerFnMocks.updateSampleFn.mockResolvedValue({}); + // "test" (id 1) is on both samples, "label" (id 2) is on one only, so // the selection has labels in mixed states. props.selectedSamples = [ @@ -137,38 +130,29 @@ describe("", () => { }); it("removes a fully-applied label from every sample", async () => { - const foo = mockApiUpdateSampleLabels(1); - const bar = mockApiUpdateSampleLabels(2); - await openSelectorAndClick("test"); await waitFor(() => { - expect(foo.body?.labels).toEqual([2]); - expect(bar.body?.labels).toEqual([]); + expect(labelsSentFor(1)).toEqual([2]); + expect(labelsSentFor(2)).toEqual([]); }); }); it("adds a partially-applied label to every sample", async () => { - const foo = mockApiUpdateSampleLabels(1); - const bar = mockApiUpdateSampleLabels(2); - await openSelectorAndClick("label"); await waitFor(() => { - expect(foo.body?.labels).toEqual([1, 2]); - expect(bar.body?.labels).toEqual([1, 2]); + expect(labelsSentFor(1)).toEqual([1, 2]); + expect(labelsSentFor(2)).toEqual([1, 2]); }); }); it("adds an unapplied label to every sample", async () => { - const foo = mockApiUpdateSampleLabels(1); - const bar = mockApiUpdateSampleLabels(2); - await openSelectorAndClick("bar"); await waitFor(() => { - expect(foo.body?.labels).toEqual([1, 2, 3]); - expect(bar.body?.labels).toEqual([1, 3]); + expect(labelsSentFor(1)).toEqual([1, 2, 3]); + expect(labelsSentFor(2)).toEqual([1, 3]); }); }); }); diff --git a/apps/web/src/samples/components/__tests__/SamplesList.test.tsx b/apps/web/src/samples/components/__tests__/SamplesList.test.tsx index 8aa709c59..0c48005db 100644 --- a/apps/web/src/samples/components/__tests__/SamplesList.test.tsx +++ b/apps/web/src/samples/components/__tests__/SamplesList.test.tsx @@ -1,7 +1,6 @@ import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { mockApiListIndexes } from "@tests/api/indexes"; -import { mockApiGetSamples } from "@tests/api/samples"; import { createFakeAccount } from "@tests/fake/account"; import { createFakeHmmSearchResults } from "@tests/fake/hmm"; import { createFakeIndexMinimal } from "@tests/fake/indexes"; @@ -11,6 +10,7 @@ import { createFakeShortlistSubtraction } from "@tests/fake/subtractions"; import { createFakeUserNested } from "@tests/fake/user"; import { mockFindHmms } from "@tests/server-fn/hmm"; import { mockFindLabels } from "@tests/server-fn/labels"; +import { mockFindSamplePages, mockFindSamples } from "@tests/server-fn/samples"; import { mockListSubtractionsShortlist } from "@tests/server-fn/subtractions"; import { mockGetAccount, mockListUsers } from "@tests/server-fn/users"; import { at, renderWithRouter } from "@tests/setup"; @@ -58,35 +58,20 @@ function SamplesListHarness({ * @returns The sample on each page, in page order */ function mockApiGetSamplePages() { - const documents = [ + const samples = [ createFakeSampleMinimal({ name: "Page One Sample" }), createFakeSampleMinimal({ name: "Page Two Sample" }), ] as const; nock.cleanAll(); - documents.forEach((document, index) => { - const page = index + 1; - - nock("http://localhost") - .persist() - .get("/api/samples") - .query((query) => Number(query.page ?? 1) === page) - .reply(200, { - page, - page_count: documents.length, - per_page: 1, - total_count: documents.length, - found_count: documents.length, - documents: [document], - }); - }); + mockFindSamplePages(samples.map((sample) => [sample])); mockFindHmms(createFakeHmmSearchResults()); mockApiListIndexes([createFakeIndexMinimal()]); mockListSubtractionsShortlist([createFakeShortlistSubtraction()]); - return documents; + return samples; } /** @@ -103,7 +88,7 @@ function mockApiGetSampleRange(names: string[]) { mockFindHmms(createFakeHmmSearchResults()); mockApiListIndexes([createFakeIndexMinimal()]); mockListSubtractionsShortlist([createFakeShortlistSubtraction()]); - mockApiGetSamples(documents); + mockFindSamples(documents); return documents; } @@ -125,7 +110,7 @@ describe("", () => { ]; mockListUsers(users); mockFindLabels(labels); - mockApiGetSamples(samples); + mockFindSamples(samples); mockFindHmms(createFakeHmmSearchResults()); mockApiListIndexes([createFakeIndexMinimal()]); mockListSubtractionsShortlist([createFakeShortlistSubtraction()]); @@ -144,9 +129,9 @@ describe("", () => { }); it("should call onChange when search input changes in toolbar", async () => { - mockApiGetSamples(samples); - mockApiGetSamples(samples); - mockApiGetSamples(samples); + mockFindSamples(samples); + mockFindSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -181,7 +166,7 @@ describe("", () => { describe("label filtering", () => { it("should show a chip for a label selected in the dropdown", async () => { - mockApiGetSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -202,8 +187,8 @@ describe("", () => { }); it("should remove every chip when the dropdown is cleared", async () => { - mockApiGetSamples(samples); - mockApiGetSamples(samples); + mockFindSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -230,8 +215,8 @@ describe("", () => { it("should show a chip for the search term", async () => { // One interceptor per samples fetch: the initial empty-term render plus // the single refetch the debounced toolbar commits for "Foo". - mockApiGetSamples(samples); - mockApiGetSamples(samples); + mockFindSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -246,7 +231,7 @@ describe("", () => { describe("workflow filtering", () => { it("should show a chip for a workflow state selected in the dropdown", async () => { - mockApiGetSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -267,8 +252,8 @@ describe("", () => { }); it("should keep the menu open and check both states toggled for one workflow", async () => { - mockApiGetSamples(samples); - mockApiGetSamples(samples); + mockFindSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -292,8 +277,8 @@ describe("", () => { }); it("should remove every workflow chip when the dropdown is cleared", async () => { - mockApiGetSamples(samples); - mockApiGetSamples(samples); + mockFindSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -319,7 +304,7 @@ describe("", () => { describe("user filtering", () => { it("should show a chip for a user selected in the dropdown", async () => { - mockApiGetSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -337,8 +322,8 @@ describe("", () => { }); it("should show a chip for each of several users selected at once", async () => { - mockApiGetSamples(samples); - mockApiGetSamples(samples); + mockFindSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -399,8 +384,8 @@ describe("", () => { }); it("should remove every chip when the dropdown is cleared", async () => { - mockApiGetSamples(samples); - mockApiGetSamples(samples); + mockFindSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -445,7 +430,7 @@ describe("", () => { mockFindHmms(createFakeHmmSearchResults()); mockApiListIndexes([createFakeIndexMinimal()]); mockListSubtractionsShortlist([createFakeShortlistSubtraction()]); - mockApiGetSamples(samples, { found_count: 2, total_count: 17 }); + mockFindSamples(samples, { foundCount: 2, totalCount: 17 }); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -552,7 +537,7 @@ describe("", () => { }); it("should clear the selection when a filter changes", async () => { - mockApiGetSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -840,7 +825,7 @@ describe("", () => { // The default samples interceptor is already registered, and it would // answer the request before any empty one added here. nock.cleanAll(); - mockApiGetSamples([]).persist(); + mockFindSamples([]); mockFindHmms(createFakeHmmSearchResults()); mockApiListIndexes([createFakeIndexMinimal()]); mockListSubtractionsShortlist([createFakeShortlistSubtraction()]); diff --git a/apps/web/src/samples/hooks.ts b/apps/web/src/samples/hooks.ts index ccaa9d763..541461aac 100644 --- a/apps/web/src/samples/hooks.ts +++ b/apps/web/src/samples/hooks.ts @@ -30,9 +30,9 @@ export function useCheckCanEditSample(sampleId: number) { const hasPermission = hasSufficientAdminRole("full", account.administrator_role) || - sample.all_write || + sample.allWrite || sample.user.id === account.id || - (sample.group_write && + (sample.groupWrite && account.groups.some((g) => g.id === sample.group?.id)); return { hasPermission, isPending: false }; diff --git a/apps/web/src/samples/labels.ts b/apps/web/src/samples/labels.ts index 5a5126efe..756bf8f33 100644 --- a/apps/web/src/samples/labels.ts +++ b/apps/web/src/samples/labels.ts @@ -1,5 +1,5 @@ import type { SampleLabel } from "@samples/queries"; -import type { SampleMinimal } from "@samples/types"; +import type { SampleMinimal } from "@virtool/contracts"; import { groupBy } from "es-toolkit"; /** diff --git a/apps/web/src/samples/queries.ts b/apps/web/src/samples/queries.ts index bc84085b4..179147743 100644 --- a/apps/web/src/samples/queries.ts +++ b/apps/web/src/samples/queries.ts @@ -1,6 +1,13 @@ -import { apiClient } from "@app/api"; import type { LabelNested } from "@labels/types"; import { samplesQueryKeys } from "@samples/keys"; +import { + createSampleFn, + deleteSampleFn, + findSamplesFn, + getSampleFn, + updateSampleFn, + updateSampleRightsFn, +} from "@server/samples/functions"; import { keepPreviousData, queryOptions, @@ -10,17 +17,15 @@ import { useSuspenseQuery, } from "@tanstack/react-query"; import { fileQueryKeys } from "@uploads/keys"; -import { union } from "es-toolkit"; -import type { ErrorResponse } from "@/types/api"; import type { - CreateSampleRequest, + LibraryType, Sample, SampleMinimal, SampleRightsUpdate, - SampleRightsUpdateReturn, SampleSearchResult, - SampleUpdate, -} from "./types"; +} from "@virtool/contracts"; +import { union } from "es-toolkit"; +import type { CreateSampleRequest, SampleUpdate } from "./types"; /** A label carried by at least one of the selected samples */ export type SampleLabel = LabelNested & { @@ -28,18 +33,6 @@ export type SampleLabel = LabelNested & { allLabeled: boolean; }; -/** - * Updates the data for a sample. - * - * Shared by the single-sample update hook and the bulk label-update hook. - */ -function updateSample(sampleId: number, update: SampleUpdate): Promise { - return apiClient - .patch(`/samples/${sampleId}`) - .send(update) - .then((response) => response.body); -} - /** * Fetch a page of samples from the API * @@ -58,7 +51,7 @@ export function useListSamples( workflows?: string[], users?: number[], ) { - return useQuery({ + return useQuery({ queryKey: samplesQueryKeys.list([ page, per_page, @@ -68,29 +61,24 @@ export function useListSamples( users, ]), queryFn: () => - apiClient - .get("/samples") - .query({ + findSamplesFn({ + data: { page, - per_page, - find: term, - label: labels, - workflows, - user: users, - }) - .then((res) => { - const { documents, ...rest } = res.body; - return { ...rest, items: documents }; - }), + perPage: per_page, + term: term ?? "", + labels: labels ?? [], + workflows: workflows ?? [], + users: users ?? [], + }, + }) as Promise, placeholderData: keepPreviousData, }); } export function sampleQueryOptions(sampleId: number) { - return queryOptions({ + return queryOptions({ queryKey: samplesQueryKeys.detail(sampleId), - queryFn: () => - apiClient.get(`/samples/${sampleId}`).then((res) => res.body), + queryFn: () => getSampleFn({ data: { sampleId } }) as Promise, }); } @@ -115,47 +103,38 @@ export function useSuspenseSample(sampleId: number) { } /** - * Creates a sample. + * Initialize a mutator for creating a sample * - * Shared by the single-sample create hook and the bulk create hook. + * @returns A mutator for creating a sample */ -function createSample({ - name, - isolate, - host, - locale, - libraryType, - subtractions, - files, - labels, - group, -}: CreateSampleRequest): Promise { - return apiClient - .post("/samples") - .send({ +export function useCreateSample() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ name, isolate, host, locale, + libraryType, subtractions, files, - library_type: libraryType, labels, group, - }) - .then((res) => res.body); -} - -/** - * Initialize a mutator for creating a sample - * - * @returns A mutator for creating a sample - */ -export function useCreateSample() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: createSample, + }) => + createSampleFn({ + data: { + name, + isolate, + host, + locale, + libraryType: libraryType as LibraryType, + subtractions, + files, + labels, + group, + }, + }) as Promise, onSuccess: () => { // The created sample reserves its read files, so the server stops // returning them. Only the reads selector shows them — an infinite @@ -175,8 +154,11 @@ export function useCreateSample() { export function useUpdateSample(sampleId: number) { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ update }) => updateSample(sampleId, update), + return useMutation({ + mutationFn: ({ update }) => + updateSampleFn({ + data: { sampleId, ...update }, + }) as Promise, onSuccess: () => { queryClient.invalidateQueries({ queryKey: samplesQueryKeys.detail(sampleId), @@ -191,11 +173,9 @@ export function useUpdateSample(sampleId: number) { * @returns A mutator for removing a sample */ export function useRemoveSample() { - return useMutation({ + return useMutation({ mutationFn: ({ sampleId }) => - apiClient - .delete(`/samples/${sampleId}`) - .then((response) => response.body), + deleteSampleFn({ data: { sampleId } }) as Promise, }); } @@ -205,16 +185,11 @@ export function useRemoveSample() { * @returns A mutator for updating a samples rights */ export function useUpdateSampleRights(sampleId: number) { - return useMutation< - SampleRightsUpdateReturn, - unknown, - { update: SampleRightsUpdate } - >({ + return useMutation({ mutationFn: ({ update }) => - apiClient - .patch(`/samples/${sampleId}/rights`) - .send(update) - .then((response) => response.body), + updateSampleRightsFn({ + data: { sampleId, ...update }, + }) as Promise, }); } @@ -235,7 +210,7 @@ export function useUpdateLabel( ) { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: (labelId) => { const clicked = selectedLabels.find((label) => label.id === labelId); const allLabeled = clicked?.allLabeled === true; @@ -244,11 +219,14 @@ export function useUpdateLabel( selectedSamples.map((sample) => { const labelIds = sample.labels.map((label) => label.id); - return updateSample(sample.id, { - labels: allLabeled - ? labelIds.filter((id) => id !== labelId) - : union(labelIds, [labelId]), - }); + return updateSampleFn({ + data: { + sampleId: sample.id, + labels: allLabeled + ? labelIds.filter((id) => id !== labelId) + : union(labelIds, [labelId]), + }, + }) as Promise; }), ); }, diff --git a/apps/web/src/samples/types.ts b/apps/web/src/samples/types.ts index 7c9fa7f1c..df1c97415 100644 --- a/apps/web/src/samples/types.ts +++ b/apps/web/src/samples/types.ts @@ -1,119 +1,7 @@ -/** - * Sample Types - * - * @remark - * Types in this file represent the data returned from the API, or are useful for working - * with sample data. - */ - -import type { GroupMinimal } from "@groups/types"; -import type { ServerJobNested } from "@jobs/types"; -import type { LabelNested } from "@labels/types"; -import type { SubtractionNested } from "@subtraction/types"; -import type { UserNested } from "@users/types"; -import type { SearchResult } from "@/types/api"; - -/* All workflow states */ -export type WorkflowState = "complete" | "pending" | "none" | "incompatible"; - -/* All Library types */ -export type LibraryType = "amplicon" | "srna" | "other" | "normal"; - -/* All workflow states for a sample */ -export type SampleWorkflows = { - /** The state of Nuvs workflows */ - nuvs: WorkflowState; - - /** The state of Pathoscope workflows */ - pathoscope: WorkflowState; -}; - -export type SampleArtifact = { - id: number; - download_url: string; - name: string; - size: number; -}; - -/* A Sample ID */ -export type SampleID = { - id: number; -}; - /* A Sample with essential information */ -export type SampleNested = SampleID & { - name: string; -}; - -/* Minimal Sample used for resource listings */ -export type SampleMinimal = SampleNested & { - created_at: string; - host: string; - isolate: string; - /* Information about the job associated with the sample */ - job?: ServerJobNested; - /* Label associated with the sample */ - labels: Array; - library_type: LibraryType; - notes: string; - nuvs: boolean | string; - pathoscope: boolean | string; - ready: boolean; - /* The user who created the sample */ - user: UserNested; - workflows: SampleWorkflows; -}; - -/* The quality charts associated with the sample */ -export type Quality = { - /* Data for bases chart */ - bases: Array>; - /* Data for composition chart */ - composition: Array>; - /* The read count of the sample */ - count: number; - encoding: string; - /* The GC content of the sample (percentage) */ - gc: number; - /* The length range */ - length: Array; - /* Data for sequences chart */ - sequences: Array; -}; - -/* The read file used to create the sample */ -export type Read = { - download_url: string; +export type SampleNested = { id: number; name: string; - name_on_disk: string; - sample: number; - size: number; - upload?: File; - uploaded_at: string; -}; - -/* A complete sample */ -export type Sample = SampleMinimal & { - all_read: boolean; - all_write: boolean; - artifacts: Array; - format: string; - group: GroupMinimal | null; - group_read: boolean; - group_write: boolean; - hold: boolean; - is_legacy: boolean; - locale: string; - paired: boolean; - quality: Quality | null; - reads: Array; - subtractions: Array; -}; - -/* Sample search results from the API */ -export type SampleSearchResult = SearchResult & { - items: Array; }; /** The fields sent to the API to create a sample */ @@ -138,22 +26,3 @@ export type SampleUpdate = { notes?: string; subtractions?: number[]; }; - -/** Data returned from API on sample rights update */ -export type SampleRightsUpdateReturn = { - all_read: boolean; - all_write: boolean; - group: number | string | null; - group_read: boolean; - group_write: boolean; - user: { [key: string]: string }; -}; - -/** Fields that can be changed when updating a sample's rights */ -export type SampleRightsUpdate = { - group?: number | string | null; - group_read?: boolean; - group_write?: boolean; - all_read?: boolean; - all_write?: boolean; -}; diff --git a/apps/web/src/samples/utils.ts b/apps/web/src/samples/utils.ts index 679266351..53c0076e2 100644 --- a/apps/web/src/samples/utils.ts +++ b/apps/web/src/samples/utils.ts @@ -1,7 +1,25 @@ -import type { JobNested } from "@jobs/types"; +import type { JobNested, ServerJobNested } from "@jobs/types"; import { stripMateToken } from "@uploads/pairing"; import type { Upload } from "@uploads/types"; -import type { CreateSampleRequest, LibraryType } from "./types"; +import type { LibraryType, SampleJobNested } from "@virtool/contracts"; +import type { CreateSampleRequest } from "./types"; + +/** + * Adapt a sample's embedded job to the snake_case shape the jobs feature's + * schema parses. The samples wire shape is camelCase, but the jobs feature is + * still served from the Python API, so its `useFetchJob` seed and + * `JobNestedSchema` expect the snake_case job. + */ +export function toServerJobNested(job: SampleJobNested): ServerJobNested { + return { + created_at: job.createdAt, + id: job.id, + progress: job.progress, + state: job.state, + user: job.user, + workflow: job.workflow, + }; +} /** The workflows that samples can be filtered by. */ export const filterableWorkflows = ["pathoscope", "nuvs"]; diff --git a/apps/web/src/server/__tests__/authorization.test.ts b/apps/web/src/server/__tests__/authorization.test.ts index 7fc121322..ff43ae187 100644 --- a/apps/web/src/server/__tests__/authorization.test.ts +++ b/apps/web/src/server/__tests__/authorization.test.ts @@ -113,6 +113,13 @@ const MODULES = [ "../root/functions.ts?tss-serverfn-split" )) as SplitServerFnModule, }, + { + path: "../samples/functions.ts", + fns: await import("../samples/functions"), + handlers: (await import( + "../samples/functions.ts?tss-serverfn-split" + )) as SplitServerFnModule, + }, { path: "../settings/functions.ts", fns: await import("../settings/functions"), @@ -231,7 +238,7 @@ describe("the open endpoints are reachable without a session", () => { expect(open.map((endpoint) => endpoint.name).sort()).toEqual([ "createFirstUserFn", "getPasswordPolicyFn", - "getRoot", + "getRootFn", "loginFn", "logoutFn", "resetPasswordFn", diff --git a/apps/web/src/server/account/functions.test.ts b/apps/web/src/server/account/functions.test.ts index 7e1fb7e0c..2fc6e826e 100644 --- a/apps/web/src/server/account/functions.test.ts +++ b/apps/web/src/server/account/functions.test.ts @@ -94,14 +94,16 @@ function call(name: string, data?: unknown) { describe("findApiKeys", () => { it("refuses an unauthenticated caller", async () => { - await expect(call("findApiKeys")).rejects.toBeInstanceOf(UnauthorizedError); + await expect(call("findApiKeysFn")).rejects.toBeInstanceOf( + UnauthorizedError, + ); }); it("returns only the signed-in user's keys", async () => { await signIn(); - await call("createApiKey", { name: "Robot", permissions: {} }); + await call("createApiKeyFn", { name: "Robot", permissions: {} }); - const keys = (await call("findApiKeys")) as { name: string }[]; + const keys = (await call("findApiKeysFn")) as { name: string }[]; expect(keys).toHaveLength(1); expect(keys[0]?.name).toBe("Robot"); @@ -112,7 +114,7 @@ describe("createApiKey", () => { it("returns the raw secret and a 201", async () => { await signIn(); - const created = (await call("createApiKey", { + const created = (await call("createApiKeyFn", { name: "Robot", permissions: { create_ref: true }, })) as { key: string; name: string }; @@ -126,7 +128,7 @@ describe("createApiKey", () => { describe("updateApiKey", () => { it("responds with 404 for a key the user does not own", async () => { const owner = await signIn("owner"); - const created = (await call("createApiKey", { + const created = (await call("createApiKeyFn", { name: "Robot", permissions: {}, })) as { id: number }; @@ -134,7 +136,7 @@ describe("updateApiKey", () => { await signIn("intruder"); await expect( - call("updateApiKey", { + call("updateApiKeyFn", { keyId: created.id, permissions: { create_ref: true }, }), @@ -150,12 +152,12 @@ describe("updateApiKey", () => { describe("deleteApiKey", () => { it("removes the signed-in user's key with a 204", async () => { await signIn(); - const created = (await call("createApiKey", { + const created = (await call("createApiKeyFn", { name: "Robot", permissions: {}, })) as { id: number }; - await call("deleteApiKey", { keyId: created.id }); + await call("deleteApiKeyFn", { keyId: created.id }); expect(setResponseStatus).toHaveBeenCalledWith(204); expect(await db.select().from(apiKeys)).toHaveLength(0); @@ -164,7 +166,7 @@ describe("deleteApiKey", () => { it("responds with 404 when the key does not exist", async () => { await signIn(); - await expect(call("deleteApiKey", { keyId: 404 })).rejects.toThrow( + await expect(call("deleteApiKeyFn", { keyId: 404 })).rejects.toThrow( "API key not found.", ); expect(setResponseStatus).toHaveBeenCalledWith(404); diff --git a/apps/web/src/server/account/functions.ts b/apps/web/src/server/account/functions.ts index 31805fa68..376417e95 100644 --- a/apps/web/src/server/account/functions.ts +++ b/apps/web/src/server/account/functions.ts @@ -39,11 +39,11 @@ const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { throw err; }); -export const findApiKeys = createServerFn({ method: "GET" }) +export const findApiKeysFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .handler(async ({ context }) => findApiKeysImpl(db, context.session.userId)); -export const createApiKey = createServerFn({ method: "POST" }) +export const createApiKeyFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(createApiKeySchema) .handler(async ({ context, data }) => { @@ -55,7 +55,7 @@ export const createApiKey = createServerFn({ method: "POST" }) return { ...apiKey, key }; }); -export const updateApiKey = createServerFn({ method: "POST" }) +export const updateApiKeyFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(updateApiKeySchema) .handler(async ({ context, data }) => { @@ -71,7 +71,7 @@ export const updateApiKey = createServerFn({ method: "POST" }) } }); -export const deleteApiKey = createServerFn({ method: "POST" }) +export const deleteApiKeyFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(keyIdSchema) .handler(async ({ context, data }) => { diff --git a/apps/web/src/server/auth/exceptions.ts b/apps/web/src/server/auth/exceptions.ts index aa395be0e..c95cc3005 100644 --- a/apps/web/src/server/auth/exceptions.ts +++ b/apps/web/src/server/auth/exceptions.ts @@ -1,4 +1,4 @@ -import { getRoot } from "../root/functions"; +import { getRootFn } from "../root/functions"; import { getPasswordPolicyFn } from "../settings/functions"; import { createFirstUserFn, @@ -14,7 +14,7 @@ import { * createFirstUserFn runs before any user or session exists. * getPasswordPolicyFn serves the first-user and forced-reset forms, which set a * password before there is a session to authenticate. - * getRoot reports whether first-user setup is needed, read by the + * getRootFn reports whether first-user setup is needed, read by the * `_authenticated` guard before a session exists. */ // Annotated rather than inferred: an inferred type would reference TanStack's @@ -23,7 +23,7 @@ import { export const authenticationExceptions: ReadonlyArray<{ url: string }> = [ createFirstUserFn, getPasswordPolicyFn, - getRoot, + getRootFn, loginFn, logoutFn, resetPasswordFn, diff --git a/apps/web/src/server/auth/middleware.test.ts b/apps/web/src/server/auth/middleware.test.ts index 2078ef497..0790a0c96 100644 --- a/apps/web/src/server/auth/middleware.test.ts +++ b/apps/web/src/server/auth/middleware.test.ts @@ -56,7 +56,7 @@ const { createFirstUserFn, loginFn, logoutFn, resetPasswordFn } = await import( "./functions" ); const { getPasswordPolicyFn } = await import("../settings/functions"); -const { getRoot } = await import("../root/functions"); +const { getRootFn } = await import("../root/functions"); const { SESSION_ID_COOKIE, SESSION_TOKEN_COOKIE } = await import("./cookies"); const { basicAuthHeader, seedApiKey, seedSession, seedUser } = await import( "./test/fixtures" @@ -117,7 +117,7 @@ describe("authenticationExceptions", () => { [ createFirstUserFn, getPasswordPolicyFn, - getRoot, + getRootFn, loginFn, logoutFn, resetPasswordFn, @@ -132,7 +132,7 @@ describe("createAuthenticationMiddleware", () => { it.each([ ["createFirstUserFn", () => createFirstUserFn], ["getPasswordPolicyFn", () => getPasswordPolicyFn], - ["getRoot", () => getRoot], + ["getRootFn", () => getRootFn], ["loginFn", () => loginFn], ["logoutFn", () => logoutFn], ["resetPasswordFn", () => resetPasswordFn], diff --git a/apps/web/src/server/db/schema/analyses.ts b/apps/web/src/server/db/schema/analyses.ts index b8c5e8452..c43a25318 100644 --- a/apps/web/src/server/db/schema/analyses.ts +++ b/apps/web/src/server/db/schema/analyses.ts @@ -1,13 +1,16 @@ // Partial read-only mirror of the `analyses` table managed by the upstream -// Python service via Alembic. Only the columns needed to reconstruct a job's -// `args` are declared here — the analyses domain is not otherwise served from -// this side yet. Keep in sync with +// Python service via Alembic. Only the columns needed here are declared — to +// reconstruct a job's `args`, and to derive and filter samples by their +// workflow tags. Keep in sync with // `../../../../../../virtool/virtool/analyses/sql.py`. -import { bigint, integer, pgTable, text } from "drizzle-orm/pg-core"; +import { bigint, boolean, integer, pgTable, text } from "drizzle-orm/pg-core"; export const analyses = pgTable("analyses", { id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(), legacy_id: text("legacy_id").unique(), job_id: integer("job_id"), + sample_id: bigint("sample_id", { mode: "number" }), + workflow: text("workflow").notNull(), + ready: boolean("ready").notNull(), }); diff --git a/apps/web/src/server/db/schema/samples.ts b/apps/web/src/server/db/schema/samples.ts index d64f984dc..224da0cf2 100644 --- a/apps/web/src/server/db/schema/samples.ts +++ b/apps/web/src/server/db/schema/samples.ts @@ -1,19 +1,129 @@ -// Partial read-only mirror of the `legacy_samples` table and the -// `legacy_sample_subtractions` join table managed by the upstream Python service -// via Alembic. Only the columns needed from this side are declared. Keep in sync -// with `../../../../../../virtool/virtool/samples/sql.py`. +// Read-only mirror of the `legacy_samples` table and its join / file tables, +// managed by the upstream Python service via Alembic. Do not generate or push +// migrations from this side. Keep the columns in sync with +// `../../../../../../virtool/virtool/samples/sql.py`. -import { bigint, integer, pgTable, text } from "drizzle-orm/pg-core"; +import { + bigint, + boolean, + integer, + jsonb, + pgTable, + text, + timestamp, +} from "drizzle-orm/pg-core"; +/** A sample's FastQC quality report, stored as JSONB and never queried. */ +export type SampleQuality = { + bases: number[][]; + composition: number[][]; + count: number; + encoding: string; + gc: number; + length: number[]; + sequences: number[]; +}; + +// The columns Python's model gives a `default=` are mirrored with `$defaultFn`, +// never `.default()`: the real columns carry no server default, so the value has +// to be supplied on insert from this side too. `name`, `library_type`, and +// `created_at` have no Python default and stay required. export const legacySamples = pgTable("legacy_samples", { id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(), - name: text("name"), + legacy_id: text("legacy_id").unique(), + name: text("name").notNull(), + host: text("host") + .$defaultFn(() => "") + .notNull(), + isolate: text("isolate") + .$defaultFn(() => "") + .notNull(), + locale: text("locale") + .$defaultFn(() => "") + .notNull(), + notes: text("notes") + .$defaultFn(() => "") + .notNull(), + library_type: text("library_type").notNull(), + format: text("format") + .$defaultFn(() => "fastq") + .notNull(), + group_id: integer("group_id"), + quality: jsonb("quality").$type(), + created_at: timestamp("created_at").notNull(), + paired: boolean("paired") + .$defaultFn(() => false) + .notNull(), + ready: boolean("ready") + .$defaultFn(() => false) + .notNull(), + hold: boolean("hold") + .$defaultFn(() => true) + .notNull(), + is_legacy: boolean("is_legacy") + .$defaultFn(() => false) + .notNull(), + all_read: boolean("all_read") + .$defaultFn(() => false) + .notNull(), + all_write: boolean("all_write") + .$defaultFn(() => false) + .notNull(), + group_read: boolean("group_read") + .$defaultFn(() => false) + .notNull(), + group_write: boolean("group_write") + .$defaultFn(() => false) + .notNull(), + user_id: integer("user_id"), job_id: integer("job_id").unique(), }); +// Join table linking a sample to its labels. +export const legacySampleLabels = pgTable("legacy_sample_labels", { + sample_id: bigint("sample_id", { mode: "number" }).notNull(), + label_id: integer("label_id").notNull(), +}); + // Join table linking a sample to its default subtractions. Read from the // subtraction side to compute a subtraction's `linked_samples`. export const legacySampleSubtractions = pgTable("legacy_sample_subtractions", { sample_id: bigint("sample_id", { mode: "number" }).notNull(), subtraction_id: bigint("subtraction_id", { mode: "number" }).notNull(), }); + +// Artifacts produced during sample creation. +export const sampleArtifacts = pgTable("sample_artifacts", { + id: integer("id").primaryKey().generatedByDefaultAsIdentity(), + sample: text("sample").notNull(), + sample_id: bigint("sample_id", { mode: "number" }), + name: text("name").notNull(), + name_on_disk: text("name_on_disk"), + size: bigint("size", { mode: "number" }), + type: text("type").notNull(), + uploaded_at: timestamp("uploaded_at"), +}); + +// Reads files that make up a sample. +export const sampleReads = pgTable("sample_reads", { + id: integer("id").primaryKey().generatedByDefaultAsIdentity(), + sample: text("sample").notNull(), + sample_id: bigint("sample_id", { mode: "number" }), + name: text("name").notNull(), + name_on_disk: text("name_on_disk").notNull(), + size: bigint("size", { mode: "number" }), + upload: integer("upload"), + uploaded_at: timestamp("uploaded_at"), +}); + +// Join table linking a sample to its input uploads, ordered by `index`. +export const sampleUploads = pgTable("sample_uploads", { + id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(), + sample: text("sample").notNull(), + sample_id: bigint("sample_id", { mode: "number" }), + upload_id: integer("upload_id").notNull(), + index: integer("index").notNull(), +}); + +/** A row from the `legacy_samples` table. */ +export type LegacySampleRow = typeof legacySamples.$inferSelect; diff --git a/apps/web/src/server/groups/functions.test.ts b/apps/web/src/server/groups/functions.test.ts index f0f97d466..0b6c95b17 100644 --- a/apps/web/src/server/groups/functions.test.ts +++ b/apps/web/src/server/groups/functions.test.ts @@ -110,7 +110,7 @@ describe("createGroup", () => { await signIn(null); await expect( - call("createGroup", { name: "hackers" }), + call("createGroupFn", { name: "hackers" }), ).rejects.toBeInstanceOf(ForbiddenError); expect(setResponseStatus).toHaveBeenCalledWith(403); expect(await db.select().from(groups)).toHaveLength(0); @@ -118,7 +118,7 @@ describe("createGroup", () => { it("refuses an unauthenticated caller", async () => { await expect( - call("createGroup", { name: "hackers" }), + call("createGroupFn", { name: "hackers" }), ).rejects.toBeInstanceOf(UnauthorizedError); expect(setResponseStatus).toHaveBeenCalledWith(401); }); @@ -126,7 +126,7 @@ describe("createGroup", () => { it("allows an administrator", async () => { await signIn("base"); - const group = (await call("createGroup", { name: "technicians" })) as { + const group = (await call("createGroupFn", { name: "technicians" })) as { name: string; }; @@ -140,7 +140,7 @@ describe("updateGroup", () => { await signIn(null); await expect( - call("updateGroup", { groupId, name: "renamed" }), + call("updateGroupFn", { groupId, name: "renamed" }), ).rejects.toBeInstanceOf(ForbiddenError); expect(setResponseStatus).toHaveBeenCalledWith(403); }); @@ -153,7 +153,7 @@ describe("updateGroup", () => { await signIn(null); await expect( - call("updateGroup", { + call("updateGroupFn", { groupId, permissions: { create_ref: true, upload_file: true }, }), @@ -169,7 +169,7 @@ describe("updateGroup", () => { const groupId = await seedGroup(); await signIn("base"); - const group = (await call("updateGroup", { + const group = (await call("updateGroupFn", { groupId, permissions: { create_ref: true }, })) as { permissions: Permissions }; @@ -183,7 +183,7 @@ describe("deleteGroup", () => { const groupId = await seedGroup(); await signIn(null); - await expect(call("deleteGroup", { groupId })).rejects.toBeInstanceOf( + await expect(call("deleteGroupFn", { groupId })).rejects.toBeInstanceOf( ForbiddenError, ); expect(setResponseStatus).toHaveBeenCalledWith(403); @@ -194,7 +194,7 @@ describe("deleteGroup", () => { const groupId = await seedGroup(); await signIn("base"); - await call("deleteGroup", { groupId }); + await call("deleteGroupFn", { groupId }); expect(await db.select().from(groups)).toHaveLength(0); }); diff --git a/apps/web/src/server/groups/functions.ts b/apps/web/src/server/groups/functions.ts index 071f14c64..61c4e8621 100644 --- a/apps/web/src/server/groups/functions.ts +++ b/apps/web/src/server/groups/functions.ts @@ -56,18 +56,18 @@ const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { // Ordinary users need the group list to set sample rights and to pick a primary // group, so the reads are open to any signed-in user. -export const listGroups = createServerFn({ method: "GET" }) +export const listGroupsFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .handler(async () => listGroupsImpl(db)); -export const findGroups = createServerFn({ method: "GET" }) +export const findGroupsFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(findGroupsSchema) .handler(async ({ data }) => findGroupsImpl(db, data?.term ?? "", data?.page ?? 1, data?.per_page ?? 25), ); -export const getGroup = createServerFn({ method: "GET" }) +export const getGroupFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(groupIdSchema) .handler(async ({ data }) => { @@ -81,7 +81,7 @@ export const getGroup = createServerFn({ method: "GET" }) // A group's permissions are unioned into every member's, so anyone who can // write a group can grant themselves any permission. All three mutations are // administrator-only, as they were in the Python service they replaced. -export const createGroup = createServerFn({ method: "POST" }) +export const createGroupFn = createServerFn({ method: "POST" }) .middleware([adminRole("base")]) .validator(createGroupSchema) .handler(async ({ data }) => { @@ -94,7 +94,7 @@ export const createGroup = createServerFn({ method: "POST" }) } }); -export const updateGroup = createServerFn({ method: "POST" }) +export const updateGroupFn = createServerFn({ method: "POST" }) .middleware([adminRole("base")]) .validator(updateGroupSchema) .handler(async ({ data }) => { @@ -106,7 +106,7 @@ export const updateGroup = createServerFn({ method: "POST" }) } }); -export const deleteGroup = createServerFn({ method: "POST" }) +export const deleteGroupFn = createServerFn({ method: "POST" }) .middleware([adminRole("base")]) .validator(groupIdSchema) .handler(async ({ data }) => { diff --git a/apps/web/src/server/hmm/functions.test.ts b/apps/web/src/server/hmm/functions.test.ts index 40c749813..9165e0e03 100644 --- a/apps/web/src/server/hmm/functions.test.ts +++ b/apps/web/src/server/hmm/functions.test.ts @@ -145,7 +145,7 @@ describe("installHmm", () => { await seedStatus(); mockManifest([RELEASE]); - const installed = (await call("installHmm")) as { + const installed = (await call("installHmmFn")) as { name: string; ready: boolean; user: { id: number; handle: string }; @@ -187,7 +187,7 @@ describe("installHmm", () => { await seedStatus(); mockManifest([RELEASE]); - const installed = (await call("installHmm")) as { + const installed = (await call("installHmmFn")) as { user: { id: number }; }; @@ -200,7 +200,7 @@ describe("installHmm", () => { await seedStatus({ updates: [{ ready: false } as HmmUpdate] }); const fetchSpy = vi.spyOn(globalThis, "fetch"); - await expect(call("installHmm")).rejects.toThrow(); + await expect(call("installHmmFn")).rejects.toThrow(); expect(setResponseStatus).toHaveBeenCalledWith(409); expect(fetchSpy).not.toHaveBeenCalled(); expect(await db.select().from(tasks)).toHaveLength(0); @@ -211,7 +211,7 @@ describe("installHmm", () => { await seedStatus(); mockManifest([]); - await expect(call("installHmm")).rejects.toThrow(); + await expect(call("installHmmFn")).rejects.toThrow(); expect(setResponseStatus).toHaveBeenCalledWith(502); expect(await db.select().from(tasks)).toHaveLength(0); }); @@ -221,7 +221,7 @@ describe("getHmm", () => { it("responds 404 when the HMM is absent", async () => { await signIn("base"); - await expect(call("getHmm", { hmmId: 5000 })).rejects.toThrow(); + await expect(call("getHmmFn", { hmmId: 5000 })).rejects.toThrow(); expect(setResponseStatus).toHaveBeenCalledWith(404); }); }); diff --git a/apps/web/src/server/hmm/functions.ts b/apps/web/src/server/hmm/functions.ts index 26047bd2a..6492ee8d5 100644 --- a/apps/web/src/server/hmm/functions.ts +++ b/apps/web/src/server/hmm/functions.ts @@ -43,12 +43,12 @@ const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { throw err; }); -export const findHmms = createServerFn({ method: "GET" }) +export const findHmmsFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(findHmmsSchema) .handler(async ({ data }) => findHmmsImpl(db, data)); -export const getHmm = createServerFn({ method: "GET" }) +export const getHmmFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(hmmIdSchema) .handler(async ({ data }) => { @@ -59,7 +59,7 @@ export const getHmm = createServerFn({ method: "GET" }) } }); -export const installHmm = createServerFn({ method: "POST" }) +export const installHmmFn = createServerFn({ method: "POST" }) .middleware([permission("modify_hmm")]) .handler(async ({ context }) => { try { diff --git a/apps/web/src/server/jobs/data.test.ts b/apps/web/src/server/jobs/data.test.ts index f8b04431a..0e9b1b234 100644 --- a/apps/web/src/server/jobs/data.test.ts +++ b/apps/web/src/server/jobs/data.test.ts @@ -109,7 +109,7 @@ describe("getJob", () => { const [analysis] = await db .insert(analyses) - .values({ job_id: jobId }) + .values({ job_id: jobId, workflow: "nuvs", ready: false }) .returning({ id: analyses.id }); if (!analysis) { throw new Error("failed to seed analysis"); @@ -128,7 +128,12 @@ describe("getJob", () => { const [sample] = await db .insert(legacySamples) - .values({ job_id: jobId }) + .values({ + job_id: jobId, + name: "Sample A", + library_type: "normal", + created_at: new Date(), + }) .returning({ id: legacySamples.id }); if (!sample) { throw new Error("failed to seed sample"); diff --git a/apps/web/src/server/jobs/data.ts b/apps/web/src/server/jobs/data.ts index db64d2e1f..422ba9d24 100644 --- a/apps/web/src/server/jobs/data.ts +++ b/apps/web/src/server/jobs/data.ts @@ -1,5 +1,5 @@ import { count, desc, eq, inArray } from "drizzle-orm"; -import type { Db } from "../db/pg"; +import type { Db, DbOrTx } from "../db/pg"; import { takeFirstOrThrow } from "../db/rows"; import { analyses } from "../db/schema/analyses"; import { indexes } from "../db/schema/indexes"; @@ -271,3 +271,37 @@ export async function getJob(db: Db, jobId: number): Promise { return job; } + +/** + * Create a new job in the `pending` state and return its id. + * + * A pending job in Postgres is claimable by any workflow runner, so this is all + * that is needed to schedule work — no key, claim, or steps are set here; the + * runner writes those when it claims the job. A job's arguments are not stored: + * they are recomposed on read from the owning resource's reverse `job_id` + * foreign key, so the caller must create the owning row (e.g. the sample) in the + * same transaction, before the job becomes visible to a runner. + * + * Takes `DbOrTx` so it can participate in the caller's transaction; the caller + * commits. + */ +export async function createJob( + db: DbOrTx, + workflow: string, + userId: number, +): Promise { + const row = takeFirstOrThrow( + await db + .insert(jobs) + .values({ + acquired: false, + created_at: new Date(), + state: "pending", + user_id: userId, + workflow, + }) + .returning({ id: jobs.id }), + ); + + return row.id; +} diff --git a/apps/web/src/server/jobs/functions.ts b/apps/web/src/server/jobs/functions.ts index 7436c48f9..597a4db28 100644 --- a/apps/web/src/server/jobs/functions.ts +++ b/apps/web/src/server/jobs/functions.ts @@ -44,17 +44,17 @@ const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { throw err; }); -export const findJobs = createServerFn({ method: "GET" }) +export const findJobsFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(findJobsSchema) .handler(async ({ data }) => findJobsImpl(db, data)); -export const getJobs = createServerFn({ method: "GET" }) +export const getJobsFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(jobIdsSchema) .handler(async ({ data }) => getJobsImpl(db, data.jobIds)); -export const getJob = createServerFn({ method: "GET" }) +export const getJobFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(jobIdSchema) .handler(async ({ data }) => { diff --git a/apps/web/src/server/labels/functions.ts b/apps/web/src/server/labels/functions.ts index 2da6bb7c8..8ddcf4d4b 100644 --- a/apps/web/src/server/labels/functions.ts +++ b/apps/web/src/server/labels/functions.ts @@ -58,12 +58,12 @@ const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { throw err; }); -export const findLabels = createServerFn({ method: "GET" }) +export const findLabelsFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(findLabelsSchema) .handler(async ({ data }) => findLabelsImpl(db, data?.term ?? "")); -export const getLabel = createServerFn({ method: "GET" }) +export const getLabelFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(labelIdSchema) .handler(async ({ data }) => { @@ -74,7 +74,7 @@ export const getLabel = createServerFn({ method: "GET" }) } }); -export const createLabel = createServerFn({ method: "POST" }) +export const createLabelFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(labelValuesSchema) .handler(async ({ data }) => { @@ -87,7 +87,7 @@ export const createLabel = createServerFn({ method: "POST" }) } }); -export const updateLabel = createServerFn({ method: "POST" }) +export const updateLabelFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(labelIdSchema.extend(labelValuesSchema.partial().shape)) .handler(async ({ data }) => { @@ -99,7 +99,7 @@ export const updateLabel = createServerFn({ method: "POST" }) } }); -export const deleteLabel = createServerFn({ method: "POST" }) +export const deleteLabelFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(labelIdSchema) .handler(async ({ data }) => { diff --git a/apps/web/src/server/messages/functions.ts b/apps/web/src/server/messages/functions.ts index dc8387dbd..e1a2d10a2 100644 --- a/apps/web/src/server/messages/functions.ts +++ b/apps/web/src/server/messages/functions.ts @@ -45,15 +45,15 @@ const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { throw err; }); -export const findMessage = createServerFn({ method: "GET" }) +export const findMessageFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .handler(async () => findMessageImpl(db)); -export const findMessages = createServerFn({ method: "GET" }) +export const findMessagesFn = createServerFn({ method: "GET" }) .middleware([adminRole("settings")]) .handler(async () => findMessagesImpl(db)); -export const createMessage = createServerFn({ method: "POST" }) +export const createMessageFn = createServerFn({ method: "POST" }) .middleware([adminRole("settings")]) .validator(createMessageSchema) .handler(async ({ context, data }) => { @@ -67,7 +67,7 @@ export const createMessage = createServerFn({ method: "POST" }) return message; }); -export const updateMessage = createServerFn({ method: "POST" }) +export const updateMessageFn = createServerFn({ method: "POST" }) .middleware([adminRole("settings")]) .validator(updateMessageSchema) .handler(async ({ context, data }) => { @@ -83,7 +83,7 @@ export const updateMessage = createServerFn({ method: "POST" }) } }); -export const deleteMessage = createServerFn({ method: "POST" }) +export const deleteMessageFn = createServerFn({ method: "POST" }) .middleware([adminRole("settings")]) .validator(idSchema) .handler(async ({ data }) => { @@ -96,7 +96,7 @@ export const deleteMessage = createServerFn({ method: "POST" }) } }); -export const setActiveMessage = createServerFn({ method: "POST" }) +export const setActiveMessageFn = createServerFn({ method: "POST" }) .middleware([adminRole("settings")]) .validator(idSchema) .handler(async ({ data }) => { @@ -107,7 +107,7 @@ export const setActiveMessage = createServerFn({ method: "POST" }) } }); -export const clearActiveMessage = createServerFn({ method: "POST" }) +export const clearActiveMessageFn = createServerFn({ method: "POST" }) .middleware([adminRole("settings")]) .handler(async () => { await clearActiveMessageImpl(db); diff --git a/apps/web/src/server/references/functions.test.ts b/apps/web/src/server/references/functions.test.ts index 6424e0f96..fbbd81ac5 100644 --- a/apps/web/src/server/references/functions.test.ts +++ b/apps/web/src/server/references/functions.test.ts @@ -159,7 +159,7 @@ describe("findReferences", () => { await seedReference(ownerId); await seedReference(otherId); - const result = (await call("findReferences", { + const result = (await call("findReferencesFn", { page: 1, per_page: 25, })) as { @@ -181,7 +181,7 @@ describe("findReferences", () => { await seedReference(adminId); await seedReference(otherId); - const result = (await call("findReferences", { + const result = (await call("findReferencesFn", { page: 1, per_page: 25, })) as { @@ -197,7 +197,7 @@ describe("getReference", () => { await signIn(null); await expect( - call("getReference", { referenceId: 999_999 }), + call("getReferenceFn", { referenceId: 999_999 }), ).rejects.toThrow("Reference not found."); expect(setResponseStatus).toHaveBeenCalledWith(404); }); @@ -206,7 +206,7 @@ describe("getReference", () => { const ownerId = await signIn(null); const referenceId = await seedReference(ownerId); - const reference = (await call("getReference", { referenceId })) as { + const reference = (await call("getReferenceFn", { referenceId })) as { users: { id: number; modifyOtu: boolean }[]; sourceTypes: string[]; }; @@ -224,7 +224,7 @@ describe("getReference", () => { await signIn(null); const referenceId = await seedReference(ownerId); - await expect(call("getReference", { referenceId })).rejects.toThrow( + await expect(call("getReferenceFn", { referenceId })).rejects.toThrow( "Reference not found.", ); expect(setResponseStatus).toHaveBeenCalledWith(404); @@ -238,7 +238,7 @@ describe("getReference", () => { await signIn("full"); const referenceId = await seedReference(ownerId); - const reference = (await call("getReference", { referenceId })) as { + const reference = (await call("getReferenceFn", { referenceId })) as { id: number; }; @@ -268,7 +268,7 @@ describe("getReference", () => { modify_otu: false, }); - const reference = (await call("getReference", { referenceId })) as { + const reference = (await call("getReferenceFn", { referenceId })) as { id: number; }; @@ -281,14 +281,14 @@ describe("createReference", () => { await signIn(null); await expect( - call("createReference", { name: "New", description: "", organism: "" }), + call("createReferenceFn", { name: "New", description: "", organism: "" }), ).rejects.toBeInstanceOf(ForbiddenError); }); it("creates an empty reference and seeds the creator with all rights", async () => { const userId = await signIn("full"); - const reference = (await call("createReference", { + const reference = (await call("createReferenceFn", { name: "Empty", description: "desc", organism: "virus", @@ -304,7 +304,7 @@ describe("createReference", () => { const userId = await signIn("full"); const sourceId = await seedReference(userId); - const reference = (await call("createReference", { + const reference = (await call("createReferenceFn", { name: "", description: "", organism: "", @@ -322,7 +322,7 @@ describe("createReference", () => { await signIn("full"); await expect( - call("createReference", { + call("createReferenceFn", { name: "Imported", description: "", organism: "", @@ -336,7 +336,7 @@ describe("createReference", () => { const userId = await signIn("full"); const uploadId = await seedUpload(userId); - const reference = (await call("createReference", { + const reference = (await call("createReferenceFn", { name: "Imported", description: "", organism: "", @@ -358,7 +358,7 @@ describe("updateReference", () => { const referenceId = await seedReference(ownerId); await expect( - call("updateReference", { referenceId, name: "Renamed" }), + call("updateReferenceFn", { referenceId, name: "Renamed" }), ).rejects.toBeInstanceOf(ForbiddenError); }); @@ -366,7 +366,7 @@ describe("updateReference", () => { const ownerId = await signIn(null); const referenceId = await seedReference(ownerId); - const reference = (await call("updateReference", { + const reference = (await call("updateReferenceFn", { referenceId, name: "Renamed", restrictSourceTypes: true, @@ -381,7 +381,7 @@ describe("updateReference", () => { const referenceId = await seedReference(ownerId, { archived: true }); await expect( - call("updateReference", { referenceId, name: "Renamed" }), + call("updateReferenceFn", { referenceId, name: "Renamed" }), ).rejects.toThrow("Reference is archived."); expect(setResponseStatus).toHaveBeenCalledWith(409); }); @@ -392,7 +392,7 @@ describe("archiveReference", () => { const ownerId = await signIn(null); const referenceId = await seedReference(ownerId); - const reference = (await call("archiveReference", { referenceId })) as { + const reference = (await call("archiveReferenceFn", { referenceId })) as { archived: boolean; }; @@ -411,7 +411,7 @@ describe("reference membership", () => { .returning({ id: groups.id }), ); - const member = (await call("addReferenceGroup", { + const member = (await call("addReferenceGroupFn", { referenceId, groupId: group.id, modify: true, @@ -437,7 +437,7 @@ describe("reference membership", () => { ); await expect( - call("addReferenceGroup", { referenceId, groupId: group.id }), + call("addReferenceGroupFn", { referenceId, groupId: group.id }), ).rejects.toBeInstanceOf(ForbiddenError); }); @@ -445,7 +445,7 @@ describe("reference membership", () => { const ownerId = await signIn(null); const referenceId = await seedReference(ownerId); - const member = (await call("updateReferenceUser", { + const member = (await call("updateReferenceUserFn", { referenceId, userId: ownerId, modify: false, @@ -462,7 +462,7 @@ describe("reference membership", () => { const referenceId = await seedReference(ownerId); await expect( - call("removeReferenceUser", { referenceId, userId: 999_999 }), + call("removeReferenceUserFn", { referenceId, userId: 999_999 }), ).rejects.toThrow("Member not found."); expect(setResponseStatus).toHaveBeenCalledWith(404); }); diff --git a/apps/web/src/server/references/functions.ts b/apps/web/src/server/references/functions.ts index a0607e4ea..64ecc80bb 100644 --- a/apps/web/src/server/references/functions.ts +++ b/apps/web/src/server/references/functions.ts @@ -126,7 +126,7 @@ const authorizeReference = createServerOnlyFn( }, ); -export const findReferences = createServerFn({ method: "GET" }) +export const findReferencesFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(findReferencesSchema) .handler(async ({ context, data }) => { @@ -144,7 +144,7 @@ export const findReferences = createServerFn({ method: "GET" }) ); }); -export const getReference = createServerFn({ method: "GET" }) +export const getReferenceFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(referenceIdSchema) .handler(async ({ context, data }) => { @@ -162,7 +162,7 @@ export const getReference = createServerFn({ method: "GET" }) } }); -export const createReference = createServerFn({ method: "POST" }) +export const createReferenceFn = createServerFn({ method: "POST" }) .middleware([permission("create_ref")]) .validator(createReferenceSchema) .handler(async ({ context, data }) => { @@ -182,7 +182,7 @@ export const createReference = createServerFn({ method: "POST" }) } }); -export const updateReference = createServerFn({ method: "POST" }) +export const updateReferenceFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(updateReferenceSchema) .handler(async ({ context, data }) => { @@ -195,7 +195,7 @@ export const updateReference = createServerFn({ method: "POST" }) } }); -export const archiveReference = createServerFn({ method: "POST" }) +export const archiveReferenceFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(referenceIdSchema) .handler(async ({ context, data }) => { @@ -211,7 +211,7 @@ export const archiveReference = createServerFn({ method: "POST" }) } }); -export const unarchiveReference = createServerFn({ method: "POST" }) +export const unarchiveReferenceFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(referenceIdSchema) .handler(async ({ context, data }) => { @@ -227,7 +227,7 @@ export const unarchiveReference = createServerFn({ method: "POST" }) } }); -export const addReferenceUser = createServerFn({ method: "POST" }) +export const addReferenceUserFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(addReferenceUserSchema) .handler(async ({ context, data }) => { @@ -247,7 +247,7 @@ export const addReferenceUser = createServerFn({ method: "POST" }) } }); -export const addReferenceGroup = createServerFn({ method: "POST" }) +export const addReferenceGroupFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(addReferenceGroupSchema) .handler(async ({ context, data }) => { @@ -269,7 +269,7 @@ export const addReferenceGroup = createServerFn({ method: "POST" }) } }); -export const updateReferenceUser = createServerFn({ method: "POST" }) +export const updateReferenceUserFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(updateReferenceUserSchema) .handler(async ({ context, data }) => { @@ -282,7 +282,7 @@ export const updateReferenceUser = createServerFn({ method: "POST" }) } }); -export const updateReferenceGroup = createServerFn({ method: "POST" }) +export const updateReferenceGroupFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(updateReferenceGroupSchema) .handler(async ({ context, data }) => { @@ -295,7 +295,7 @@ export const updateReferenceGroup = createServerFn({ method: "POST" }) } }); -export const removeReferenceUser = createServerFn({ method: "POST" }) +export const removeReferenceUserFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(referenceUserSchema) .handler(async ({ context, data }) => { @@ -313,7 +313,7 @@ export const removeReferenceUser = createServerFn({ method: "POST" }) } }); -export const removeReferenceGroup = createServerFn({ method: "POST" }) +export const removeReferenceGroupFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(referenceGroupSchema) .handler(async ({ context, data }) => { diff --git a/apps/web/src/server/root/functions.test.ts b/apps/web/src/server/root/functions.test.ts index d50b2571c..4a7a8aa9b 100644 --- a/apps/web/src/server/root/functions.test.ts +++ b/apps/web/src/server/root/functions.test.ts @@ -62,7 +62,7 @@ beforeEach(() => { describe("getRoot", () => { it("reports firstUser when the instance has no users", async () => { - const root = (await callServerFn(handlers, "getRoot", undefined)) as { + const root = (await callServerFn(handlers, "getRootFn", undefined)) as { firstUser: boolean; version: string; }; @@ -74,7 +74,7 @@ describe("getRoot", () => { it("reports no firstUser once a user exists", async () => { await seedUser(db); - const root = (await callServerFn(handlers, "getRoot", undefined)) as { + const root = (await callServerFn(handlers, "getRootFn", undefined)) as { firstUser: boolean; }; diff --git a/apps/web/src/server/root/functions.ts b/apps/web/src/server/root/functions.ts index 445d273cd..5573e6af8 100644 --- a/apps/web/src/server/root/functions.ts +++ b/apps/web/src/server/root/functions.ts @@ -7,7 +7,7 @@ import { getUserCount } from "../users/data"; // before any session exists to decide whether to redirect to first-user setup, // so this cannot require a session. `version` is the running deployment's build // version, injected by Vite's `define` (see appVersion.d.ts). -export const getRoot = createServerFn({ method: "GET" }) +export const getRootFn = createServerFn({ method: "GET" }) .middleware([open()]) .handler(async () => ({ firstUser: (await getUserCount(db)) === 0, diff --git a/apps/web/src/server/samples/data.test.ts b/apps/web/src/server/samples/data.test.ts new file mode 100644 index 000000000..5e9e7fa77 --- /dev/null +++ b/apps/web/src/server/samples/data.test.ts @@ -0,0 +1,769 @@ +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { seedUser } from "../auth/test/fixtures"; +import type { Db } from "../db/pg"; +import { takeFirstOrThrow } from "../db/rows"; +import { analyses } from "../db/schema/analyses"; +import { groups, userGroups } from "../db/schema/groups"; +import { jobs } from "../db/schema/jobs"; +import { labels } from "../db/schema/labels"; +import { + legacySampleLabels, + legacySampleSubtractions, + legacySamples, + sampleArtifacts, + sampleReads, + sampleUploads, +} from "../db/schema/samples"; +import { subtractions } from "../db/schema/subtractions"; +import { uploads } from "../db/schema/uploads"; +import { users } from "../db/schema/users"; +import { createTestDatabase, type TestDatabase } from "../db/test/fixtures"; +import { addToGroup, seedGroup } from "../groups/test/fixtures"; +import { MemoryStorage } from "../storage"; +import { UploadNotFoundError } from "../uploads/data"; +import { + type CreateSampleValues, + checkSampleRight, + createSample, + deleteSample, + findSamples, + getSample, + hasSampleRight, + resolveSampleActor, + type SampleActor, + SampleFileDuplicateError, + SampleGroupNotFoundError, + SampleLabelsNotFoundError, + SampleNameConflictError, + SampleNotFoundError, + SampleOwnerlessError, + sampleReadableFilter, + updateSample, + updateSampleRights, +} from "./data"; + +let database: TestDatabase; +let db: Db; +let ownerId: number; + +beforeAll(async () => { + database = await createTestDatabase(); + db = database.db; +}, 60_000); + +afterAll(async () => { + await database.drop(); +}); + +beforeEach(async () => { + await db.delete(analyses); + await db.delete(legacySampleLabels); + await db.delete(legacySampleSubtractions); + await db.delete(sampleUploads); + await db.delete(sampleArtifacts); + await db.delete(sampleReads); + await db.delete(legacySamples); + await db.delete(jobs); + await db.delete(labels); + await db.delete(subtractions); + await db.delete(uploads); + await db.delete(userGroups); + await db.delete(groups); + await db.delete(users); + + ownerId = await seedUser(db, { handle: "owner" }); +}); + +async function seedSample( + overrides: Partial = {}, +): Promise { + return takeFirstOrThrow( + await db + .insert(legacySamples) + .values({ + name: "Sample", + library_type: "normal", + created_at: new Date(), + user_id: ownerId, + ...overrides, + }) + .returning({ id: legacySamples.id }), + ).id; +} + +async function seedLabel(name: string): Promise { + return takeFirstOrThrow( + await db + .insert(labels) + .values({ name, color: "#000000", description: "" }) + .returning({ id: labels.id }), + ).id; +} + +async function seedSubtraction(name: string): Promise { + return takeFirstOrThrow( + await db + .insert(subtractions) + .values({ name }) + .returning({ id: subtractions.id }), + ).id; +} + +async function seedUpload(reserved = false): Promise { + return takeFirstOrThrow( + await db + .insert(uploads) + .values({ + createdAt: new Date(), + name: "reads.fq.gz", + nameOnDisk: `disk-${Math.random()}`, + type: "reads", + ready: true, + reserved, + userId: ownerId, + }) + .returning({ id: uploads.id }), + ).id; +} + +const adminActor: SampleActor = { userId: 999, groupIds: [], isAdmin: true }; + +describe("hasSampleRight", () => { + const row = { + all_read: false, + all_write: false, + group_read: false, + group_write: false, + group_id: 5, + user_id: 1, + }; + + it("grants an administrator every right", () => { + const actor: SampleActor = { userId: 2, groupIds: [], isAdmin: true }; + expect(hasSampleRight(row, actor, "read")).toBe(true); + expect(hasSampleRight(row, actor, "write")).toBe(true); + }); + + it("grants the owner every right", () => { + const actor: SampleActor = { userId: 1, groupIds: [], isAdmin: false }; + expect(hasSampleRight(row, actor, "read")).toBe(true); + expect(hasSampleRight(row, actor, "write")).toBe(true); + }); + + it("grants everyone read when all_read is set", () => { + const actor: SampleActor = { userId: 2, groupIds: [], isAdmin: false }; + expect(hasSampleRight({ ...row, all_read: true }, actor, "read")).toBe( + true, + ); + expect(hasSampleRight({ ...row, all_read: true }, actor, "write")).toBe( + false, + ); + }); + + it("grants a group member read only through group_read", () => { + const member: SampleActor = { userId: 2, groupIds: [5], isAdmin: false }; + const outsider: SampleActor = { userId: 2, groupIds: [6], isAdmin: false }; + const groupReadable = { ...row, group_read: true }; + expect(hasSampleRight(groupReadable, member, "read")).toBe(true); + expect(hasSampleRight(groupReadable, outsider, "read")).toBe(false); + }); + + it("denies a non-owner, non-member with no world rights", () => { + const actor: SampleActor = { userId: 2, groupIds: [], isAdmin: false }; + expect(hasSampleRight(row, actor, "read")).toBe(false); + }); +}); + +describe("checkSampleRight", () => { + it("allows a lookup for a nonexistent sample so the fetch 404s", async () => { + const actor = await resolveSampleActor(db, ownerId); + expect(await checkSampleRight(db, 123456, actor, "read")).toBe(true); + }); + + it("denies a non-owner without world or group rights", async () => { + const sampleId = await seedSample({ all_read: false }); + const other = await seedUser(db, { handle: "other" }); + const actor = await resolveSampleActor(db, other); + expect(await checkSampleRight(db, sampleId, actor, "read")).toBe(false); + }); +}); + +describe("findSamples", () => { + const options = { + page: 1, + perPage: 25, + term: "", + labels: [], + users: [], + workflows: [], + }; + + it("scopes a non-admin to their own, world-readable, and group-readable samples", async () => { + const groupId = await seedGroup(db, { name: "techs" }); + const other = await seedUser(db, { handle: "other" }); + await addToGroup(db, other, groupId); + + const own = await seedSample({ user_id: other, name: "Own" }); + const world = await seedSample({ + user_id: ownerId, + all_read: true, + name: "World", + }); + const groupReadable = await seedSample({ + user_id: ownerId, + group_id: groupId, + group_read: true, + name: "Group", + }); + await seedSample({ user_id: ownerId, name: "Hidden" }); + + const actor = await resolveSampleActor(db, other); + const result = await findSamples(db, options, actor); + + expect(new Set(result.items.map((s) => s.id))).toEqual( + new Set([own, world, groupReadable]), + ); + }); + + it("returns every sample for an administrator", async () => { + await seedSample({ name: "A" }); + await seedSample({ name: "B" }); + + const result = await findSamples(db, options, adminActor); + + expect(result.items).toHaveLength(2); + }); + + it("reports the unscoped total but the scoped found count", async () => { + await seedSample({ user_id: ownerId, name: "Mine" }); + const stranger = await seedUser(db, { handle: "stranger" }); + await seedSample({ user_id: stranger, name: "Theirs" }); + + const actor = await resolveSampleActor(db, ownerId); + const result = await findSamples(db, options, actor); + + expect(result.totalCount).toBe(2); + expect(result.foundCount).toBe(1); + }); + + it("filters by a case-insensitive name substring", async () => { + await seedSample({ name: "Apple" }); + await seedSample({ name: "Banana" }); + + const result = await findSamples( + db, + { ...options, term: "app" }, + adminActor, + ); + + expect(result.items.map((s) => s.name)).toEqual(["Apple"]); + }); + + it("filters by owner", async () => { + const other = await seedUser(db, { handle: "other" }); + await seedSample({ user_id: ownerId, name: "Mine" }); + await seedSample({ user_id: other, name: "Theirs" }); + + const result = await findSamples( + db, + { ...options, users: [other] }, + adminActor, + ); + + expect(result.items.map((s) => s.name)).toEqual(["Theirs"]); + }); + + it("filters by label", async () => { + const labelId = await seedLabel("important"); + const labelled = await seedSample({ name: "Labelled" }); + await seedSample({ name: "Plain" }); + await db + .insert(legacySampleLabels) + .values({ sample_id: labelled, label_id: labelId }); + + const result = await findSamples( + db, + { ...options, labels: [labelId] }, + adminActor, + ); + + expect(result.items.map((s) => s.id)).toEqual([labelled]); + }); +}); + +describe("workflow tags and filtering", () => { + it("derives tags from a sample's analyses", async () => { + const readyNuvs = await seedSample({ name: "Ready" }); + await db + .insert(analyses) + .values({ sample_id: readyNuvs, workflow: "nuvs", ready: true }); + + const pendingPatho = await seedSample({ name: "Pending" }); + await db.insert(analyses).values({ + sample_id: pendingPatho, + workflow: "pathoscope", + ready: false, + }); + + const noneSample = await seedSample({ name: "None" }); + + const result = await findSamples( + db, + { page: 1, perPage: 25, term: "", labels: [], users: [], workflows: [] }, + adminActor, + ); + const byId = new Map(result.items.map((s) => [s.id, s])); + + expect(byId.get(readyNuvs)?.nuvs).toBe(true); + expect(byId.get(readyNuvs)?.workflows.nuvs).toBe("complete"); + expect(byId.get(pendingPatho)?.pathoscope).toBe("ip"); + expect(byId.get(pendingPatho)?.workflows.pathoscope).toBe("pending"); + expect(byId.get(noneSample)?.nuvs).toBe(false); + expect(byId.get(noneSample)?.workflows.nuvs).toBe("none"); + }); + + it("filters by a workflow:condition pair", async () => { + const ready = await seedSample({ name: "Ready" }); + await db + .insert(analyses) + .values({ sample_id: ready, workflow: "nuvs", ready: true }); + await seedSample({ name: "NoAnalyses" }); + + const result = await findSamples( + db, + { + page: 1, + perPage: 25, + term: "", + labels: [], + users: [], + workflows: ["nuvs:ready"], + }, + adminActor, + ); + + expect(result.items.map((s) => s.id)).toEqual([ready]); + }); + + it("drops unrecognized workflow filters rather than matching everything", async () => { + await seedSample({ name: "A" }); + await seedSample({ name: "B" }); + + const result = await findSamples( + db, + { + page: 1, + perPage: 25, + term: "", + labels: [], + users: [], + workflows: ["bogus:none"], + }, + adminActor, + ); + + expect(result.items).toHaveLength(2); + }); +}); + +describe("getSample", () => { + it("returns the full sample shape", async () => { + const groupId = await seedGroup(db, { name: "g" }); + const labelId = await seedLabel("lab"); + const subtractionId = await seedSubtraction("sub"); + const sampleId = await seedSample({ + name: "Full", + group_id: groupId, + quality: { count: 1 } as never, + }); + + await db + .insert(legacySampleLabels) + .values({ sample_id: sampleId, label_id: labelId }); + await db + .insert(legacySampleSubtractions) + .values({ sample_id: sampleId, subtraction_id: subtractionId }); + await db.insert(sampleReads).values([ + { + sample: String(sampleId), + sample_id: sampleId, + name: "reads_1.fq.gz", + name_on_disk: "reads_1.fq.gz", + }, + { + sample: String(sampleId), + sample_id: sampleId, + name: "reads_2.fq.gz", + name_on_disk: "reads_2.fq.gz", + }, + ]); + + const sample = await getSample(db, sampleId); + + expect(sample.labels.map((l) => l.id)).toEqual([labelId]); + expect(sample.subtractions.map((s) => s.id)).toEqual([subtractionId]); + expect(sample.group?.id).toBe(groupId); + expect(sample.quality).toEqual({ count: 1 }); + expect(sample.reads).toHaveLength(2); + expect(sample.paired).toBe(true); + expect(sample.user.id).toBe(ownerId); + }); + + it("loads reads and artifacts keyed by the legacy storage id", async () => { + const sampleId = await seedSample({ legacy_id: "abc123" }); + + // A legacy row is keyed only by the text `sample` storage id, with + // `sample_id` left null. + await db.insert(sampleReads).values({ + sample: "abc123", + sample_id: null, + name: "reads_1.fq.gz", + name_on_disk: "reads_1.fq.gz", + }); + await db.insert(sampleArtifacts).values({ + sample: "abc123", + sample_id: null, + name: "fastqc.txt", + type: "fastqc", + }); + + const sample = await getSample(db, sampleId); + + expect(sample.reads.map((r) => r.name)).toEqual(["reads_1.fq.gz"]); + expect(sample.artifacts.map((a) => a.name)).toEqual(["fastqc.txt"]); + }); + + it("throws when the sample does not exist", async () => { + await expect(getSample(db, 123456)).rejects.toBeInstanceOf( + SampleNotFoundError, + ); + }); + + it("throws when the sample has no owner", async () => { + const sampleId = await seedSample({ user_id: null }); + await expect(getSample(db, sampleId)).rejects.toBeInstanceOf( + SampleOwnerlessError, + ); + }); +}); + +describe("createSample", () => { + function values( + overrides: Partial = {}, + ): CreateSampleValues { + return { + name: "New Sample", + host: "", + isolate: "", + locale: "", + notes: "", + libraryType: "normal", + group: null, + subtractions: [], + labels: [], + files: [], + userId: ownerId, + ...overrides, + }; + } + + it("creates a pending job, reserves the uploads, and links them in order", async () => { + const first = await seedUpload(); + const second = await seedUpload(); + + const sample = await createSample(db, values({ files: [first, second] })); + + // `paired` is derived from the reads on read, which are not uploaded until + // the job runs, so the stored column is what create sets from the file count. + const [row] = await db + .select({ paired: legacySamples.paired }) + .from(legacySamples) + .where(eq(legacySamples.id, sample.id)); + expect(row?.paired).toBe(true); + + const [job] = await db + .select() + .from(jobs) + .where(eq(jobs.id, sample.job?.id ?? 0)); + expect(job?.workflow).toBe("create_sample"); + expect(job?.state).toBe("pending"); + + const reserved = await db + .select({ id: uploads.id, reserved: uploads.reserved }) + .from(uploads); + expect(reserved.every((u) => u.reserved)).toBe(true); + + const links = await db + .select() + .from(sampleUploads) + .where(eq(sampleUploads.sample_id, sample.id)) + .orderBy(sampleUploads.index); + expect(links.map((l) => l.upload_id)).toEqual([first, second]); + }); + + it("inserts the label and subtraction join rows", async () => { + const file = await seedUpload(); + const labelId = await seedLabel("lab"); + const subtractionId = await seedSubtraction("sub"); + + const sample = await createSample( + db, + values({ + files: [file], + labels: [labelId], + subtractions: [subtractionId], + }), + ); + + expect(sample.labels.map((l) => l.id)).toEqual([labelId]); + expect(sample.subtractions.map((s) => s.id)).toEqual([subtractionId]); + }); + + it("rejects a duplicate name", async () => { + await seedSample({ name: "Taken" }); + await expect( + createSample(db, values({ name: "Taken" })), + ).rejects.toBeInstanceOf(SampleNameConflictError); + }); + + it("rejects a duplicated upload", async () => { + const file = await seedUpload(); + await expect( + createSample(db, values({ files: [file, file] })), + ).rejects.toBeInstanceOf(SampleFileDuplicateError); + }); + + it("rejects a missing label", async () => { + const file = await seedUpload(); + await expect( + createSample(db, values({ files: [file], labels: [987654] })), + ).rejects.toBeInstanceOf(SampleLabelsNotFoundError); + }); + + it("rejects a file that is not a visible reads upload", async () => { + const reference = takeFirstOrThrow( + await db + .insert(uploads) + .values({ + createdAt: new Date(), + name: "ref.fa.gz", + nameOnDisk: `disk-ref-${Math.random()}`, + type: "reference", + ready: true, + userId: ownerId, + }) + .returning({ id: uploads.id }), + ).id; + + await expect( + createSample(db, values({ files: [reference] })), + ).rejects.toBeInstanceOf(UploadNotFoundError); + + // The reference upload is left untouched by the failed reservation. + const [row] = await db + .select({ reserved: uploads.reserved }) + .from(uploads) + .where(eq(uploads.id, reference)); + expect(row?.reserved).toBe(false); + }); + + it("rejects an unfinished reads upload", async () => { + const pending = takeFirstOrThrow( + await db + .insert(uploads) + .values({ + createdAt: new Date(), + name: "reads.fq.gz", + nameOnDisk: `disk-pending-${Math.random()}`, + type: "reads", + ready: false, + userId: ownerId, + }) + .returning({ id: uploads.id }), + ).id; + + await expect( + createSample(db, values({ files: [pending] })), + ).rejects.toBeInstanceOf(UploadNotFoundError); + }); +}); + +describe("updateSample", () => { + it("updates scalars and replaces the label set", async () => { + const first = await seedLabel("first"); + const second = await seedLabel("second"); + const sampleId = await seedSample({ name: "Old" }); + await db + .insert(legacySampleLabels) + .values({ sample_id: sampleId, label_id: first }); + + const sample = await updateSample(db, sampleId, { + name: "New", + labels: [second], + }); + + expect(sample.name).toBe("New"); + expect(sample.labels.map((l) => l.id)).toEqual([second]); + }); + + it("allows renaming a sample to its own name", async () => { + const sampleId = await seedSample({ name: "Same" }); + const sample = await updateSample(db, sampleId, { name: "Same" }); + expect(sample.name).toBe("Same"); + }); + + it("rejects a name already used by another sample", async () => { + await seedSample({ name: "Taken" }); + const sampleId = await seedSample({ name: "Mine" }); + await expect( + updateSample(db, sampleId, { name: "Taken" }), + ).rejects.toBeInstanceOf(SampleNameConflictError); + }); + + it("throws when the sample does not exist", async () => { + await expect( + updateSample(db, 123456, { name: "x" }), + ).rejects.toBeInstanceOf(SampleNotFoundError); + }); +}); + +describe("deleteSample", () => { + it("cascades across the sample's tables and releases its reserved uploads", async () => { + const uploadId = await seedUpload(true); + const sampleId = await seedSample({ name: "Doomed" }); + + await db.insert(sampleUploads).values({ + sample: String(sampleId), + sample_id: sampleId, + upload_id: uploadId, + index: 0, + }); + await db.insert(sampleReads).values({ + sample: String(sampleId), + sample_id: sampleId, + name: "reads_1.fq.gz", + name_on_disk: "reads_1.fq.gz", + }); + await db.insert(sampleArtifacts).values({ + sample: String(sampleId), + sample_id: sampleId, + name: "a.json", + type: "json", + }); + await db + .insert(analyses) + .values({ sample_id: sampleId, workflow: "nuvs", ready: true }); + + const deleted = await deleteSample(db, new MemoryStorage(), sampleId); + expect(deleted.id).toBe(sampleId); + + expect( + await db + .select() + .from(legacySamples) + .where(eq(legacySamples.id, sampleId)), + ).toHaveLength(0); + expect( + await db + .select() + .from(sampleUploads) + .where(eq(sampleUploads.sample_id, sampleId)), + ).toHaveLength(0); + expect( + await db + .select() + .from(sampleReads) + .where(eq(sampleReads.sample_id, sampleId)), + ).toHaveLength(0); + expect( + await db + .select() + .from(sampleArtifacts) + .where(eq(sampleArtifacts.sample_id, sampleId)), + ).toHaveLength(0); + expect( + await db.select().from(analyses).where(eq(analyses.sample_id, sampleId)), + ).toHaveLength(0); + + const [upload] = await db + .select({ reserved: uploads.reserved }) + .from(uploads) + .where(eq(uploads.id, uploadId)); + expect(upload?.reserved).toBe(false); + }); + + it("throws when the sample does not exist", async () => { + await expect( + deleteSample(db, new MemoryStorage(), 123456), + ).rejects.toBeInstanceOf(SampleNotFoundError); + }); +}); + +describe("updateSampleRights", () => { + it("sets the rights flags", async () => { + const sampleId = await seedSample({ name: "Rights" }); + const sample = await updateSampleRights(db, sampleId, { + allRead: true, + groupWrite: true, + }); + expect(sample.allRead).toBe(true); + expect(sample.groupWrite).toBe(true); + }); + + it("resolves and assigns a group by id", async () => { + const groupId = await seedGroup(db, { name: "grp" }); + const sampleId = await seedSample({ name: "Rights" }); + const sample = await updateSampleRights(db, sampleId, { group: groupId }); + expect(sample.group?.id).toBe(groupId); + }); + + it("clears the group when passed null", async () => { + const groupId = await seedGroup(db, { name: "grp" }); + const sampleId = await seedSample({ name: "Rights", group_id: groupId }); + const sample = await updateSampleRights(db, sampleId, { group: null }); + expect(sample.group).toBeNull(); + }); + + it("rejects a group that does not exist", async () => { + const sampleId = await seedSample({ name: "Rights" }); + await expect( + updateSampleRights(db, sampleId, { group: 987654 }), + ).rejects.toBeInstanceOf(SampleGroupNotFoundError); + }); + + it("throws when the sample does not exist", async () => { + await expect( + updateSampleRights(db, 123456, { allRead: true }), + ).rejects.toBeInstanceOf(SampleNotFoundError); + }); +}); + +describe("sampleReadableFilter", () => { + it("returns undefined for an administrator", () => { + expect(sampleReadableFilter(adminActor)).toBeUndefined(); + }); + + it("returns a predicate for a non-administrator", () => { + const actor: SampleActor = { userId: 1, groupIds: [2], isAdmin: false }; + expect(sampleReadableFilter(actor)).toBeDefined(); + }); +}); + +describe("resolveSampleActor", () => { + it("resolves group membership and the admin flag", async () => { + const groupId = await seedGroup(db, { name: "g" }); + const adminId = await seedUser(db, { + handle: "admin", + administratorRole: "full", + }); + await addToGroup(db, adminId, groupId); + + const actor = await resolveSampleActor(db, adminId); + expect(actor.isAdmin).toBe(true); + expect(actor.groupIds).toEqual([groupId]); + + const plain = await resolveSampleActor(db, ownerId); + expect(plain.isAdmin).toBe(false); + }); +}); diff --git a/apps/web/src/server/samples/data.ts b/apps/web/src/server/samples/data.ts new file mode 100644 index 000000000..248677f37 --- /dev/null +++ b/apps/web/src/server/samples/data.ts @@ -0,0 +1,1239 @@ +import type { + JobState, + LabelNested, + LibraryType, + Quality, + Read, + Sample, + SampleArtifact, + SampleCreateRequest, + SampleJobNested, + SampleMinimal, + SampleRightsUpdate, + SampleSearchResult, + SampleUpdateRequest, + SampleWorkflows, + SubtractionNested, + UserNested, + WorkflowState, +} from "@virtool/contracts"; +import { + and, + asc, + count, + desc, + eq, + exists, + ilike, + inArray, + not, + or, + type SQL, + sql, +} from "drizzle-orm"; +import type { Db, DbOrTx } from "../db/pg"; +import { takeFirstOrThrow } from "../db/rows"; +import { analyses } from "../db/schema/analyses"; +import { groups, userGroups } from "../db/schema/groups"; +import { labels } from "../db/schema/labels"; +import { + type LegacySampleRow, + legacySampleLabels, + legacySampleSubtractions, + legacySamples, + sampleArtifacts, + sampleReads, + sampleUploads, +} from "../db/schema/samples"; +import { subtractions } from "../db/schema/subtractions"; +import { uploads } from "../db/schema/uploads"; +import { users } from "../db/schema/users"; +import { AppError } from "../errors"; +import { emit } from "../events/emit"; +import { createJob, getJobs } from "../jobs/data"; +import { logger } from "../logger"; +import { getSettings } from "../settings/data"; +import { + deletePrefix, + type StorageBackend, + samplePrefix, + sampleStorageId, +} from "../storage"; +import { reserveUploads } from "../uploads/data"; + +/** A read or write right on a sample. */ +export type SampleRight = "read" | "write"; + +/** The caller resolved to the identity used for per-sample authorization. */ +export type SampleActor = { + userId: number; + groupIds: number[]; + isAdmin: boolean; +}; + +/** Filters and pagination accepted by {@link findSamples}. */ +export type FindSamplesOptions = { + page: number; + perPage: number; + term: string; + labels: number[]; + users: number[]; + workflows: string[]; +}; + +/** + * The fields a sample is created from, plus the creating user. The handler + * coerces the request's `group` (id, legacy string, or null) to an id or null + * before it reaches here. + */ +export type CreateSampleValues = Omit & { + group: number | null; + userId: number; +}; + +/** The fields that can be changed when updating a sample. */ +export type UpdateSampleValues = SampleUpdateRequest; + +/** Thrown when a requested sample does not exist. */ +export class SampleNotFoundError extends AppError {} + +/** Thrown when a sample name is already taken. */ +export class SampleNameConflictError extends AppError {} + +/** Thrown when a create or update references a label that does not exist. */ +export class SampleLabelsNotFoundError extends AppError {} + +/** Thrown when a create or update references a subtraction that does not exist. */ +export class SampleSubtractionsNotFoundError extends AppError {} + +/** Thrown when a rights or create request names a group that does not exist. */ +export class SampleGroupNotFoundError extends AppError {} + +/** Thrown when the `force_choice` policy requires a group and none was given. */ +export class SampleGroupRequiredError extends AppError {} + +/** Thrown when the same upload is supplied more than once to create a sample. */ +export class SampleFileDuplicateError extends AppError {} + +/** + * Thrown when a sample has no owner. `user_id` is nullable in the schema, but a + * sample without a creating user is a data-integrity violation, not a routine + * outcome — reading one surfaces it rather than degrading to a blank owner. + */ +export class SampleOwnerlessError extends AppError {} + +const WORKFLOW_NAMES = ["nuvs", "pathoscope"] as const; +const WORKFLOW_CONDITIONS = ["none", "pending", "ready"] as const; + +type WorkflowName = (typeof WORKFLOW_NAMES)[number]; + +/** A sample's derived workflow tags, keyed as the client consumes them. */ +type WorkflowTags = { + nuvs: boolean | string; + pathoscope: boolean | string; + workflows: SampleWorkflows; +}; + +// Escape LIKE wildcards so a user's `%` or `_` matches literally. +function escapeLike(term: string): string { + return term.replace(/[\\%_]/g, (char) => `\\${char}`); +} + +// `None` (no analyses) is a `false` tag, a ready analysis is `true`, and an +// unfinished analysis is `"ip"` — the legacy top-level tag encoding. +function encodeTag(ready: boolean | undefined): boolean | string { + if (ready === undefined) { + return false; + } + return ready ? true : "ip"; +} + +function encodeWorkflows(readyByWorkflow: Map): WorkflowTags { + const workflows: SampleWorkflows = { nuvs: "none", pathoscope: "none" }; + + for (const name of WORKFLOW_NAMES) { + const ready = readyByWorkflow.get(name); + if (ready !== undefined) { + workflows[name] = ready ? "complete" : ("pending" as WorkflowState); + } + } + + return { + nuvs: encodeTag(readyByWorkflow.get("nuvs")), + pathoscope: encodeTag(readyByWorkflow.get("pathoscope")), + workflows, + }; +} + +const EMPTY_TAGS: WorkflowTags = encodeWorkflows(new Map()); + +// One `GROUP BY workflow, bool_or(ready)` query bounded to the page's sample +// ids, so the aggregation never scans the whole analyses table. +async function getWorkflowTagsBySample( + db: DbOrTx, + sampleIds: number[], +): Promise> { + if (sampleIds.length === 0) { + return new Map(); + } + + const rows = await db + .select({ + sampleId: analyses.sample_id, + workflow: analyses.workflow, + ready: sql`bool_or(${analyses.ready})`, + }) + .from(analyses) + .where(inArray(analyses.sample_id, sampleIds)) + .groupBy(analyses.sample_id, analyses.workflow); + + const readyBySample = new Map>(); + + for (const row of rows) { + if (row.sampleId == null) { + continue; + } + const forSample = readyBySample.get(row.sampleId) ?? new Map(); + forSample.set(row.workflow, row.ready); + readyBySample.set(row.sampleId, forSample); + } + + return new Map( + sampleIds.map((id) => [ + id, + encodeWorkflows(readyBySample.get(id) ?? new Map()), + ]), + ); +} + +async function getLabelsBySample( + db: DbOrTx, + sampleIds: number[], +): Promise> { + if (sampleIds.length === 0) { + return new Map(); + } + + const rows = await db + .select({ + sampleId: legacySampleLabels.sample_id, + id: labels.id, + name: labels.name, + color: labels.color, + description: labels.description, + }) + .from(legacySampleLabels) + .innerJoin(labels, eq(labels.id, legacySampleLabels.label_id)) + .where(inArray(legacySampleLabels.sample_id, sampleIds)) + .orderBy(asc(legacySampleLabels.label_id)); + + const bySample = new Map(); + + for (const row of rows) { + const list = bySample.get(row.sampleId) ?? []; + list.push({ + id: row.id, + name: row.name ?? "", + color: row.color ?? "", + description: row.description ?? "", + }); + bySample.set(row.sampleId, list); + } + + return bySample; +} + +// The sample's creation job, reduced to the embedded shape. Reuses the jobs +// data layer so a sample's job never disagrees with the jobs endpoints. +async function getSampleJobs( + db: Db, + jobIds: number[], +): Promise> { + const jobs = await getJobs(db, jobIds); + + return new Map( + jobs.map((job) => [ + job.id, + { + createdAt: job.created_at.toISOString(), + id: job.id, + progress: job.progress, + // The mirror stores states and workflows as free text; the columns + // only ever hold the enumerated values, and a sample's job is always + // the `create_sample` job that built it. + state: job.state as JobState, + user: job.user, + workflow: job.workflow as "create_sample", + }, + ]), + ); +} + +async function getSubtractionsBySample( + db: DbOrTx, + sampleId: number, +): Promise { + return db + .select({ id: subtractions.id, name: subtractions.name }) + .from(legacySampleSubtractions) + .innerJoin( + subtractions, + eq(subtractions.id, legacySampleSubtractions.subtraction_id), + ) + .where(eq(legacySampleSubtractions.sample_id, sampleId)) + .orderBy(asc(legacySampleSubtractions.subtraction_id)); +} + +async function getArtifacts( + db: DbOrTx, + sampleId: number, + storageId: string, +): Promise { + const rows = await db + .select() + .from(sampleArtifacts) + .where( + or( + eq(sampleArtifacts.sample_id, sampleId), + eq(sampleArtifacts.sample, storageId), + ), + ) + .orderBy(asc(sampleArtifacts.id)); + + return rows.map((row) => ({ + id: row.id, + name: row.name, + size: row.size ?? 0, + downloadUrl: `/samples/${sampleId}/artifacts/${row.name_on_disk ?? ""}`, + })); +} + +async function getReads( + db: DbOrTx, + sampleId: number, + storageId: string, +): Promise { + const rows = await db + .select({ + read: sampleReads, + upload: uploads, + uploadUser: { id: users.id, handle: users.handle }, + }) + .from(sampleReads) + .leftJoin(uploads, eq(uploads.id, sampleReads.upload)) + .leftJoin(users, eq(users.id, uploads.userId)) + .where( + or( + eq(sampleReads.sample_id, sampleId), + eq(sampleReads.sample, storageId), + ), + ) + .orderBy(asc(sampleReads.id)); + + return rows.map(({ read, upload, uploadUser }) => ({ + downloadUrl: `/samples/${sampleId}/reads/${read.name}`, + id: read.id, + name: read.name, + nameOnDisk: read.name_on_disk, + sample: sampleId, + size: read.size ?? 0, + upload: + upload == null + ? null + : { + id: upload.id, + name: upload.name ?? "", + size: upload.size, + uploadedAt: upload.uploadedAt?.toISOString() ?? null, + user: uploadUser?.id != null ? uploadUser : null, + }, + uploadedAt: read.uploaded_at?.toISOString() ?? "", + })); +} + +function mapMinimal( + row: LegacySampleRow, + sampleLabels: LabelNested[], + tags: WorkflowTags, + job: SampleJobNested | undefined, + user: UserNested, +): SampleMinimal { + return { + createdAt: row.created_at.toISOString(), + host: row.host, + id: row.id, + isolate: row.isolate, + job, + labels: sampleLabels, + libraryType: row.library_type as LibraryType, + name: row.name, + notes: row.notes, + nuvs: tags.nuvs, + pathoscope: tags.pathoscope, + ready: row.ready, + user, + workflows: tags.workflows, + }; +} + +// The sample's owner, resolved from the joined `users` row. `user_id` is nullable +// in the schema; an ownerless sample maps to the sentinel owner. +function resolveOwner( + userId: number | null, + handle: string | null, +): UserNested { + if (userId == null) { + return { id: 0, handle: "" }; + } + + return { id: userId, handle: handle ?? "" }; +} + +/** Resolve a user id to the identity used for per-sample authorization. */ +export async function resolveSampleActor( + db: Db, + userId: number, +): Promise { + const [userRows, groupRows] = await Promise.all([ + db + .select({ role: users.administratorRole }) + .from(users) + .where(eq(users.id, userId)) + .limit(1), + db + .select({ groupId: userGroups.groupId }) + .from(userGroups) + .where(eq(userGroups.userId, userId)), + ]); + + return { + userId, + groupIds: groupRows.map((row) => row.groupId), + isAdmin: userRows[0]?.role === "full", + }; +} + +/** The `legacy_samples` columns {@link hasSampleRight} needs to resolve a right. */ +type SampleRightsRow = { + all_read: boolean; + all_write: boolean; + group_read: boolean; + group_write: boolean; + group_id: number | null; + user_id: number | null; +}; + +/** + * Whether `actor` holds `right` on a sample row. Shared by the samples and + * analyses domains so a sample and the analyses on it never disagree about who + * may read or write them. + * + * A full administrator and the sample's owner always hold every right; + * otherwise `all_read`/`all_write` grant everyone and `group_read`/`group_write` + * grant only members of the sample's group. + */ +export function hasSampleRight( + row: SampleRightsRow, + actor: SampleActor, + right: SampleRight, +): boolean { + if (actor.isAdmin || row.user_id === actor.userId) { + return true; + } + + const isGroupMember = + row.group_id != null && actor.groupIds.includes(row.group_id); + + if (right === "read") { + return row.all_read || (isGroupMember && row.group_read); + } + + return row.all_write || (isGroupMember && row.group_write); +} + +/** + * The Postgres predicate scoping a list to the samples `actor` may read — the + * WHERE-clause form of {@link hasSampleRight}. A full administrator sees every + * sample. Returns `undefined` for an administrator so callers can omit the + * clause entirely. + */ +export function sampleReadableFilter(actor: SampleActor): SQL | undefined { + if (actor.isAdmin) { + return undefined; + } + + const clauses = [ + eq(legacySamples.all_read, true), + eq(legacySamples.user_id, actor.userId), + ]; + + if (actor.groupIds.length > 0) { + clauses.push( + and( + eq(legacySamples.group_read, true), + inArray(legacySamples.group_id, actor.groupIds), + ) as SQL, + ); + } + + return or(...clauses); +} + +// A correlated EXISTS on analyses for the enclosing sample row, optionally +// restricted to a ready state. +function existsAnalysis(db: Db, workflow: string, ready?: boolean): SQL { + const conditions = [ + eq(analyses.sample_id, legacySamples.id), + eq(analyses.workflow, workflow), + ]; + + if (ready !== undefined) { + conditions.push(eq(analyses.ready, ready)); + } + + return exists( + db + .select({ one: sql`1` }) + .from(analyses) + .where(and(...conditions)), + ); +} + +function composeWorkflowConditionFilter( + db: Db, + workflow: string, + condition: string, +): SQL { + if (condition === "ready") { + return existsAnalysis(db, workflow, true); + } + + if (condition === "pending") { + return and( + existsAnalysis(db, workflow), + not(existsAnalysis(db, workflow, true)), + ) as SQL; + } + + return not(existsAnalysis(db, workflow)); +} + +// Each `workflow:condition` pair becomes a correlated semi-join on analyses. +// Conditions for one workflow are ORed, different workflows ANDed. Pairs with an +// unknown workflow or condition are dropped, matching the old Mongo query. +function composeWorkflowFilter(db: Db, workflows: string[]): SQL | undefined { + const conditionsByWorkflow = new Map>(); + + for (const value of workflows) { + for (const pair of value.split(" ")) { + const [workflow, condition] = pair.split(":"); + + if ( + workflow !== undefined && + condition !== undefined && + (WORKFLOW_NAMES as readonly string[]).includes(workflow) && + (WORKFLOW_CONDITIONS as readonly string[]).includes(condition) + ) { + const key = workflow as WorkflowName; + const set = conditionsByWorkflow.get(key) ?? new Set(); + set.add(condition); + conditionsByWorkflow.set(key, set); + } + } + } + + if (conditionsByWorkflow.size === 0) { + return undefined; + } + + const clauses = [...conditionsByWorkflow].map( + ([workflow, conditions]) => + or( + ...[...conditions].map((condition) => + composeWorkflowConditionFilter(db, workflow, condition), + ), + ) as SQL, + ); + + return and(...clauses); +} + +export async function findSamples( + db: Db, + options: FindSamplesOptions, + actor: SampleActor, +): Promise { + const filters: SQL[] = []; + + const readable = sampleReadableFilter(actor); + if (readable) { + filters.push(readable); + } + + if (options.term) { + filters.push(ilike(legacySamples.name, `%${escapeLike(options.term)}%`)); + } + + if (options.users.length > 0) { + filters.push(inArray(legacySamples.user_id, options.users)); + } + + if (options.labels.length > 0) { + filters.push( + inArray( + legacySamples.id, + db + .select({ id: legacySampleLabels.sample_id }) + .from(legacySampleLabels) + .where(inArray(legacySampleLabels.label_id, options.labels)), + ), + ); + } + + const workflowFilter = composeWorkflowFilter(db, options.workflows); + if (workflowFilter) { + filters.push(workflowFilter); + } + + const where = filters.length > 0 ? and(...filters) : undefined; + + const [totalRows, foundRows, rows] = await Promise.all([ + // Python's `total_count` is the unscoped grand total of samples, not the + // count visible to the caller. Match it exactly. + db.select({ value: count() }).from(legacySamples), + db.select({ value: count() }).from(legacySamples).where(where), + // The owner joins onto the sample row; the collection relationships fan + // out below, batched by the page's sample ids. + db + .select({ sample: legacySamples, ownerHandle: users.handle }) + .from(legacySamples) + .leftJoin(users, eq(users.id, legacySamples.user_id)) + .where(where) + .orderBy(desc(legacySamples.created_at), asc(legacySamples.id)) + .offset((options.page - 1) * options.perPage) + .limit(options.perPage), + ]); + + const totalCount = totalRows[0]?.value ?? 0; + const foundCount = foundRows[0]?.value ?? 0; + + const sampleIds = rows.map(({ sample }) => sample.id); + const jobIds = [ + ...new Set( + rows + .map(({ sample }) => sample.job_id) + .filter((id): id is number => id != null), + ), + ]; + + const [labelsBySample, tagsBySample, jobsById] = await Promise.all([ + getLabelsBySample(db, sampleIds), + getWorkflowTagsBySample(db, sampleIds), + getSampleJobs(db, jobIds), + ]); + + return { + foundCount, + totalCount, + page: options.page, + perPage: options.perPage, + pageCount: foundCount ? Math.ceil(foundCount / options.perPage) : 0, + items: rows.map(({ sample, ownerHandle }) => + mapMinimal( + sample, + labelsBySample.get(sample.id) ?? [], + tagsBySample.get(sample.id) ?? EMPTY_TAGS, + sample.job_id != null + ? (jobsById.get(sample.job_id) ?? undefined) + : undefined, + resolveOwner(sample.user_id, ownerHandle), + ), + ), + }; +} + +export async function getSample(db: Db, sampleId: number): Promise { + // The owner and group are one-to-one, so they join onto the sample row; the + // collection relationships (labels, subtractions, reads, artifacts, analyses) + // are separate result sets that fan out below. + const [row] = await db + .select({ + sample: legacySamples, + ownerHandle: users.handle, + group: { id: groups.id, name: groups.name, legacy_id: groups.legacyId }, + }) + .from(legacySamples) + .leftJoin(users, eq(users.id, legacySamples.user_id)) + .leftJoin(groups, eq(groups.id, legacySamples.group_id)) + .where(eq(legacySamples.id, sampleId)) + .limit(1); + + if (!row) { + throw new SampleNotFoundError(); + } + + const { sample, ownerHandle, group: groupRow } = row; + + if (sample.user_id == null) { + throw new SampleOwnerlessError(); + } + + const jobIds = sample.job_id != null ? [sample.job_id] : []; + const storageId = sampleStorageId(sampleId, sample.legacy_id); + + const [ + labelsBySample, + tagsBySample, + jobsById, + sampleSubtractions, + artifacts, + reads, + ] = await Promise.all([ + getLabelsBySample(db, [sampleId]), + getWorkflowTagsBySample(db, [sampleId]), + getSampleJobs(db, jobIds), + getSubtractionsBySample(db, sampleId), + getArtifacts(db, sampleId, storageId), + getReads(db, sampleId, storageId), + ]); + + const minimal = mapMinimal( + sample, + labelsBySample.get(sampleId) ?? [], + tagsBySample.get(sampleId) ?? EMPTY_TAGS, + sample.job_id != null + ? (jobsById.get(sample.job_id) ?? undefined) + : undefined, + resolveOwner(sample.user_id, ownerHandle), + ); + + return { + ...minimal, + allRead: sample.all_read, + allWrite: sample.all_write, + artifacts, + format: sample.format, + group: groupRow, + groupRead: sample.group_read, + groupWrite: sample.group_write, + hold: sample.hold, + isLegacy: sample.is_legacy, + locale: sample.locale, + paired: reads.length === 2, + quality: (sample.quality as Quality | null) ?? null, + reads, + subtractions: sampleSubtractions, + }; +} + +/** + * The owner user id of a sample, or `null` when the sample does not exist — the + * rights-mutation gate treats that as "not found". A sample that exists but has + * no owner is an invalid state and throws {@link SampleOwnerlessError}. + */ +export async function getSampleOwnerId( + db: Db, + sampleId: number, +): Promise { + const [row] = await db + .select({ userId: legacySamples.user_id }) + .from(legacySamples) + .where(eq(legacySamples.id, sampleId)) + .limit(1); + + if (!row) { + return null; + } + + if (row.userId == null) { + throw new SampleOwnerlessError(); + } + + return row.userId; +} + +/** + * Whether `actor` holds `right` on the sample, resolving the row first. + * + * A lookup for a nonexistent sample returns `true`, so the caller's subsequent + * fetch produces the 404 rather than this returning a misleading 403. + */ +export async function checkSampleRight( + db: Db, + sampleId: number, + actor: SampleActor, + right: SampleRight, +): Promise { + const [row] = await db + .select({ + all_read: legacySamples.all_read, + all_write: legacySamples.all_write, + group_read: legacySamples.group_read, + group_write: legacySamples.group_write, + group_id: legacySamples.group_id, + user_id: legacySamples.user_id, + }) + .from(legacySamples) + .where(eq(legacySamples.id, sampleId)) + .limit(1); + + if (!row) { + return true; + } + + return hasSampleRight(row, actor, right); +} + +async function checkNameInUse( + db: DbOrTx, + name: string, + excludeId?: number, +): Promise { + const [row] = await db + .select({ id: legacySamples.id }) + .from(legacySamples) + .where( + excludeId === undefined + ? eq(legacySamples.name, name) + : and( + eq(legacySamples.name, name), + not(eq(legacySamples.id, excludeId)), + ), + ) + .limit(1); + + if (row) { + throw new SampleNameConflictError("Sample name is already in use"); + } +} + +async function checkLabelsExist(db: DbOrTx, labelIds: number[]): Promise { + if (labelIds.length === 0) { + return; + } + + const rows = await db + .select({ id: labels.id }) + .from(labels) + .where(inArray(labels.id, labelIds)); + + const found = new Set(rows.map((row) => row.id)); + const missing = labelIds.filter((id) => !found.has(id)); + + if (missing.length > 0) { + throw new SampleLabelsNotFoundError( + `Labels do not exist: ${missing.join(", ")}`, + ); + } +} + +async function checkSubtractionsExist( + db: DbOrTx, + subtractionIds: number[], +): Promise { + if (subtractionIds.length === 0) { + return; + } + + const rows = await db + .select({ id: subtractions.id }) + .from(subtractions) + .where(inArray(subtractions.id, subtractionIds)); + + const found = new Set(rows.map((row) => row.id)); + const missing = subtractionIds.filter((id) => !found.has(id)); + + if (missing.length > 0) { + throw new SampleSubtractionsNotFoundError( + `Subtractions do not exist: ${missing.join(",")}`, + ); + } +} + +async function resolveCreateGroup( + db: Db, + values: CreateSampleValues, + sampleGroup: string, +): Promise { + if (sampleGroup === "force_choice") { + if (values.group == null) { + throw new SampleGroupRequiredError( + "Group value required for sample creation", + ); + } + + const [group] = await db + .select({ id: groups.id }) + .from(groups) + .where(eq(groups.id, values.group)) + .limit(1); + + if (!group) { + throw new SampleGroupNotFoundError("Group does not exist"); + } + + return group.id; + } + + if (sampleGroup === "users_primary_group") { + const [row] = await db + .select({ groupId: userGroups.groupId }) + .from(userGroups) + .where( + and(eq(userGroups.userId, values.userId), eq(userGroups.primary, true)), + ) + .limit(1); + + return row?.groupId ?? null; + } + + return null; +} + +export async function createSample( + db: Db, + values: CreateSampleValues, +): Promise { + const settings = await getSettings(db); + + await Promise.all([ + checkNameInUse(db, values.name), + checkLabelsExist(db, values.labels), + checkSubtractionsExist(db, values.subtractions), + ]); + + if (new Set(values.files).size !== values.files.length) { + throw new SampleFileDuplicateError("File is duplicated"); + } + + const groupId = await resolveCreateGroup(db, values, settings.sampleGroup); + + const { sampleId, jobId } = await db.transaction(async (tx) => { + // Reserve uploads and create the job inside the sample's transaction so + // everything commits atomically: a runner must not claim the job before + // the sample row it derives its arguments from exists. + await reserveUploads(tx, values.files); + + const jobId = await createJob(tx, "create_sample", values.userId); + + const sample = takeFirstOrThrow( + await tx + .insert(legacySamples) + .values({ + all_read: settings.sampleAllRead, + all_write: settings.sampleAllWrite, + created_at: new Date(), + format: "fastq", + group_id: groupId, + group_read: settings.sampleGroupRead, + group_write: settings.sampleGroupWrite, + hold: true, + host: values.host, + is_legacy: false, + isolate: values.isolate, + job_id: jobId, + library_type: values.libraryType, + locale: values.locale, + name: values.name, + notes: values.notes, + paired: values.files.length === 2, + quality: null, + ready: false, + user_id: values.userId, + }) + .returning({ id: legacySamples.id }), + ); + + const sampleId = sample.id; + + if (values.labels.length > 0) { + await tx.insert(legacySampleLabels).values( + values.labels.map((labelId) => ({ + sample_id: sampleId, + label_id: labelId, + })), + ); + } + + if (values.subtractions.length > 0) { + await tx.insert(legacySampleSubtractions).values( + values.subtractions.map((subtractionId) => ({ + sample_id: sampleId, + subtraction_id: subtractionId, + })), + ); + } + + if (values.files.length > 0) { + await tx.insert(sampleUploads).values( + values.files.map((uploadId, index) => ({ + sample: String(sampleId), + sample_id: sampleId, + upload_id: uploadId, + index, + })), + ); + } + + return { sampleId, jobId }; + }); + + await emit("jobs", jobId, "create"); + await emit("samples", sampleId, "create"); + + return getSample(db, sampleId); +} + +export async function updateSample( + db: Db, + sampleId: number, + values: UpdateSampleValues, +): Promise { + const [existing] = await db + .select({ id: legacySamples.id }) + .from(legacySamples) + .where(eq(legacySamples.id, sampleId)) + .limit(1); + + if (!existing) { + throw new SampleNotFoundError(); + } + + const checks: Promise[] = []; + + if (values.name !== undefined) { + checks.push(checkNameInUse(db, values.name, sampleId)); + } + if (values.labels !== undefined) { + checks.push(checkLabelsExist(db, values.labels)); + } + if (values.subtractions !== undefined) { + checks.push(checkSubtractionsExist(db, values.subtractions)); + } + + await Promise.all(checks); + + const scalars: Partial< + Pick + > = {}; + + if (values.name !== undefined) { + scalars.name = values.name; + } + if (values.host !== undefined) { + scalars.host = values.host; + } + if (values.isolate !== undefined) { + scalars.isolate = values.isolate; + } + if (values.locale !== undefined) { + scalars.locale = values.locale; + } + if (values.notes !== undefined) { + scalars.notes = values.notes; + } + + await db.transaction(async (tx) => { + if (Object.keys(scalars).length > 0) { + await tx + .update(legacySamples) + .set(scalars) + .where(eq(legacySamples.id, sampleId)); + } + + if (values.labels !== undefined) { + await tx + .delete(legacySampleLabels) + .where(eq(legacySampleLabels.sample_id, sampleId)); + + if (values.labels.length > 0) { + await tx.insert(legacySampleLabels).values( + values.labels.map((labelId) => ({ + sample_id: sampleId, + label_id: labelId, + })), + ); + } + } + + if (values.subtractions !== undefined) { + await tx + .delete(legacySampleSubtractions) + .where(eq(legacySampleSubtractions.sample_id, sampleId)); + + if (values.subtractions.length > 0) { + await tx.insert(legacySampleSubtractions).values( + values.subtractions.map((subtractionId) => ({ + sample_id: sampleId, + subtraction_id: subtractionId, + })), + ); + } + } + }); + + await emit("samples", sampleId, "update"); + + return getSample(db, sampleId); +} + +export async function updateSampleRights( + db: Db, + sampleId: number, + data: SampleRightsUpdate, +): Promise { + const [existing] = await db + .select({ id: legacySamples.id }) + .from(legacySamples) + .where(eq(legacySamples.id, sampleId)) + .limit(1); + + if (!existing) { + throw new SampleNotFoundError(); + } + + const scalars: Partial< + Pick< + LegacySampleRow, + "all_read" | "all_write" | "group_read" | "group_write" | "group_id" + > + > = {}; + + if (data.allRead !== undefined) { + scalars.all_read = data.allRead; + } + if (data.allWrite !== undefined) { + scalars.all_write = data.allWrite; + } + if (data.groupRead !== undefined) { + scalars.group_read = data.groupRead; + } + if (data.groupWrite !== undefined) { + scalars.group_write = data.groupWrite; + } + + if ("group" in data) { + let groupId: number | null = null; + + if (data.group != null && data.group !== "none") { + const [group] = await db + .select({ id: groups.id }) + .from(groups) + .where( + typeof data.group === "number" + ? eq(groups.id, data.group) + : eq(groups.legacyId, data.group), + ) + .limit(1); + + if (!group) { + throw new SampleGroupNotFoundError("Group does not exist"); + } + + groupId = group.id; + } + + scalars.group_id = groupId; + } + + if (Object.keys(scalars).length > 0) { + await db + .update(legacySamples) + .set(scalars) + .where(eq(legacySamples.id, sampleId)); + } + + await emit("samples", sampleId, "update"); + + return getSample(db, sampleId); +} + +export async function deleteSample( + db: Db, + storage: StorageBackend, + sampleId: number, +): Promise { + const [row] = await db + .select({ id: legacySamples.id, legacy_id: legacySamples.legacy_id }) + .from(legacySamples) + .where(eq(legacySamples.id, sampleId)) + .limit(1); + + if (!row) { + throw new SampleNotFoundError(); + } + + // Capture the full sample for the return value before its rows are removed. + const sample = await getSample(db, sampleId); + + const storageId = sampleStorageId(sampleId, row.legacy_id); + + const uploadRows = await db + .select({ uploadId: sampleUploads.upload_id }) + .from(sampleUploads) + .where( + or( + eq(sampleUploads.sample_id, sampleId), + eq(sampleUploads.sample, storageId), + ), + ); + const uploadIds = [...new Set(uploadRows.map((r) => r.uploadId))]; + + // The FK cascade only covers two of these relationships, so every table is + // deleted explicitly. Rows may be keyed by the integer id or the legacy + // storage string depending on when the sample was created. + await db.transaction(async (tx) => { + if (uploadIds.length > 0) { + await tx + .update(uploads) + .set({ reserved: false }) + .where(inArray(uploads.id, uploadIds)); + } + + await tx.delete(analyses).where(eq(analyses.sample_id, sampleId)); + await tx + .delete(sampleUploads) + .where( + or( + eq(sampleUploads.sample_id, sampleId), + eq(sampleUploads.sample, storageId), + ), + ); + await tx + .delete(sampleArtifacts) + .where( + or( + eq(sampleArtifacts.sample_id, sampleId), + eq(sampleArtifacts.sample, storageId), + ), + ); + await tx + .delete(sampleReads) + .where( + or( + eq(sampleReads.sample_id, sampleId), + eq(sampleReads.sample, storageId), + ), + ); + + const deleted = await tx + .delete(legacySamples) + .where(eq(legacySamples.id, sampleId)) + .returning({ id: legacySamples.id }); + + if (deleted.length === 0) { + throw new SampleNotFoundError(); + } + }); + + for (const failure of await deletePrefix(storage, samplePrefix(storageId))) { + logger.error( + { sampleId, key: failure.key, err: failure.error }, + "storage cleanup failed; file orphaned", + ); + } + + await emit("samples", sampleId, "delete"); + + return sample; +} diff --git a/apps/web/src/server/samples/functions.test.ts b/apps/web/src/server/samples/functions.test.ts new file mode 100644 index 000000000..67715674d --- /dev/null +++ b/apps/web/src/server/samples/functions.test.ts @@ -0,0 +1,238 @@ +import { eq } from "drizzle-orm"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import type { Db } from "../db/pg"; +import { takeFirstOrThrow } from "../db/rows"; +import { jobs } from "../db/schema/jobs"; +import { legacySamples } from "../db/schema/samples"; +import { sessions } from "../db/schema/sessions"; +import { users } from "../db/schema/users"; +import { createTestDatabase, type TestDatabase } from "../db/test/fixtures"; +import { callServerFn, type SplitServerFnModule } from "../test/serverFn"; + +const getRequest = vi.fn(); +const setResponseStatus = vi.fn(); + +vi.mock("@tanstack/react-start/server", () => ({ + deleteCookie: vi.fn(), + getCookie: vi.fn(), + getRequest, + setCookie: vi.fn(), + setResponseStatus, +})); + +vi.mock("@sentry/tanstackstart-react", () => ({ + captureException: vi.fn(), + setUser: vi.fn(), +})); + +let db: Db; +vi.mock("../db/pg", () => ({ + client: {}, + get db() { + return db; + }, +})); + +vi.mock("../events/emit", () => ({ emit: vi.fn() })); + +const handlers = (await import( + "./functions.ts?tss-serverfn-split" +)) as SplitServerFnModule; +const { ForbiddenError } = await import("../auth/middleware"); +const { SESSION_ID_COOKIE, SESSION_TOKEN_COOKIE } = await import( + "../auth/cookies" +); +const { seedSession, seedUser } = await import("../auth/test/fixtures"); + +let database: TestDatabase; + +beforeAll(async () => { + database = await createTestDatabase(); + db = database.db; +}, 60_000); + +afterAll(async () => { + await database.drop(); +}); + +beforeEach(async () => { + vi.clearAllMocks(); + await db.delete(legacySamples); + await db.delete(jobs); + await db.delete(sessions); + await db.delete(users); + getRequest.mockReturnValue( + new Request("https://virtool.test/_serverFn/test"), + ); +}); + +/** Authenticate the next call as a user with the given administrator role. */ +async function signIn( + administratorRole: "full" | "base" | null = null, +): Promise { + const userId = await seedUser(db, { administratorRole }); + const { sessionId, token } = await seedSession(db, userId); + + getRequest.mockReturnValue( + new Request("https://virtool.test/_serverFn/test", { + headers: { + cookie: `${SESSION_ID_COOKIE}=${sessionId}; ${SESSION_TOKEN_COOKIE}=${token}`, + }, + }), + ); + + return userId; +} + +async function seedSampleRow( + overrides: Partial = {}, +): Promise { + return takeFirstOrThrow( + await db + .insert(legacySamples) + .values({ + name: "Sample", + library_type: "normal", + created_at: new Date(), + ...overrides, + }) + .returning({ id: legacySamples.id }), + ).id; +} + +function call(name: string, data?: unknown) { + return callServerFn(handlers, name, data); +} + +describe("getSample", () => { + it("returns 403 when the caller may not read the sample", async () => { + const owner = await seedUser(db, { handle: "owner" }); + const sampleId = await seedSampleRow({ user_id: owner, all_read: false }); + + await signIn(null); + + await expect(call("getSampleFn", { sampleId })).rejects.toBeInstanceOf( + ForbiddenError, + ); + expect(setResponseStatus).toHaveBeenCalledWith(403); + }); + + it("returns 404, not 403, for a sample that does not exist", async () => { + await signIn(null); + + await expect(call("getSampleFn", { sampleId: 123456 })).rejects.toThrow(); + expect(setResponseStatus).toHaveBeenCalledWith(404); + expect(setResponseStatus).not.toHaveBeenCalledWith(403); + }); + + it("returns the sample the owner may read", async () => { + const userId = await signIn(null); + const sampleId = await seedSampleRow({ user_id: userId }); + + const sample = (await call("getSampleFn", { sampleId })) as { id: number }; + expect(sample.id).toBe(sampleId); + }); +}); + +describe("updateSampleRights", () => { + it("returns 404 for a sample that does not exist", async () => { + await signIn("full"); + + await expect( + call("updateSampleRightsFn", { sampleId: 123456, allRead: true }), + ).rejects.toThrow(); + expect(setResponseStatus).toHaveBeenCalledWith(404); + }); + + it("refuses a non-owner who is not a full administrator", async () => { + const owner = await seedUser(db, { handle: "owner" }); + const sampleId = await seedSampleRow({ user_id: owner }); + + await signIn(null); + + await expect( + call("updateSampleRightsFn", { sampleId, allRead: true }), + ).rejects.toBeInstanceOf(ForbiddenError); + expect(setResponseStatus).toHaveBeenCalledWith(403); + }); + + it("allows the sample owner", async () => { + const userId = await signIn(null); + const sampleId = await seedSampleRow({ user_id: userId }); + + const sample = (await call("updateSampleRightsFn", { + sampleId, + allRead: true, + })) as { allRead: boolean }; + expect(sample.allRead).toBe(true); + }); + + it("allows a full administrator who is not the owner", async () => { + const owner = await seedUser(db, { handle: "owner" }); + const sampleId = await seedSampleRow({ user_id: owner }); + + await signIn("full"); + + const sample = (await call("updateSampleRightsFn", { + sampleId, + allWrite: true, + })) as { allWrite: boolean }; + expect(sample.allWrite).toBe(true); + }); +}); + +describe("deleteSample", () => { + it("refuses a caller without write rights", async () => { + const owner = await seedUser(db, { handle: "owner" }); + const sampleId = await seedSampleRow({ user_id: owner, all_write: false }); + + await signIn(null); + + await expect(call("deleteSampleFn", { sampleId })).rejects.toBeInstanceOf( + ForbiddenError, + ); + expect(setResponseStatus).toHaveBeenCalledWith(403); + }); + + it("returns 400 when an unfinished sample's job is still running", async () => { + const userId = await signIn(null); + + const jobId = takeFirstOrThrow( + await db + .insert(jobs) + .values({ + acquired: true, + created_at: new Date(), + state: "running", + user_id: userId, + workflow: "create_sample", + }) + .returning({ id: jobs.id }), + ).id; + + const sampleId = await seedSampleRow({ + user_id: userId, + ready: false, + job_id: jobId, + }); + + await expect(call("deleteSampleFn", { sampleId })).rejects.toThrow(); + expect(setResponseStatus).toHaveBeenCalledWith(400); + + // The sample must survive the rejected delete. + expect( + await db + .select() + .from(legacySamples) + .where(eq(legacySamples.id, sampleId)), + ).toHaveLength(1); + }); +}); diff --git a/apps/web/src/server/samples/functions.ts b/apps/web/src/server/samples/functions.ts new file mode 100644 index 000000000..740e3a9c4 --- /dev/null +++ b/apps/web/src/server/samples/functions.ts @@ -0,0 +1,257 @@ +import { createServerFn, createServerOnlyFn } from "@tanstack/react-start"; +import { setResponseStatus } from "@tanstack/react-start/server"; +import { SampleCreateRequest, SampleUpdateRequest } from "@virtool/contracts"; +import { z } from "zod"; +import { ForbiddenError } from "../auth/middleware"; +import { authenticated, permission } from "../auth/policy"; +import { db } from "../db/pg"; +import { ClientError } from "../errors"; +import { storage } from "../storage"; +import { UploadNotFoundError, UploadReservedError } from "../uploads/data"; +import { pageSchema, perPageSchema, rowIdSchema } from "../validation"; +import { + checkSampleRight, + createSample as createSampleImpl, + deleteSample as deleteSampleImpl, + findSamples as findSamplesImpl, + getSample as getSampleImpl, + getSampleOwnerId, + resolveSampleActor, + SampleFileDuplicateError, + SampleGroupNotFoundError, + SampleGroupRequiredError, + SampleLabelsNotFoundError, + SampleNameConflictError, + SampleNotFoundError, + type SampleRight, + SampleSubtractionsNotFoundError, + updateSample as updateSampleImpl, + updateSampleRights as updateSampleRightsImpl, +} from "./data"; + +const sampleIdSchema = z.object({ + sampleId: rowIdSchema, +}); + +const findSamplesSchema = z.object({ + page: pageSchema, + perPage: perPageSchema, + term: z.string().default(""), + labels: z.array(rowIdSchema).default([]), + workflows: z.array(z.string()).default([]), + users: z.array(rowIdSchema).default([]), +}); + +// The group id (or legacy string), or null when none applies. `""` and `"none"` +// mean "no group", matching the Python request validator. +const groupSchema = z.union([z.number().int(), z.string(), z.null()]); + +const updateSampleSchema = sampleIdSchema.extend(SampleUpdateRequest.shape); + +const updateRightsSchema = sampleIdSchema.extend({ + allRead: z.boolean().optional(), + allWrite: z.boolean().optional(), + group: groupSchema.optional(), + groupRead: z.boolean().optional(), + groupWrite: z.boolean().optional(), +}); + +// Job states from which a sample may be deleted: the terminal states, where the +// creation job is finished and will not resume. +const DELETABLE_JOB_STATES = new Set(["cancelled", "failed", "succeeded"]); + +// Wrapped in createServerOnlyFn so the compiler can strip this body — and the +// ./data imports it references — from the client bundle. A plain top-level +// helper would pin ./data and its postgres transitive dependency in the client +// graph. +const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { + if (err instanceof SampleNotFoundError) { + setResponseStatus(404); + throw new ClientError("Sample not found.", 404); + } + if (err instanceof SampleNameConflictError) { + setResponseStatus(400); + throw new ClientError("Sample name is already in use.", 400); + } + if ( + err instanceof SampleLabelsNotFoundError || + err instanceof SampleSubtractionsNotFoundError || + err instanceof SampleGroupNotFoundError || + err instanceof SampleGroupRequiredError || + err instanceof SampleFileDuplicateError + ) { + setResponseStatus(400); + throw new ClientError(err.message, 400); + } + if (err instanceof UploadNotFoundError) { + setResponseStatus(400); + throw new ClientError("File does not exist.", 400); + } + if (err instanceof UploadReservedError) { + setResponseStatus(400); + throw new ClientError("File is already reserved.", 400); + } + throw err; +}); + +// The `authenticated()` floor guarantees a signed-in caller; this enforces the +// per-sample right the operation needs on top of it. A lookup for a nonexistent +// sample returns "allowed", so the subsequent fetch surfaces the 404 rather than +// a misleading 403. +const authorizeSample = createServerOnlyFn( + async ( + sampleId: number, + userId: number, + right: SampleRight, + ): Promise => { + const actor = await resolveSampleActor(db, userId); + + if (!(await checkSampleRight(db, sampleId, actor, right))) { + setResponseStatus(403); + throw new ForbiddenError(); + } + }, +); + +function coerceGroup(group: string | number | null | undefined): number | null { + if (group == null || group === "" || group === "none") { + return null; + } + return typeof group === "number" ? group : Number(group); +} + +export const findSamplesFn = createServerFn({ method: "GET" }) + .middleware([authenticated()]) + .validator(findSamplesSchema) + .handler(async ({ context, data }) => { + const actor = await resolveSampleActor(db, context.session.userId); + + return findSamplesImpl( + db, + { + page: data.page, + perPage: data.perPage, + term: data.term, + labels: data.labels, + users: data.users, + workflows: data.workflows, + }, + actor, + ); + }); + +export const getSampleFn = createServerFn({ method: "GET" }) + .middleware([authenticated()]) + .validator(sampleIdSchema) + .handler(async ({ context, data }) => { + try { + await authorizeSample(data.sampleId, context.session.userId, "read"); + return await getSampleImpl(db, data.sampleId); + } catch (err) { + return rethrowAsHttp(err); + } + }); + +export const createSampleFn = createServerFn({ method: "POST" }) + .middleware([permission("create_sample")]) + .validator(SampleCreateRequest) + .handler(async ({ context, data }) => { + try { + const sample = await createSampleImpl(db, { + name: data.name, + host: data.host, + isolate: data.isolate, + locale: data.locale, + notes: data.notes, + libraryType: data.libraryType, + group: coerceGroup(data.group), + subtractions: data.subtractions, + labels: data.labels, + files: data.files, + userId: context.session.userId, + }); + setResponseStatus(201); + return sample; + } catch (err) { + return rethrowAsHttp(err); + } + }); + +export const updateSampleFn = createServerFn({ method: "POST" }) + .middleware([authenticated()]) + .validator(updateSampleSchema) + .handler(async ({ context, data }) => { + const { sampleId, ...values } = data; + try { + await authorizeSample(sampleId, context.session.userId, "write"); + return await updateSampleImpl(db, sampleId, values); + } catch (err) { + return rethrowAsHttp(err); + } + }); + +export const deleteSampleFn = createServerFn({ method: "POST" }) + .middleware([authenticated()]) + .validator(sampleIdSchema) + .handler(async ({ context, data }) => { + try { + await authorizeSample(data.sampleId, context.session.userId, "write"); + + const sample = await getSampleImpl(db, data.sampleId); + + // A sample whose creation job is still running cannot be deleted. + if (!sample.ready) { + if (!sample.job) { + setResponseStatus(400); + throw new ClientError( + "Unfinalized samples without jobs cannot be deleted.", + 400, + ); + } + if (!DELETABLE_JOB_STATES.has(sample.job.state)) { + setResponseStatus(400); + throw new ClientError( + `Cannot delete sample with active job (current state: ${sample.job.state}).`, + 400, + ); + } + } + + await deleteSampleImpl(db, storage, data.sampleId); + setResponseStatus(204); + return null; + } catch (err) { + return rethrowAsHttp(err); + } + }); + +export const updateSampleRightsFn = createServerFn({ method: "POST" }) + .middleware([authenticated()]) + .validator(updateRightsSchema) + .handler(async ({ context, data }) => { + const { sampleId, ...values } = data; + + // Rights mutation is gated more tightly than read/write: only the owner or + // a full administrator may change them. A missing sample is a 404, not a + // 403 — the owner lookup returns null for both. + const ownerId = await getSampleOwnerId(db, sampleId); + + if (ownerId === null) { + setResponseStatus(404); + throw new ClientError("Sample not found.", 404); + } + + const actor = await resolveSampleActor(db, context.session.userId); + + // Only the owner or a full administrator may change a sample's rights. + if (!actor.isAdmin && actor.userId !== ownerId) { + setResponseStatus(403); + throw new ForbiddenError(); + } + + try { + return await updateSampleRightsImpl(db, sampleId, values); + } catch (err) { + return rethrowAsHttp(err); + } + }); diff --git a/apps/web/src/server/settings/functions.test.ts b/apps/web/src/server/settings/functions.test.ts index ec579dc6d..a751870d8 100644 --- a/apps/web/src/server/settings/functions.test.ts +++ b/apps/web/src/server/settings/functions.test.ts @@ -95,12 +95,14 @@ function call(name: string, data?: unknown) { describe("getSettings", () => { it("refuses an unauthenticated caller", async () => { - await expect(call("getSettings")).rejects.toBeInstanceOf(UnauthorizedError); + await expect(call("getSettingsFn")).rejects.toBeInstanceOf( + UnauthorizedError, + ); }); it("refuses a caller without the settings role", async () => { await signIn("base"); - await expect(call("getSettings")).rejects.toBeInstanceOf(ForbiddenError); + await expect(call("getSettingsFn")).rejects.toBeInstanceOf(ForbiddenError); }); it("returns the settings for a settings administrator", async () => { @@ -112,7 +114,7 @@ describe("getSettings", () => { sampleGroup: "force_choice", }); - await expect(call("getSettings")).resolves.toMatchObject({ + await expect(call("getSettingsFn")).resolves.toMatchObject({ defaultSourceTypes: ["genotype"], enableApi: true, minimumPasswordLength: 12, @@ -124,14 +126,14 @@ describe("getSettings", () => { describe("updateSettings", () => { it("refuses an unauthenticated caller", async () => { await expect( - call("updateSettings", { enableApi: true }), + call("updateSettingsFn", { enableApi: true }), ).rejects.toBeInstanceOf(UnauthorizedError); }); it("refuses a caller without the settings role", async () => { await signIn("base"); await expect( - call("updateSettings", { enableApi: true }), + call("updateSettingsFn", { enableApi: true }), ).rejects.toBeInstanceOf(ForbiddenError); }); @@ -140,7 +142,7 @@ describe("updateSettings", () => { await seedSettings(db, { enableApi: false }); await expect( - call("updateSettings", { + call("updateSettingsFn", { enableApi: true, defaultSourceTypes: ["strain"], }), @@ -159,12 +161,12 @@ describe("updateSettings", () => { it("rejects an invalid sample group", async () => { await signIn("settings"); await expect( - call("updateSettings", { sampleGroup: "everyone" }), + call("updateSettingsFn", { sampleGroup: "everyone" }), ).rejects.toThrow(); }); it("rejects an empty patch", async () => { await signIn("settings"); - await expect(call("updateSettings", {})).rejects.toThrow(); + await expect(call("updateSettingsFn", {})).rejects.toThrow(); }); }); diff --git a/apps/web/src/server/settings/functions.ts b/apps/web/src/server/settings/functions.ts index d17032044..31257ed6a 100644 --- a/apps/web/src/server/settings/functions.ts +++ b/apps/web/src/server/settings/functions.ts @@ -54,11 +54,11 @@ const updateSettingsSchema = z message: "At least one setting must be provided.", }); -export const getSettings = createServerFn({ method: "GET" }) +export const getSettingsFn = createServerFn({ method: "GET" }) .middleware([adminRole("settings")]) .handler(async (): Promise => getSettingsImpl(db)); -export const updateSettings = createServerFn({ method: "POST" }) +export const updateSettingsFn = createServerFn({ method: "POST" }) .middleware([adminRole("settings")]) .validator(updateSettingsSchema) .handler(async ({ data }): Promise => updateSettingsImpl(db, data)); diff --git a/apps/web/src/server/subtraction/data.test.ts b/apps/web/src/server/subtraction/data.test.ts index b4661f701..88be63ec0 100644 --- a/apps/web/src/server/subtraction/data.test.ts +++ b/apps/web/src/server/subtraction/data.test.ts @@ -226,7 +226,11 @@ describe("getSubtraction", () => { const sampleId = takeFirstOrThrow( await db .insert(legacySamples) - .values({ name: "Sample A" }) + .values({ + name: "Sample A", + library_type: "normal", + created_at: new Date(), + }) .returning({ id: legacySamples.id }), ).id; @@ -336,7 +340,11 @@ describe("deleteSubtraction", () => { const sampleId = takeFirstOrThrow( await db .insert(legacySamples) - .values({ name: "Sample A" }) + .values({ + name: "Sample A", + library_type: "normal", + created_at: new Date(), + }) .returning({ id: legacySamples.id }), ).id; diff --git a/apps/web/src/server/subtraction/functions.test.ts b/apps/web/src/server/subtraction/functions.test.ts index 02e228651..44e4f6c7a 100644 --- a/apps/web/src/server/subtraction/functions.test.ts +++ b/apps/web/src/server/subtraction/functions.test.ts @@ -114,7 +114,7 @@ describe("createSubtraction", () => { const uploadId = await seedUpload(userId); await expect( - call("createSubtraction", { + call("createSubtractionFn", { name: "Arabidopsis", nickname: "", uploadId, @@ -125,7 +125,7 @@ describe("createSubtraction", () => { it("refuses an unauthenticated caller", async () => { await expect( - call("createSubtraction", { + call("createSubtractionFn", { name: "Arabidopsis", nickname: "", uploadId: 1, @@ -137,7 +137,7 @@ describe("createSubtraction", () => { const userId = await signIn("full"); const uploadId = await seedUpload(userId); - const subtraction = (await call("createSubtraction", { + const subtraction = (await call("createSubtractionFn", { name: "Arabidopsis", nickname: "plant", uploadId, @@ -152,7 +152,7 @@ describe("createSubtraction", () => { await signIn("full"); await expect( - call("createSubtraction", { + call("createSubtractionFn", { name: "Arabidopsis", nickname: "", uploadId: 999_999, @@ -167,7 +167,7 @@ describe("getSubtraction", () => { await signIn(null); await expect( - call("getSubtraction", { subtractionId: 999_999 }), + call("getSubtractionFn", { subtractionId: 999_999 }), ).rejects.toThrow("Subtraction not found."); expect(setResponseStatus).toHaveBeenCalledWith(404); }); @@ -178,7 +178,7 @@ describe("deleteSubtraction", () => { await signIn(null); await expect( - call("deleteSubtraction", { subtractionId: 1 }), + call("deleteSubtractionFn", { subtractionId: 1 }), ).rejects.toBeInstanceOf(ForbiddenError); }); }); diff --git a/apps/web/src/server/subtraction/functions.ts b/apps/web/src/server/subtraction/functions.ts index c40c7665e..8fd172662 100644 --- a/apps/web/src/server/subtraction/functions.ts +++ b/apps/web/src/server/subtraction/functions.ts @@ -52,7 +52,7 @@ const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { throw err; }); -export const findSubtractions = createServerFn({ method: "GET" }) +export const findSubtractionsFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(findSubtractionsSchema) .handler(async ({ data }) => @@ -64,11 +64,11 @@ export const findSubtractions = createServerFn({ method: "GET" }) }), ); -export const listSubtractionsShortlist = createServerFn({ method: "GET" }) +export const listSubtractionsShortlistFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .handler(async () => listSubtractionsShortlistImpl(db)); -export const getSubtraction = createServerFn({ method: "GET" }) +export const getSubtractionFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(subtractionIdSchema) .handler(async ({ data }) => { @@ -79,7 +79,7 @@ export const getSubtraction = createServerFn({ method: "GET" }) } }); -export const createSubtraction = createServerFn({ method: "POST" }) +export const createSubtractionFn = createServerFn({ method: "POST" }) .middleware([permission("modify_subtraction")]) .validator(createSubtractionSchema) .handler(async ({ context, data }) => { @@ -97,7 +97,7 @@ export const createSubtraction = createServerFn({ method: "POST" }) } }); -export const updateSubtraction = createServerFn({ method: "POST" }) +export const updateSubtractionFn = createServerFn({ method: "POST" }) .middleware([permission("modify_subtraction")]) .validator(updateSubtractionSchema) .handler(async ({ data }) => { @@ -109,7 +109,7 @@ export const updateSubtraction = createServerFn({ method: "POST" }) } }); -export const deleteSubtraction = createServerFn({ method: "POST" }) +export const deleteSubtractionFn = createServerFn({ method: "POST" }) .middleware([permission("modify_subtraction")]) .validator(subtractionIdSchema) .handler(async ({ data }) => { diff --git a/apps/web/src/server/tasks/functions.ts b/apps/web/src/server/tasks/functions.ts index 154a4d377..e4d7e85f1 100644 --- a/apps/web/src/server/tasks/functions.ts +++ b/apps/web/src/server/tasks/functions.ts @@ -23,7 +23,7 @@ const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { throw err; }); -export const getTask = createServerFn({ method: "GET" }) +export const getTaskFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(taskIdSchema) .handler(async ({ data }) => { diff --git a/apps/web/src/server/uploads/data.ts b/apps/web/src/server/uploads/data.ts index ebfff6035..1a2e5647a 100644 --- a/apps/web/src/server/uploads/data.ts +++ b/apps/web/src/server/uploads/data.ts @@ -1,4 +1,4 @@ -import { and, count, desc, eq } from "drizzle-orm"; +import { and, count, desc, eq, inArray } from "drizzle-orm"; import type { DbOrTx } from "../db/pg"; import { takeFirstOrThrow } from "../db/rows"; import { type UploadRow, uploads as uploadsTable } from "../db/schema/uploads"; @@ -190,6 +190,62 @@ export async function createUpload( return toUpload(row, await fetchUser(db, row.userId)); } +/** + * Reserve the given uploads so they cannot be used for another sample. + * + * Only a visible reads upload is a valid sample input, so every id must resolve + * to a `reads` upload that is `ready` and not `removed`; any id that does not — + * a `reference`/`subtraction` upload, an unfinished one, or a removed one — is + * rejected with {@link UploadNotFoundError}, exactly as a missing id is. This + * runs before any reservation, so a bad batch reserves none. + * + * If one is already reserved a {@link UploadReservedError} is thrown. The final + * update is conditional on `reserved = false` and its row count checked, so a + * request that loses a race to reserve one of these uploads fails rather than + * double-reserving. Takes `DbOrTx` to run inside the caller's transaction; the + * caller commits. + */ +export async function reserveUploads( + db: DbOrTx, + uploadIds: number[], +): Promise { + const ids = [...new Set(uploadIds)]; + + if (ids.length === 0) { + return; + } + + const existing = await db + .select({ id: uploadsTable.id, reserved: uploadsTable.reserved }) + .from(uploadsTable) + .where( + and( + inArray(uploadsTable.id, ids), + eq(uploadsTable.type, "reads"), + eq(uploadsTable.ready, true), + eq(uploadsTable.removed, false), + ), + ); + + if (existing.length !== ids.length) { + throw new UploadNotFoundError(); + } + + if (existing.some((row) => row.reserved)) { + throw new UploadReservedError(); + } + + const reserved = await db + .update(uploadsTable) + .set({ reserved: true }) + .where(and(inArray(uploadsTable.id, ids), eq(uploadsTable.reserved, false))) + .returning({ id: uploadsTable.id }); + + if (reserved.length !== ids.length) { + throw new UploadReservedError(); + } +} + export async function deleteUpload( db: DbOrTx, storage: StorageBackend, diff --git a/apps/web/src/server/uploads/functions.test.ts b/apps/web/src/server/uploads/functions.test.ts index f12dbe53c..2f2774dd6 100644 --- a/apps/web/src/server/uploads/functions.test.ts +++ b/apps/web/src/server/uploads/functions.test.ts @@ -123,7 +123,7 @@ describe("findUploads", () => { const userId = await signIn(null); await seedUpload(userId, { name: "reads.fq.gz" }); - const result = (await call("findUploads", { + const result = (await call("findUploadsFn", { page: 1, per_page: 25, })) as { items: { name: string }[] }; @@ -138,7 +138,7 @@ describe("deleteUpload", () => { const upload = await seedUpload(userId); await expect( - call("deleteUpload", { id: upload.id }), + call("deleteUploadFn", { id: upload.id }), ).rejects.toBeInstanceOf(ForbiddenError); expect(setResponseStatus).toHaveBeenCalledWith(403); }); @@ -147,7 +147,7 @@ describe("deleteUpload", () => { const userId = await signIn("full"); const upload = await seedUpload(userId); - await call("deleteUpload", { id: upload.id }); + await call("deleteUploadFn", { id: upload.id }); const [row] = await db.select().from(uploadsTable); expect(row?.removed).toBe(true); @@ -157,7 +157,7 @@ describe("deleteUpload", () => { it("maps a missing upload to a 404", async () => { await signIn("full"); - await expect(call("deleteUpload", { id: 404 })).rejects.toThrow( + await expect(call("deleteUploadFn", { id: 404 })).rejects.toThrow( "Upload not found.", ); expect(setResponseStatus).toHaveBeenCalledWith(404); @@ -167,7 +167,7 @@ describe("deleteUpload", () => { const userId = await signIn("full"); const upload = await seedUpload(userId, { reserved: true }); - await expect(call("deleteUpload", { id: upload.id })).rejects.toThrow( + await expect(call("deleteUploadFn", { id: upload.id })).rejects.toThrow( "Upload is reserved and in use.", ); expect(setResponseStatus).toHaveBeenCalledWith(409); diff --git a/apps/web/src/server/uploads/functions.ts b/apps/web/src/server/uploads/functions.ts index cd7868e65..33e3d1de5 100644 --- a/apps/web/src/server/uploads/functions.ts +++ b/apps/web/src/server/uploads/functions.ts @@ -47,7 +47,7 @@ const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { throw err; }); -export const findUploads = createServerFn({ method: "GET" }) +export const findUploadsFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(findUploadsSchema) .handler(async ({ data }) => @@ -60,7 +60,7 @@ export const findUploads = createServerFn({ method: "GET" }) ), ); -export const deleteUpload = createServerFn({ method: "POST" }) +export const deleteUploadFn = createServerFn({ method: "POST" }) .middleware([permission("remove_file")]) .validator(uploadIdSchema) .handler(async ({ data }) => { diff --git a/apps/web/src/server/users/functions.ts b/apps/web/src/server/users/functions.ts index 339200bef..e6812ab7c 100644 --- a/apps/web/src/server/users/functions.ts +++ b/apps/web/src/server/users/functions.ts @@ -114,17 +114,17 @@ const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { throw err; }); -export const listAdministratorRoles = createServerFn({ method: "GET" }) +export const listAdministratorRolesFn = createServerFn({ method: "GET" }) .middleware([adminRole("base")]) .handler(async () => listAdministratorRolesImpl()); // Any authenticated user can see who else exists — the handles are already // visible on samples, jobs, and analyses they can read. -export const listUsers = createServerFn({ method: "GET" }) +export const listUsersFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .handler(async () => listUsersImpl(db)); -export const findUsers = createServerFn({ method: "GET" }) +export const findUsersFn = createServerFn({ method: "GET" }) .middleware([adminRole("users")]) .validator(findUsersSchema) .handler(async ({ data }) => { @@ -140,9 +140,9 @@ export const findUsers = createServerFn({ method: "GET" }) // A paginated user search any signed-in user may run, mirroring Python's // `GET /users` (authenticated, no administrator filter). Backs the reference // member picker, where a non-admin who holds `modify` on a reference searches -// users to add. `findUsers` above is the stricter administrator-only variant +// users to add. `findUsersFn` above is the stricter administrator-only variant // used by the user administration views. -export const searchUsers = createServerFn({ method: "GET" }) +export const searchUsersFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(searchUsersSchema) .handler(async ({ data }) => @@ -156,11 +156,11 @@ export const searchUsers = createServerFn({ method: "GET" }) // Not on the authentication exception list, so an anonymous call gets a 401. // The login wall and the authenticated route guard both rely on that: a // rejected call is how they learn there is no session. -export const getAccount = createServerFn({ method: "GET" }) +export const getAccountFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .handler(async ({ context }) => getAccountImpl(db, context.session.userId)); -export const getUser = createServerFn({ method: "GET" }) +export const getUserFn = createServerFn({ method: "GET" }) .middleware([adminRole("users")]) .validator(userIdSchema) .handler(async ({ data }) => { @@ -173,7 +173,7 @@ export const getUser = createServerFn({ method: "GET" }) } }); -export const createUser = createServerFn({ method: "POST" }) +export const createUserFn = createServerFn({ method: "POST" }) .middleware([adminRole("users")]) .validator(createUserSchema) .handler(async ({ data }) => { @@ -194,7 +194,7 @@ export const createUser = createServerFn({ method: "POST" }) } }); -export const updateUser = createServerFn({ method: "POST" }) +export const updateUserFn = createServerFn({ method: "POST" }) .middleware([adminRole("users")]) .validator(updateUserSchema) .handler(async ({ context, data }) => { @@ -220,7 +220,7 @@ export const updateUser = createServerFn({ method: "POST" }) } }); -export const updateAccountHandle = createServerFn({ method: "POST" }) +export const updateAccountHandleFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(accountHandleSchema) .handler(async ({ context, data }) => { @@ -235,7 +235,7 @@ export const updateAccountHandle = createServerFn({ method: "POST" }) } }); -export const setAdministratorRole = createServerFn({ method: "POST" }) +export const setAdministratorRoleFn = createServerFn({ method: "POST" }) .middleware([adminRole("full")]) .validator(setAdministratorRoleSchema) .handler(async ({ context, data }) => { diff --git a/apps/web/src/subtraction/queries.ts b/apps/web/src/subtraction/queries.ts index fe1bfc9db..a1e05ab5c 100644 --- a/apps/web/src/subtraction/queries.ts +++ b/apps/web/src/subtraction/queries.ts @@ -1,10 +1,10 @@ import { - createSubtraction, - deleteSubtraction, - findSubtractions, - getSubtraction, - listSubtractionsShortlist, - updateSubtraction, + createSubtractionFn, + deleteSubtractionFn, + findSubtractionsFn, + getSubtractionFn, + listSubtractionsShortlistFn, + updateSubtractionFn, } from "@server/subtraction/functions"; import { subtractionQueryKeys } from "@subtraction/keys"; import { @@ -35,7 +35,7 @@ export function useCreateSubtraction() { { name: string; nickname: string; uploadId: number } >({ mutationFn: ({ name, nickname, uploadId }) => - createSubtraction({ + createSubtractionFn({ data: { name, nickname, uploadId }, }) as Promise, onSuccess: () => { @@ -61,7 +61,7 @@ export function subtractionsQueryOptions( return queryOptions({ queryKey: subtractionQueryKeys.list([page, per_page, term]), queryFn: () => - findSubtractions({ + findSubtractionsFn({ data: { page, per_page, term }, }) as Promise, }); @@ -94,7 +94,7 @@ export function useFetchSubtraction(subtractionId: number) { return useQuery({ queryKey: subtractionQueryKeys.detail(subtractionId), queryFn: () => - getSubtraction({ + getSubtractionFn({ data: { subtractionId }, }) as Promise, }); @@ -114,7 +114,7 @@ export function useUpdateSubtraction(subtractionId: number) { { name: string; nickname: string } >({ mutationFn: ({ name, nickname }) => - updateSubtraction({ + updateSubtractionFn({ data: { subtractionId, name, nickname }, }) as Promise, onSuccess: () => { @@ -133,7 +133,7 @@ export function useUpdateSubtraction(subtractionId: number) { export function useDeleteSubtraction() { return useMutation({ mutationFn: ({ subtractionId }) => - deleteSubtraction({ + deleteSubtractionFn({ data: { subtractionId }, }) as Promise, }); @@ -150,6 +150,7 @@ export function useDeleteSubtraction() { export function useFetchSubtractionsShortlist() { return useQuery({ queryKey: subtractionQueryKeys.shortlist(), - queryFn: () => listSubtractionsShortlist() as Promise, + queryFn: () => + listSubtractionsShortlistFn() as Promise, }); } diff --git a/apps/web/src/tasks/queries.ts b/apps/web/src/tasks/queries.ts index 0583024b9..439d3d5ba 100644 --- a/apps/web/src/tasks/queries.ts +++ b/apps/web/src/tasks/queries.ts @@ -1,4 +1,4 @@ -import { getTask } from "@server/tasks/functions"; +import { getTaskFn } from "@server/tasks/functions"; import { useQuery } from "@tanstack/react-query"; import { taskQueryKeys } from "@tasks/keys"; import { type ServerTask, TaskSchema } from "./types"; @@ -17,7 +17,7 @@ import { type ServerTask, TaskSchema } from "./types"; export function useFetchTask(taskId: number, seed?: ServerTask) { return useQuery({ queryKey: taskQueryKeys.detail(taskId), - queryFn: () => getTask({ data: { taskId } }), + queryFn: () => getTaskFn({ data: { taskId } }), select: TaskSchema.parse, enabled: Number.isInteger(taskId), initialData: seed ? TaskSchema.parse(seed) : undefined, diff --git a/apps/web/src/tests/api/samples.ts b/apps/web/src/tests/api/samples.ts deleted file mode 100644 index c4f50d4f9..000000000 --- a/apps/web/src/tests/api/samples.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { faker } from "@faker-js/faker"; -import type { - LibraryType, - Sample, - SampleMinimal, - SampleRightsUpdate, -} from "@samples/types"; -import nock from "nock"; -import { createFakeSample } from "../fake/samples"; -import { createFakeSubtractionNested } from "../fake/subtractions"; - -/** - * Creates a mocked API call for getting a paginated list of samples - * - * @param samples - The sample documents - * @param counts - Overrides for the counts, which otherwise both match the - * number of documents. ``total_count`` is every sample the user may see and - * ``found_count`` is only those matching the filters. - * @returns The nock scope for the mocked API call - */ -export function mockApiGetSamples( - samples: SampleMinimal[], - counts: { found_count?: number; total_count?: number } = {}, -) { - return nock("http://localhost") - .get("/api/samples") - .query(true) - .reply(200, { - page: 1, - page_count: 1, - per_page: 5, - total_count: counts.total_count ?? samples.length, - found_count: counts.found_count ?? samples.length, - documents: samples, - }); -} - -/** - * Sets up a mocked API route for fetching a single sample - * - * @param sampleDetail - The sample detail to be returned from the mocked API call - * @param statusCode - The HTTP status code to simulate in the response - * @returns The nock scope for the mocked API call - */ -export function mockApiGetSampleDetail( - sampleDetail: Sample, - statusCode?: number, -) { - return nock("http://localhost") - .get(`/api/samples/${sampleDetail.id}`) - .query(true) - .reply(statusCode || 200, sampleDetail); -} - -/** - * Sets up a mocked API route for updating the sample details - * - * @param sample - The sample details - * @param name - The updated name - * @param isolate - The updated isolate - * @param host - The updated host - * @param locale - The updated locale - * @param notes - The updated notes - * @returns A nock scope for the mocked API call - */ -export function mockApiEditSample( - sample: Sample, - name: string, - isolate: string, - host: string, - locale: string, - notes: string, -) { - const sampleDetail = { ...sample, name, isolate, host, locale, notes }; - - return nock("http://localhost") - .patch(`/api/samples/${sample.id}`) - .reply(200, sampleDetail); -} - -/** - * Sets up a mocked API route for updating a samples rights - * - * @param sample - The sample details - * @param update - The update to be applied - * @returns A nock scope for the mocked API call - */ -export function mockApiUpdateSampleRights( - sample: Sample, - update: SampleRightsUpdate, -) { - const sampleRightsUpdate = { - all_read: faker.datatype.boolean(), - all_write: faker.datatype.boolean(), - group: null, - group_read: faker.datatype.boolean(), - group_write: faker.datatype.boolean(), - user: { id: sample.user.id }, - ...update, - }; - - return nock("http://localhost") - .patch(`/api/samples/${sample.id}/rights`) - .reply(200, sampleRightsUpdate); -} - -/** - * Creates a mocked API call for creating a sample - * - * @param name - The name of the sample - * @param isolate - The sample isolate - * @param host - The sample host - * @param locale - The locale of the sample - * @param library_type - The library type of the sample - * @param files - The sample uploads used to create the sample - * @param labels - The labels associated with the sample - * @param subtractions - The subtractions associated with the sample - * @param group - The group associated with the sample - * @returns The nock scope for the mocked API call - */ -export function mockApiCreateSample( - name: string, - isolate: string, - host: string, - locale: string, - library_type: LibraryType, - files: number[], - labels: number[], - subtractions: number[], - group: string | null, -) { - const sample = createFakeSample({ - name, - isolate, - host, - locale, - library_type, - subtractions: [createFakeSubtractionNested({ id: subtractions[0] })], - }); - - return nock("http://localhost") - .post("/api/samples", { - name, - isolate, - host, - locale, - library_type, - files, - labels, - subtractions, - group, - }) - .reply(201, sample); -} - -/** - * Creates a mocked API call for removing a sample - * - * @param sampleId - The id of the sample being removed - * @returns The nock scope for the mocked API call - */ -export function mockApiRemoveSample(sampleId: number) { - return nock("http://localhost").delete(`/api/samples/${sampleId}`).reply(200); -} diff --git a/apps/web/src/tests/fake/references.ts b/apps/web/src/tests/fake/references.ts index 18e9c8e7a..82ad09afc 100644 --- a/apps/web/src/tests/fake/references.ts +++ b/apps/web/src/tests/fake/references.ts @@ -1,9 +1,6 @@ import { faker } from "@faker-js/faker"; -import type { - Reference, - ReferenceMinimal, - ReferenceNested, -} from "@references/types"; +import type { ReferenceNested } from "@references/types"; +import type { Reference, ReferenceMinimal } from "@virtool/contracts"; import { createFakeUserNested } from "./user"; /** diff --git a/apps/web/src/tests/fake/samples.ts b/apps/web/src/tests/fake/samples.ts index cf8625937..237817653 100644 --- a/apps/web/src/tests/fake/samples.ts +++ b/apps/web/src/tests/fake/samples.ts @@ -1,6 +1,5 @@ import { faker } from "@faker-js/faker"; -import type { Quality, Read, Sample, SampleMinimal } from "@samples/types"; -import { createFakeServerJobNested } from "./jobs"; +import type { Quality, Read, Sample, SampleMinimal } from "@virtool/contracts"; import { createFakeLabelNested } from "./labels"; import { createFakeSubtractionNested } from "./subtractions"; import { createFakeUserNested } from "./user"; @@ -16,12 +15,19 @@ export function createFakeSampleMinimal( const defaultSampleMinimal: SampleMinimal = { id: faker.number.int(), name: `${faker.word.noun({ strategy: "any-length" })} ${faker.number.int()}`, - created_at: faker.date.past().toISOString(), + createdAt: faker.date.past().toISOString(), host: faker.word.noun({ strategy: "any-length" }), isolate: faker.word.noun({ strategy: "any-length" }), - job: createFakeServerJobNested({ workflow: "create_sample" }), + job: { + createdAt: faker.date.past().toISOString(), + id: faker.number.int(), + progress: 100, + state: "succeeded", + user: createFakeUserNested(), + workflow: "create_sample", + }, labels: [createFakeLabelNested()], - library_type: "normal", + libraryType: "normal", notes: faker.lorem.lines(5), nuvs: faker.datatype.boolean(), pathoscope: faker.datatype.boolean(), @@ -41,13 +47,13 @@ export function createFakeSampleMinimal( */ export function createFakeSampleRead(overrides?: Partial): Read { const defaultRead = { - download_url: faker.word.noun({ strategy: "any-length" }), + downloadUrl: faker.word.noun({ strategy: "any-length" }), id: faker.number.int(), name: faker.word.noun({ strategy: "any-length" }), - name_on_disk: faker.word.noun({ strategy: "any-length" }), + nameOnDisk: faker.word.noun({ strategy: "any-length" }), sample: faker.number.int(), size: faker.number.int(), - uploaded_at: faker.date.past().toISOString(), + uploadedAt: faker.date.past().toISOString(), }; return { ...defaultRead, ...overrides }; @@ -71,15 +77,15 @@ export function createFakeSampleQuality(): Quality { export function createFakeSample(overrides?: Partial): Sample { const defaultSample = { ...createFakeSampleMinimal(), - all_read: faker.datatype.boolean(), - all_write: faker.datatype.boolean(), + allRead: faker.datatype.boolean(), + allWrite: faker.datatype.boolean(), artifacts: [], format: "fastq", group: null, - group_read: faker.datatype.boolean(), - group_write: faker.datatype.boolean(), + groupRead: faker.datatype.boolean(), + groupWrite: faker.datatype.boolean(), hold: faker.datatype.boolean(), - is_legacy: faker.datatype.boolean(), + isLegacy: faker.datatype.boolean(), locale: faker.location.country(), paired: faker.datatype.boolean(), quality: createFakeSampleQuality(), diff --git a/apps/web/src/tests/server-fn/account.ts b/apps/web/src/tests/server-fn/account.ts index 0389c0ab8..6a81bfae1 100644 --- a/apps/web/src/tests/server-fn/account.ts +++ b/apps/web/src/tests/server-fn/account.ts @@ -9,16 +9,16 @@ import { createFakeApiKey } from "../fake/account"; * view can stub them without per-file `vi.mock` boilerplate. */ export const accountServerFnMocks = { - findApiKeys: vi.fn(), - createApiKey: vi.fn(), - updateApiKey: vi.fn(), - deleteApiKey: vi.fn(), + findApiKeysFn: vi.fn(), + createApiKeyFn: vi.fn(), + updateApiKeyFn: vi.fn(), + deleteApiKeyFn: vi.fn(), }; /** Sets up findApiKeys to resolve with the given API keys. */ export function mockFindApiKeys(apiKeys: ApiKey[]): Mock { - accountServerFnMocks.findApiKeys.mockResolvedValue(apiKeys); - return accountServerFnMocks.findApiKeys; + accountServerFnMocks.findApiKeysFn.mockResolvedValue(apiKeys); + return accountServerFnMocks.findApiKeysFn; } /** @@ -30,9 +30,9 @@ export function mockCreateApiKey( permissions: Permissions, overrides?: Partial, ): Mock { - accountServerFnMocks.createApiKey.mockResolvedValue({ + accountServerFnMocks.createApiKeyFn.mockResolvedValue({ ...createFakeApiKey({ permissions, ...overrides }), key, }); - return accountServerFnMocks.createApiKey; + return accountServerFnMocks.createApiKeyFn; } diff --git a/apps/web/src/tests/server-fn/groups.ts b/apps/web/src/tests/server-fn/groups.ts index 040906ae9..af7c15356 100644 --- a/apps/web/src/tests/server-fn/groups.ts +++ b/apps/web/src/tests/server-fn/groups.ts @@ -7,23 +7,23 @@ import { type Mock, vi } from "vitest"; * the groups server functions without per-file `vi.mock` boilerplate. */ export const groupServerFnMocks = { - listGroups: vi.fn(), - findGroups: vi.fn(), - getGroup: vi.fn(), - createGroup: vi.fn(), - updateGroup: vi.fn(), - deleteGroup: vi.fn(), + listGroupsFn: vi.fn(), + findGroupsFn: vi.fn(), + getGroupFn: vi.fn(), + createGroupFn: vi.fn(), + updateGroupFn: vi.fn(), + deleteGroupFn: vi.fn(), }; /** Sets up the listGroups server fn to resolve with the provided groups. */ export function mockListGroups(groups: Group[]): Mock { - groupServerFnMocks.listGroups.mockResolvedValue(groups); - return groupServerFnMocks.listGroups; + groupServerFnMocks.listGroupsFn.mockResolvedValue(groups); + return groupServerFnMocks.listGroupsFn; } /** Sets up the getGroup server fn to resolve with the provided group. */ export function mockGetGroup(group: Group): Mock { - groupServerFnMocks.getGroup.mockImplementation( + groupServerFnMocks.getGroupFn.mockImplementation( async ({ data }: { data: { groupId: number } }) => { if (data.groupId === group.id) { return group; @@ -31,5 +31,5 @@ export function mockGetGroup(group: Group): Mock { throw new Error(`unexpected groupId in mockGetGroup: ${data.groupId}`); }, ); - return groupServerFnMocks.getGroup; + return groupServerFnMocks.getGroupFn; } diff --git a/apps/web/src/tests/server-fn/hmm.ts b/apps/web/src/tests/server-fn/hmm.ts index d2f151546..188833685 100644 --- a/apps/web/src/tests/server-fn/hmm.ts +++ b/apps/web/src/tests/server-fn/hmm.ts @@ -7,21 +7,21 @@ import { type Mock, vi } from "vitest"; * these without per-file `vi.mock` boilerplate. */ export const hmmServerFnMocks = { - findHmms: vi.fn(), - getHmm: vi.fn(), - installHmm: vi.fn(), + findHmmsFn: vi.fn(), + getHmmFn: vi.fn(), + installHmmFn: vi.fn(), }; /** Sets up findHmms to resolve with the given search results. */ export function mockFindHmms(searchResults: HmmSearchResult): Mock { - hmmServerFnMocks.findHmms.mockResolvedValue(searchResults); - return hmmServerFnMocks.findHmms; + hmmServerFnMocks.findHmmsFn.mockResolvedValue(searchResults); + return hmmServerFnMocks.findHmmsFn; } /** Sets up getHmm to resolve with the given HMM. */ export function mockGetHmm(hmm: Hmm): Mock { - hmmServerFnMocks.getHmm.mockResolvedValue(hmm); - return hmmServerFnMocks.getHmm; + hmmServerFnMocks.getHmmFn.mockResolvedValue(hmm); + return hmmServerFnMocks.getHmmFn; } /** @@ -29,8 +29,8 @@ export function mockGetHmm(hmm: Hmm): Mock { * a server function surfaces a 404 to the client. */ export function mockGetHmmError(status: number): Mock { - hmmServerFnMocks.getHmm.mockRejectedValue( + hmmServerFnMocks.getHmmFn.mockRejectedValue( Object.assign(new Error("HMM not found."), { status }), ); - return hmmServerFnMocks.getHmm; + return hmmServerFnMocks.getHmmFn; } diff --git a/apps/web/src/tests/server-fn/jobs.ts b/apps/web/src/tests/server-fn/jobs.ts index 14a347045..ee31cd0c3 100644 --- a/apps/web/src/tests/server-fn/jobs.ts +++ b/apps/web/src/tests/server-fn/jobs.ts @@ -7,9 +7,9 @@ import { type Mock, vi } from "vitest"; * the jobs server functions without per-file `vi.mock` boilerplate. */ export const jobServerFnMocks = { - findJobs: vi.fn(), - getJob: vi.fn(), - getJobs: vi.fn(), + findJobsFn: vi.fn(), + getJobFn: vi.fn(), + getJobsFn: vi.fn(), }; /** Sets up findJobs to resolve with a single page containing the given jobs. */ @@ -17,7 +17,7 @@ export function mockFindJobs( jobs: ServerJobMinimal[], found_count?: number, ): Mock { - jobServerFnMocks.findJobs.mockResolvedValue({ + jobServerFnMocks.findJobsFn.mockResolvedValue({ counts: {}, found_count: found_count ?? jobs.length, items: jobs, @@ -26,7 +26,7 @@ export function mockFindJobs( per_page: 25, total_count: jobs.length, }); - return jobServerFnMocks.findJobs; + return jobServerFnMocks.findJobsFn; } /** @@ -36,16 +36,16 @@ export function mockFindJobs( * the result rather than an error. */ export function mockGetJobs(jobs: ServerJob[]): Mock { - jobServerFnMocks.getJobs.mockImplementation( + jobServerFnMocks.getJobsFn.mockImplementation( async ({ data }: { data: { jobIds: number[] } }) => jobs.filter((job) => data.jobIds.includes(job.id)), ); - return jobServerFnMocks.getJobs; + return jobServerFnMocks.getJobsFn; } /** Sets up getJob to resolve with the given job when matched by id. */ export function mockGetJob(jobId: number, job: ServerJob): Mock { - jobServerFnMocks.getJob.mockImplementation( + jobServerFnMocks.getJobFn.mockImplementation( async ({ data }: { data: { jobId: number } }) => { if (data.jobId === jobId) { return job; @@ -53,5 +53,5 @@ export function mockGetJob(jobId: number, job: ServerJob): Mock { throw new Error(`unexpected jobId in mockGetJob: ${data.jobId}`); }, ); - return jobServerFnMocks.getJob; + return jobServerFnMocks.getJobFn; } diff --git a/apps/web/src/tests/server-fn/labels.ts b/apps/web/src/tests/server-fn/labels.ts index 009573ca2..08141ba43 100644 --- a/apps/web/src/tests/server-fn/labels.ts +++ b/apps/web/src/tests/server-fn/labels.ts @@ -7,15 +7,15 @@ import { type Mock, vi } from "vitest"; * labels can stub them without per-file `vi.mock` boilerplate. */ export const labelServerFnMocks = { - createLabel: vi.fn(), - deleteLabel: vi.fn(), - findLabels: vi.fn(), - getLabel: vi.fn(), - updateLabel: vi.fn(), + createLabelFn: vi.fn(), + deleteLabelFn: vi.fn(), + findLabelsFn: vi.fn(), + getLabelFn: vi.fn(), + updateLabelFn: vi.fn(), }; /** Sets up findLabels to resolve with the given labels. */ export function mockFindLabels(labels: Label[]): Mock { - labelServerFnMocks.findLabels.mockResolvedValue(labels); - return labelServerFnMocks.findLabels; + labelServerFnMocks.findLabelsFn.mockResolvedValue(labels); + return labelServerFnMocks.findLabelsFn; } diff --git a/apps/web/src/tests/server-fn/references.ts b/apps/web/src/tests/server-fn/references.ts index a0c7c502e..2f29618b2 100644 --- a/apps/web/src/tests/server-fn/references.ts +++ b/apps/web/src/tests/server-fn/references.ts @@ -1,4 +1,4 @@ -import type { Reference, ReferenceMinimal } from "@references/types"; +import type { Reference, ReferenceMinimal } from "@virtool/contracts"; import { type Mock, vi } from "vitest"; /** @@ -7,18 +7,18 @@ import { type Mock, vi } from "vitest"; * or mutates references can stub them without per-file `vi.mock` boilerplate. */ export const referenceServerFnMocks = { - findReferences: vi.fn(), - getReference: vi.fn(), - createReference: vi.fn(), - updateReference: vi.fn(), - archiveReference: vi.fn(), - unarchiveReference: vi.fn(), - addReferenceUser: vi.fn(), - addReferenceGroup: vi.fn(), - updateReferenceUser: vi.fn(), - updateReferenceGroup: vi.fn(), - removeReferenceUser: vi.fn(), - removeReferenceGroup: vi.fn(), + findReferencesFn: vi.fn(), + getReferenceFn: vi.fn(), + createReferenceFn: vi.fn(), + updateReferenceFn: vi.fn(), + archiveReferenceFn: vi.fn(), + unarchiveReferenceFn: vi.fn(), + addReferenceUserFn: vi.fn(), + addReferenceGroupFn: vi.fn(), + updateReferenceUserFn: vi.fn(), + updateReferenceGroupFn: vi.fn(), + removeReferenceUserFn: vi.fn(), + removeReferenceGroupFn: vi.fn(), }; /** Sets up findReferences to resolve with a single page of the given items. */ @@ -26,7 +26,7 @@ export function mockFindReferences( items: ReferenceMinimal[], overrides?: Partial<{ foundCount: number; totalCount: number }>, ): Mock { - referenceServerFnMocks.findReferences.mockResolvedValue({ + referenceServerFnMocks.findReferencesFn.mockResolvedValue({ foundCount: items.length, totalCount: items.length, page: 1, @@ -35,12 +35,12 @@ export function mockFindReferences( items, ...overrides, }); - return referenceServerFnMocks.findReferences; + return referenceServerFnMocks.findReferencesFn; } /** Sets up getReference to resolve with the given reference when matched. */ export function mockGetReference(reference: Reference): Mock { - referenceServerFnMocks.getReference.mockImplementation( + referenceServerFnMocks.getReferenceFn.mockImplementation( async ({ data }: { data: { referenceId: number } }) => { if (data.referenceId === reference.id) { return reference; @@ -50,23 +50,23 @@ export function mockGetReference(reference: Reference): Mock { ); }, ); - return referenceServerFnMocks.getReference; + return referenceServerFnMocks.getReferenceFn; } /** Sets up createReference to resolve with the given reference. */ export function mockCreateReference(reference: Reference): Mock { - referenceServerFnMocks.createReference.mockResolvedValue(reference); - return referenceServerFnMocks.createReference; + referenceServerFnMocks.createReferenceFn.mockResolvedValue(reference); + return referenceServerFnMocks.createReferenceFn; } /** Sets up archiveReference to resolve with the given reference. */ export function mockArchiveReference(reference: Reference): Mock { - referenceServerFnMocks.archiveReference.mockResolvedValue(reference); - return referenceServerFnMocks.archiveReference; + referenceServerFnMocks.archiveReferenceFn.mockResolvedValue(reference); + return referenceServerFnMocks.archiveReferenceFn; } /** Sets up unarchiveReference to resolve with the given reference. */ export function mockUnarchiveReference(reference: Reference): Mock { - referenceServerFnMocks.unarchiveReference.mockResolvedValue(reference); - return referenceServerFnMocks.unarchiveReference; + referenceServerFnMocks.unarchiveReferenceFn.mockResolvedValue(reference); + return referenceServerFnMocks.unarchiveReferenceFn; } diff --git a/apps/web/src/tests/server-fn/root.ts b/apps/web/src/tests/server-fn/root.ts index 04539c287..1a74e87d7 100644 --- a/apps/web/src/tests/server-fn/root.ts +++ b/apps/web/src/tests/server-fn/root.ts @@ -6,7 +6,7 @@ import { type Mock, vi } from "vitest"; * unauthenticated root document without per-file `vi.mock` boilerplate. */ export const rootServerFnMocks = { - getRoot: vi.fn(), + getRootFn: vi.fn(), }; /** @@ -17,9 +17,9 @@ export function mockGetRoot(root: { firstUser: boolean; version?: string; }): Mock { - rootServerFnMocks.getRoot.mockResolvedValue({ + rootServerFnMocks.getRootFn.mockResolvedValue({ version: __APP_VERSION__, ...root, }); - return rootServerFnMocks.getRoot; + return rootServerFnMocks.getRootFn; } diff --git a/apps/web/src/tests/server-fn/samples.ts b/apps/web/src/tests/server-fn/samples.ts new file mode 100644 index 000000000..df5e150f2 --- /dev/null +++ b/apps/web/src/tests/server-fn/samples.ts @@ -0,0 +1,108 @@ +import type { Sample, SampleMinimal } from "@virtool/contracts"; +import { type Mock, vi } from "vitest"; +import { createFakeSample } from "../fake/samples"; + +/** + * Mock handles for the `@server/samples/functions` server-fn module. Wired in + * globally from `tests/setup.tsx` so any test importing this helper can stub the + * sample server functions without per-file `vi.mock` boilerplate. + */ +export const sampleServerFnMocks = { + findSamplesFn: vi.fn(), + getSampleFn: vi.fn(), + createSampleFn: vi.fn(), + updateSampleFn: vi.fn(), + deleteSampleFn: vi.fn(), + updateSampleRightsFn: vi.fn(), +}; + +/** + * Sets up findSamples to resolve with a single page of the given samples. + * + * @param samples - the samples on the page + * @param counts - overrides for the counts, which otherwise both match the + * number of samples. `totalCount` is every sample the user may see and + * `foundCount` is only those matching the filters. + */ +export function mockFindSamples( + samples: SampleMinimal[], + counts: { foundCount?: number; totalCount?: number } = {}, +): Mock { + sampleServerFnMocks.findSamplesFn.mockResolvedValue({ + page: 1, + pageCount: 1, + perPage: 5, + totalCount: counts.totalCount ?? samples.length, + foundCount: counts.foundCount ?? samples.length, + items: samples, + }); + return sampleServerFnMocks.findSamplesFn; +} + +/** + * Sets up findSamples to serve one page per entry in `pages`, selecting by the + * requested `page`, so a selection made on one page can be asserted from + * another. + * + * @param pages - the samples on each page, in page order + */ +export function mockFindSamplePages(pages: SampleMinimal[][]): Mock { + sampleServerFnMocks.findSamplesFn.mockImplementation( + async ({ data }: { data?: { page?: number } }) => { + const page = data?.page ?? 1; + return { + page, + pageCount: pages.length, + perPage: 1, + totalCount: pages.length, + foundCount: pages.length, + items: pages[page - 1] ?? [], + }; + }, + ); + return sampleServerFnMocks.findSamplesFn; +} + +/** Sets up getSample to resolve with the given sample. */ +export function mockGetSample(sample: Sample): Mock { + sampleServerFnMocks.getSampleFn.mockResolvedValue(sample); + return sampleServerFnMocks.getSampleFn; +} + +/** Sets up createSample to resolve with the given (or a fake) sample. */ +export function mockCreateSample(sample?: Sample): Mock { + sampleServerFnMocks.createSampleFn.mockResolvedValue( + sample ?? createFakeSample(), + ); + return sampleServerFnMocks.createSampleFn; +} + +/** Sets up updateSample to resolve with the given sample, patched with the fields. */ +export function mockUpdateSample( + sample: Sample, + update: Partial = {}, +): Mock { + sampleServerFnMocks.updateSampleFn.mockResolvedValue({ + ...sample, + ...update, + }); + return sampleServerFnMocks.updateSampleFn; +} + +/** Sets up deleteSample to resolve. */ +export function mockDeleteSample(): Mock { + sampleServerFnMocks.deleteSampleFn.mockResolvedValue(null); + return sampleServerFnMocks.deleteSampleFn; +} + +/** Sets up updateSampleRights to resolve with the given sample, patched with the fields. */ +export function mockUpdateSampleRights( + sample: Sample, + update: Partial = {}, +): Mock { + sampleServerFnMocks.updateSampleRightsFn.mockResolvedValue({ + ...sample, + ...update, + }); + return sampleServerFnMocks.updateSampleRightsFn; +} diff --git a/apps/web/src/tests/server-fn/settings.ts b/apps/web/src/tests/server-fn/settings.ts index 5595fdc7f..7e9ef86e4 100644 --- a/apps/web/src/tests/server-fn/settings.ts +++ b/apps/web/src/tests/server-fn/settings.ts @@ -9,8 +9,8 @@ import { type Mock, vi } from "vitest"; */ export const settingsServerFnMocks = { getPasswordPolicyFn: vi.fn(), - getSettings: vi.fn(), - updateSettings: vi.fn(), + getSettingsFn: vi.fn(), + updateSettingsFn: vi.fn(), }; /** Set the minimum password length the password forms will validate against. */ @@ -25,14 +25,14 @@ export function mockGetPasswordPolicy( /** Resolve the settings query with the given settings. */ export function mockGetSettings(settings: Settings): Mock { - settingsServerFnMocks.getSettings.mockResolvedValue(settings); - return settingsServerFnMocks.getSettings; + settingsServerFnMocks.getSettingsFn.mockResolvedValue(settings); + return settingsServerFnMocks.getSettingsFn; } /** Resolve the settings update, echoing the given settings back to the caller. */ export function mockUpdateSettings(settings: Settings): Mock { - settingsServerFnMocks.updateSettings.mockResolvedValue(settings); - return settingsServerFnMocks.updateSettings; + settingsServerFnMocks.updateSettingsFn.mockResolvedValue(settings); + return settingsServerFnMocks.updateSettingsFn; } /** @@ -48,8 +48,8 @@ export function mockSettingsStore(initial: Settings): { } { let current = initial; - settingsServerFnMocks.getSettings.mockImplementation(async () => current); - settingsServerFnMocks.updateSettings.mockImplementation( + settingsServerFnMocks.getSettingsFn.mockImplementation(async () => current); + settingsServerFnMocks.updateSettingsFn.mockImplementation( async ({ data }: { data: Partial }) => { current = { ...current, ...data }; return current; @@ -57,7 +57,7 @@ export function mockSettingsStore(initial: Settings): { ); return { - getSettings: settingsServerFnMocks.getSettings, - updateSettings: settingsServerFnMocks.updateSettings, + getSettings: settingsServerFnMocks.getSettingsFn, + updateSettings: settingsServerFnMocks.updateSettingsFn, }; } diff --git a/apps/web/src/tests/server-fn/subtractions.ts b/apps/web/src/tests/server-fn/subtractions.ts index f6287293e..444cd0fa0 100644 --- a/apps/web/src/tests/server-fn/subtractions.ts +++ b/apps/web/src/tests/server-fn/subtractions.ts @@ -11,17 +11,17 @@ import { type Mock, vi } from "vitest"; * reads subtractions can stub them without per-file `vi.mock` boilerplate. */ export const subtractionServerFnMocks = { - createSubtraction: vi.fn(), - deleteSubtraction: vi.fn(), - findSubtractions: vi.fn(), - getSubtraction: vi.fn(), - listSubtractionsShortlist: vi.fn(), - updateSubtraction: vi.fn(), + createSubtractionFn: vi.fn(), + deleteSubtractionFn: vi.fn(), + findSubtractionsFn: vi.fn(), + getSubtractionFn: vi.fn(), + listSubtractionsShortlistFn: vi.fn(), + updateSubtractionFn: vi.fn(), }; /** Sets up findSubtractions to resolve with a single page of the given items. */ export function mockFindSubtractions(items: SubtractionMinimal[]): Mock { - subtractionServerFnMocks.findSubtractions.mockResolvedValue({ + subtractionServerFnMocks.findSubtractionsFn.mockResolvedValue({ found_count: items.length, total_count: items.length, ready_count: items.filter((item) => item.ready).length, @@ -30,12 +30,12 @@ export function mockFindSubtractions(items: SubtractionMinimal[]): Mock { per_page: 25, items, }); - return subtractionServerFnMocks.findSubtractions; + return subtractionServerFnMocks.findSubtractionsFn; } /** Sets up getSubtraction to resolve with the given subtraction when matched. */ export function mockGetSubtraction(subtraction: Subtraction): Mock { - subtractionServerFnMocks.getSubtraction.mockImplementation( + subtractionServerFnMocks.getSubtractionFn.mockImplementation( async ({ data }: { data: { subtractionId: number } }) => { if (data.subtractionId === subtraction.id) { return subtraction; @@ -45,13 +45,13 @@ export function mockGetSubtraction(subtraction: Subtraction): Mock { ); }, ); - return subtractionServerFnMocks.getSubtraction; + return subtractionServerFnMocks.getSubtractionFn; } /** Sets up createSubtraction to resolve with the given subtraction. */ export function mockCreateSubtraction(subtraction: Subtraction): Mock { - subtractionServerFnMocks.createSubtraction.mockResolvedValue(subtraction); - return subtractionServerFnMocks.createSubtraction; + subtractionServerFnMocks.createSubtractionFn.mockResolvedValue(subtraction); + return subtractionServerFnMocks.createSubtractionFn; } /** @@ -59,7 +59,7 @@ export function mockCreateSubtraction(subtraction: Subtraction): Mock { * whatever name and nickname the caller submitted. */ export function mockUpdateSubtraction(subtraction: Subtraction): Mock { - subtractionServerFnMocks.updateSubtraction.mockImplementation( + subtractionServerFnMocks.updateSubtractionFn.mockImplementation( async ({ data, }: { @@ -70,21 +70,21 @@ export function mockUpdateSubtraction(subtraction: Subtraction): Mock { nickname: data.nickname ?? subtraction.nickname, }), ); - return subtractionServerFnMocks.updateSubtraction; + return subtractionServerFnMocks.updateSubtractionFn; } /** Sets up deleteSubtraction to resolve. */ export function mockDeleteSubtraction(): Mock { - subtractionServerFnMocks.deleteSubtraction.mockResolvedValue(null); - return subtractionServerFnMocks.deleteSubtraction; + subtractionServerFnMocks.deleteSubtractionFn.mockResolvedValue(null); + return subtractionServerFnMocks.deleteSubtractionFn; } /** Sets up listSubtractionsShortlist to resolve with the given options. */ export function mockListSubtractionsShortlist( subtractions: SubtractionOption[], ): Mock { - subtractionServerFnMocks.listSubtractionsShortlist.mockResolvedValue( + subtractionServerFnMocks.listSubtractionsShortlistFn.mockResolvedValue( subtractions, ); - return subtractionServerFnMocks.listSubtractionsShortlist; + return subtractionServerFnMocks.listSubtractionsShortlistFn; } diff --git a/apps/web/src/tests/server-fn/uploads.ts b/apps/web/src/tests/server-fn/uploads.ts index 46edd9d2f..9731168a0 100644 --- a/apps/web/src/tests/server-fn/uploads.ts +++ b/apps/web/src/tests/server-fn/uploads.ts @@ -11,13 +11,13 @@ import { type Mock, vi } from "vitest"; * exercise uploading mock `@uploads/uploader` directly. */ export const uploadServerFnMocks = { - deleteUpload: vi.fn(), - findUploads: vi.fn(), + deleteUploadFn: vi.fn(), + findUploadsFn: vi.fn(), }; /** Sets up findUploads to resolve with a single page of the given uploads. */ export function mockFindUploads(files: Upload[]): Mock { - uploadServerFnMocks.findUploads.mockResolvedValue({ + uploadServerFnMocks.findUploadsFn.mockResolvedValue({ items: files, found_count: files.length, total_count: files.length, @@ -25,5 +25,5 @@ export function mockFindUploads(files: Upload[]): Mock { page_count: 1, per_page: 25, }); - return uploadServerFnMocks.findUploads; + return uploadServerFnMocks.findUploadsFn; } diff --git a/apps/web/src/tests/server-fn/users.ts b/apps/web/src/tests/server-fn/users.ts index a413e8c16..a3b154c91 100644 --- a/apps/web/src/tests/server-fn/users.ts +++ b/apps/web/src/tests/server-fn/users.ts @@ -9,21 +9,21 @@ import { expect, type Mock, vi } from "vitest"; * the users server functions without per-file `vi.mock` boilerplate. */ export const userServerFnMocks = { - findUsers: vi.fn(), - searchUsers: vi.fn(), - listUsers: vi.fn(), - getAccount: vi.fn(), - getUser: vi.fn(), - createUser: vi.fn(), - updateUser: vi.fn(), - updateAccountHandle: vi.fn(), - setAdministratorRole: vi.fn(), - listAdministratorRoles: vi.fn(), + findUsersFn: vi.fn(), + searchUsersFn: vi.fn(), + listUsersFn: vi.fn(), + getAccountFn: vi.fn(), + getUserFn: vi.fn(), + createUserFn: vi.fn(), + updateUserFn: vi.fn(), + updateAccountHandleFn: vi.fn(), + setAdministratorRoleFn: vi.fn(), + listAdministratorRolesFn: vi.fn(), }; /** Sets up findUsers to resolve with a single page containing the given users. */ export function mockFindUsers(users: User[]): Mock { - userServerFnMocks.findUsers.mockResolvedValue({ + userServerFnMocks.findUsersFn.mockResolvedValue({ items: users, foundCount: users.length, page: 1, @@ -31,12 +31,12 @@ export function mockFindUsers(users: User[]): Mock { perPage: 25, totalCount: users.length, }); - return userServerFnMocks.findUsers; + return userServerFnMocks.findUsersFn; } /** Sets up searchUsers to resolve with a single page containing the given users. */ export function mockSearchUsers(users: User[]): Mock { - userServerFnMocks.searchUsers.mockResolvedValue({ + userServerFnMocks.searchUsersFn.mockResolvedValue({ items: users, foundCount: users.length, page: 1, @@ -44,21 +44,21 @@ export function mockSearchUsers(users: User[]): Mock { perPage: 25, totalCount: users.length, }); - return userServerFnMocks.searchUsers; + return userServerFnMocks.searchUsersFn; } /** Sets up listUsers to resolve with the given users, reduced to id and handle. */ export function mockListUsers(users: UserNested[]): Mock { - userServerFnMocks.listUsers.mockResolvedValue( + userServerFnMocks.listUsersFn.mockResolvedValue( users.map(({ handle, id }) => ({ handle, id })), ); - return userServerFnMocks.listUsers; + return userServerFnMocks.listUsersFn; } /** Sets up getAccount to resolve with the given account. */ export function mockGetAccount(account: Account): Mock { - userServerFnMocks.getAccount.mockResolvedValue(account); - return userServerFnMocks.getAccount; + userServerFnMocks.getAccountFn.mockResolvedValue(account); + return userServerFnMocks.getAccountFn; } /** @@ -72,14 +72,14 @@ export function mockGetAccountUnauthorized(): Mock { const error = new Error("Unauthorized"); error.name = "UnauthorizedError"; - userServerFnMocks.getAccount.mockRejectedValue(error); + userServerFnMocks.getAccountFn.mockRejectedValue(error); - return userServerFnMocks.getAccount; + return userServerFnMocks.getAccountFn; } /** Sets up getUser to resolve with the given user when matched by id. */ export function mockGetUser(userId: number, user: User): Mock { - userServerFnMocks.getUser.mockImplementation( + userServerFnMocks.getUserFn.mockImplementation( async ({ data }: { data: { userId: number } }) => { if (data.userId === userId) { return user; @@ -87,7 +87,7 @@ export function mockGetUser(userId: number, user: User): Mock { throw new Error(`unexpected userId in mockGetUser: ${data.userId}`); }, ); - return userServerFnMocks.getUser; + return userServerFnMocks.getUserFn; } /** Sets up createUser to resolve with the given user (or reject on a 4xx code). */ @@ -97,11 +97,11 @@ export function mockCreateUser( message = "User already exists.", ): Mock { if (statusCode >= 400) { - userServerFnMocks.createUser.mockRejectedValue(new Error(message)); + userServerFnMocks.createUserFn.mockRejectedValue(new Error(message)); } else { - userServerFnMocks.createUser.mockResolvedValue(user ?? {}); + userServerFnMocks.createUserFn.mockResolvedValue(user ?? {}); } - return userServerFnMocks.createUser; + return userServerFnMocks.createUserFn; } /** Sets up updateUser to resolve with the merged user (or reject on a 4xx code). */ @@ -114,11 +114,11 @@ export function mockUpdateUser( if (statusCode >= 400) { const message = typeof update.message === "string" ? update.message : "Bad request."; - userServerFnMocks.updateUser.mockRejectedValue(new Error(message)); + userServerFnMocks.updateUserFn.mockRejectedValue(new Error(message)); } else { - userServerFnMocks.updateUser.mockResolvedValue({ ...user, ...update }); + userServerFnMocks.updateUserFn.mockResolvedValue({ ...user, ...update }); } - return userServerFnMocks.updateUser; + return userServerFnMocks.updateUserFn; } /** @@ -135,9 +135,11 @@ export function mockUpdateAccountHandle( expectedHandle?: string, ): Mock { if (statusCode >= 400) { - userServerFnMocks.updateAccountHandle.mockRejectedValue(new Error(message)); + userServerFnMocks.updateAccountHandleFn.mockRejectedValue( + new Error(message), + ); } else { - userServerFnMocks.updateAccountHandle.mockImplementation( + userServerFnMocks.updateAccountHandleFn.mockImplementation( async ({ data }: { data: { handle: string } }) => { if (expectedHandle !== undefined) { expect(data.handle).toBe(expectedHandle); @@ -146,17 +148,17 @@ export function mockUpdateAccountHandle( }, ); } - return userServerFnMocks.updateAccountHandle; + return userServerFnMocks.updateAccountHandleFn; } /** Sets up listAdministratorRoles to resolve with the given roles. */ export function mockListAdministratorRoles(roles: AdministratorRole[]): Mock { - userServerFnMocks.listAdministratorRoles.mockResolvedValue(roles); - return userServerFnMocks.listAdministratorRoles; + userServerFnMocks.listAdministratorRolesFn.mockResolvedValue(roles); + return userServerFnMocks.listAdministratorRolesFn; } /** Sets up setAdministratorRole to resolve with the given user. */ export function mockSetAdministratorRole(user: User): Mock { - userServerFnMocks.setAdministratorRole.mockResolvedValue(user); - return userServerFnMocks.setAdministratorRole; + userServerFnMocks.setAdministratorRoleFn.mockResolvedValue(user); + return userServerFnMocks.setAdministratorRoleFn; } diff --git a/apps/web/src/tests/setup.tsx b/apps/web/src/tests/setup.tsx index 25d5ac85d..832d51a9f 100644 --- a/apps/web/src/tests/setup.tsx +++ b/apps/web/src/tests/setup.tsx @@ -30,6 +30,7 @@ import { jobServerFnMocks } from "./server-fn/jobs"; import { labelServerFnMocks } from "./server-fn/labels"; import { referenceServerFnMocks } from "./server-fn/references"; import { rootServerFnMocks } from "./server-fn/root"; +import { sampleServerFnMocks } from "./server-fn/samples"; import { mockGetPasswordPolicy, settingsServerFnMocks, @@ -89,6 +90,11 @@ vi.mock("@server/references/functions", async () => { return referenceServerFnMocks; }); +vi.mock("@server/samples/functions", async () => { + const { sampleServerFnMocks } = await import("./server-fn/samples"); + return sampleServerFnMocks; +}); + beforeEach(() => { for (const fn of Object.values(groupServerFnMocks)) { fn.mockReset(); @@ -101,14 +107,16 @@ beforeEach(() => { ...Object.values(authServerFnMocks), ...Object.values(labelServerFnMocks), ...Object.values(rootServerFnMocks), - uploadServerFnMocks.findUploads, - uploadServerFnMocks.deleteUpload, - subtractionServerFnMocks.findSubtractions, - subtractionServerFnMocks.getSubtraction, - subtractionServerFnMocks.listSubtractionsShortlist, - referenceServerFnMocks.findReferences, - referenceServerFnMocks.getReference, - settingsServerFnMocks.getSettings, + uploadServerFnMocks.findUploadsFn, + uploadServerFnMocks.deleteUploadFn, + subtractionServerFnMocks.findSubtractionsFn, + subtractionServerFnMocks.getSubtractionFn, + subtractionServerFnMocks.listSubtractionsShortlistFn, + referenceServerFnMocks.findReferencesFn, + referenceServerFnMocks.getReferenceFn, + sampleServerFnMocks.findSamplesFn, + sampleServerFnMocks.getSampleFn, + settingsServerFnMocks.getSettingsFn, ]) { fn.mockReset(); // Default to a pending promise so an un-stubbed query renders its loading @@ -117,20 +125,24 @@ beforeEach(() => { } for (const fn of [ - subtractionServerFnMocks.createSubtraction, - subtractionServerFnMocks.updateSubtraction, - subtractionServerFnMocks.deleteSubtraction, - referenceServerFnMocks.createReference, - referenceServerFnMocks.updateReference, - referenceServerFnMocks.archiveReference, - referenceServerFnMocks.unarchiveReference, - referenceServerFnMocks.addReferenceUser, - referenceServerFnMocks.addReferenceGroup, - referenceServerFnMocks.updateReferenceUser, - referenceServerFnMocks.updateReferenceGroup, - referenceServerFnMocks.removeReferenceUser, - referenceServerFnMocks.removeReferenceGroup, - settingsServerFnMocks.updateSettings, + subtractionServerFnMocks.createSubtractionFn, + subtractionServerFnMocks.updateSubtractionFn, + subtractionServerFnMocks.deleteSubtractionFn, + referenceServerFnMocks.createReferenceFn, + referenceServerFnMocks.updateReferenceFn, + referenceServerFnMocks.archiveReferenceFn, + referenceServerFnMocks.unarchiveReferenceFn, + referenceServerFnMocks.addReferenceUserFn, + referenceServerFnMocks.addReferenceGroupFn, + referenceServerFnMocks.updateReferenceUserFn, + referenceServerFnMocks.updateReferenceGroupFn, + referenceServerFnMocks.removeReferenceUserFn, + referenceServerFnMocks.removeReferenceGroupFn, + sampleServerFnMocks.createSampleFn, + sampleServerFnMocks.updateSampleFn, + sampleServerFnMocks.deleteSampleFn, + sampleServerFnMocks.updateSampleRightsFn, + settingsServerFnMocks.updateSettingsFn, ]) { fn.mockReset(); } diff --git a/apps/web/src/uploads/components/__tests__/FileManager.test.tsx b/apps/web/src/uploads/components/__tests__/FileManager.test.tsx index 041071590..d88c6482b 100644 --- a/apps/web/src/uploads/components/__tests__/FileManager.test.tsx +++ b/apps/web/src/uploads/components/__tests__/FileManager.test.tsx @@ -121,7 +121,7 @@ describe("", () => { mockGetAccount(createFakeAccount({ administrator_role: "full" })); mockFindUploads(files); - uploadServerFnMocks.deleteUpload.mockResolvedValue(null); + uploadServerFnMocks.deleteUploadFn.mockResolvedValue(null); await renderWithRouter(, path); @@ -139,15 +139,15 @@ describe("", () => { await userEvent.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => { - expect(uploadServerFnMocks.deleteUpload).toHaveBeenCalledWith({ + expect(uploadServerFnMocks.deleteUploadFn).toHaveBeenCalledWith({ data: { id: first.id }, }); - expect(uploadServerFnMocks.deleteUpload).toHaveBeenCalledWith({ + expect(uploadServerFnMocks.deleteUploadFn).toHaveBeenCalledWith({ data: { id: second.id }, }); }); - expect(uploadServerFnMocks.deleteUpload).not.toHaveBeenCalledWith({ + expect(uploadServerFnMocks.deleteUploadFn).not.toHaveBeenCalledWith({ data: { id: unselected.id }, }); }); diff --git a/apps/web/src/uploads/components/__tests__/UploadItem.test.tsx b/apps/web/src/uploads/components/__tests__/UploadItem.test.tsx index 29afd4ac1..84b6a3216 100644 --- a/apps/web/src/uploads/components/__tests__/UploadItem.test.tsx +++ b/apps/web/src/uploads/components/__tests__/UploadItem.test.tsx @@ -43,13 +43,13 @@ describe("", () => { }); it("should have [props.onRemove] called when trash icon clicked", async () => { - uploadServerFnMocks.deleteUpload.mockResolvedValue(null); + uploadServerFnMocks.deleteUploadFn.mockResolvedValue(null); renderWithProviders(); await userEvent.click(screen.getByRole("button", { name: "remove" })); await waitFor(() => { - expect(uploadServerFnMocks.deleteUpload).toHaveBeenCalledWith({ + expect(uploadServerFnMocks.deleteUploadFn).toHaveBeenCalledWith({ data: { id: props.id }, }); }); diff --git a/apps/web/src/uploads/queries.ts b/apps/web/src/uploads/queries.ts index 8283960ce..99bf37b1e 100644 --- a/apps/web/src/uploads/queries.ts +++ b/apps/web/src/uploads/queries.ts @@ -1,4 +1,4 @@ -import { deleteUpload, findUploads } from "@server/uploads/functions"; +import { deleteUploadFn, findUploadsFn } from "@server/uploads/functions"; import { keepPreviousData, useInfiniteQuery, @@ -12,7 +12,8 @@ import type { FileResponse, UploadType } from "./types"; export function useListFiles(type: UploadType, page: number, per_page: number) { return useQuery({ queryKey: fileQueryKeys.list([type, page, per_page]), - queryFn: () => findUploads({ data: { upload_type: type, page, per_page } }), + queryFn: () => + findUploadsFn({ data: { upload_type: type, page, per_page } }), placeholderData: keepPreviousData, }); } @@ -21,7 +22,7 @@ export function useInfiniteFindFiles(type: UploadType, per_page: number) { return useInfiniteQuery({ queryKey: fileQueryKeys.infiniteList([type, per_page]), queryFn: ({ pageParam }) => - findUploads({ + findUploadsFn({ data: { upload_type: type, page: pageParam as number, per_page }, }), initialPageParam: 1, @@ -41,7 +42,7 @@ export function useInfiniteFindFiles(type: UploadType, per_page: number) { */ export function useDeleteFile() { return useMutation({ - mutationFn: ({ id }) => deleteUpload({ data: { id } }), + mutationFn: ({ id }) => deleteUploadFn({ data: { id } }), }); } @@ -59,7 +60,7 @@ export function useDeleteFiles() { // the first failure would let the list refetch while the rest are still // in flight, so files that did get deleted would linger in the list. const results = await Promise.allSettled( - ids.map((id) => deleteUpload({ data: { id } })), + ids.map((id) => deleteUploadFn({ data: { id } })), ); const failure = results.find((result) => result.status === "rejected"); diff --git a/apps/web/src/users/components/__tests__/UserGroups.test.tsx b/apps/web/src/users/components/__tests__/UserGroups.test.tsx index 6ac90eb28..c1ae8baf6 100644 --- a/apps/web/src/users/components/__tests__/UserGroups.test.tsx +++ b/apps/web/src/users/components/__tests__/UserGroups.test.tsx @@ -112,7 +112,7 @@ describe("", () => { await userEvent.click(screen.getByRole("option", { name: "bar" })); await waitFor(() => - expect(userServerFnMocks.updateUser).toHaveBeenCalledWith({ + expect(userServerFnMocks.updateUserFn).toHaveBeenCalledWith({ data: { userId, groups: [1, 2] }, }), ); @@ -138,7 +138,7 @@ describe("", () => { await userEvent.keyboard("{ArrowDown}{Enter}"); await waitFor(() => - expect(userServerFnMocks.updateUser).toHaveBeenCalledWith({ + expect(userServerFnMocks.updateUserFn).toHaveBeenCalledWith({ data: { userId, groups: [1, 2] }, }), ); @@ -163,7 +163,7 @@ describe("", () => { await userEvent.click(await screen.findByRole("option", { name: "bar" })); await waitFor(() => - expect(userServerFnMocks.updateUser).toHaveBeenCalledWith({ + expect(userServerFnMocks.updateUserFn).toHaveBeenCalledWith({ data: { userId, groups: [1, 2] }, }), ); @@ -223,7 +223,7 @@ describe("", () => { await userEvent.click(await screen.findByRole("radio", { name: "bar" })); await waitFor(() => - expect(userServerFnMocks.updateUser).toHaveBeenCalledWith({ + expect(userServerFnMocks.updateUserFn).toHaveBeenCalledWith({ data: { userId, primary_group: 2 }, }), ); @@ -246,7 +246,7 @@ describe("", () => { ); await waitFor(() => - expect(userServerFnMocks.updateUser).toHaveBeenCalledWith({ + expect(userServerFnMocks.updateUserFn).toHaveBeenCalledWith({ data: { userId, primary_group: null }, }), ); @@ -269,7 +269,7 @@ describe("", () => { ); await waitFor(() => - expect(userServerFnMocks.updateUser).toHaveBeenCalledWith({ + expect(userServerFnMocks.updateUserFn).toHaveBeenCalledWith({ data: { userId, groups: [2], primary_group: null }, }), ); @@ -292,7 +292,7 @@ describe("", () => { ); await waitFor(() => - expect(userServerFnMocks.updateUser).toHaveBeenCalledWith({ + expect(userServerFnMocks.updateUserFn).toHaveBeenCalledWith({ data: { userId, groups: [1] }, }), ); diff --git a/apps/web/src/users/queries.ts b/apps/web/src/users/queries.ts index 26cb30979..09f5ba55b 100644 --- a/apps/web/src/users/queries.ts +++ b/apps/web/src/users/queries.ts @@ -1,12 +1,12 @@ import type { AdministratorRoleName } from "@administration/types"; import { - createUser, - findUsers, - getUser, - listUsers, - searchUsers, - setAdministratorRole, - updateUser, + createUserFn, + findUsersFn, + getUserFn, + listUsersFn, + searchUsersFn, + setAdministratorRoleFn, + updateUserFn, } from "@server/users/functions"; import { keepPreviousData, @@ -28,7 +28,7 @@ import type { UserNested } from "./types"; export function useListUsers() { return useQuery({ queryKey: userQueryKeys.nested(), - queryFn: () => listUsers(), + queryFn: () => listUsersFn(), }); } @@ -43,7 +43,7 @@ export function useInfiniteFindUsers(perPage: number, term: string) { return useInfiniteQuery({ queryKey: userQueryKeys.infiniteList([perPage, term]), queryFn: ({ pageParam }) => - searchUsers({ data: { page: pageParam as number, perPage, term } }), + searchUsersFn({ data: { page: pageParam as number, perPage, term } }), initialPageParam: 1, getNextPageParam: (lastPage) => { if (lastPage.page >= lastPage.pageCount) { @@ -74,7 +74,7 @@ export function usersQueryOptions( return queryOptions({ queryKey: userQueryKeys.list([page, perPage, term, administrator, active]), queryFn: () => - findUsers({ + findUsersFn({ data: { page, perPage, term, administrator, active }, }), }); @@ -109,7 +109,7 @@ export function useSuspenseUsers( export function useCreateUser() { const queryClient = useQueryClient(); return useMutation< - Awaited>, + Awaited>, Error, { handle: string; @@ -118,7 +118,7 @@ export function useCreateUser() { } >({ mutationFn: ({ handle, password, forceReset }) => - createUser({ data: { handle, password, forceReset } }), + createUserFn({ data: { handle, password, forceReset } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: userQueryKeys.lists() }); }, @@ -133,7 +133,7 @@ export function useCreateUser() { export function userQueryOptions(userId: number) { return queryOptions({ queryKey: userQueryKeys.detail(userId), - queryFn: () => getUser({ data: { userId } }), + queryFn: () => getUserFn({ data: { userId } }), }); } @@ -168,12 +168,12 @@ export type UserUpdate = { export function useUpdateUser() { const queryClient = useQueryClient(); return useMutation< - Awaited>, + Awaited>, Error, { userId: number; update: UserUpdate } >({ mutationFn: ({ userId, update }) => - updateUser({ data: { userId, ...update } }), + updateUserFn({ data: { userId, ...update } }), onSuccess: (result) => { if (result) { queryClient.setQueryData(userQueryKeys.detail(result.id), result); @@ -191,12 +191,12 @@ export function useUpdateUser() { export function useSetAdministratorRole() { const queryClient = useQueryClient(); return useMutation< - Awaited>, + Awaited>, Error, { role: AdministratorRoleName | null; user_id: number } >({ mutationFn: ({ role, user_id }) => - setAdministratorRole({ data: { userId: user_id, role } }), + setAdministratorRoleFn({ data: { userId: user_id, role } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: userQueryKeys.all() }); }, diff --git a/docs/architecture.md b/docs/architecture.md index 61c69b578..67277b598 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -157,18 +157,21 @@ written by `data.ts` and rendered by components, so neither owns it: `@virtool/contracts` does. `references.ts` there defines `Reference`, `ReferenceMinimal`, the rights and membership shapes, and the `ReferenceSearchResult` page; `server/references/data.ts` imports them -to annotate its returns, and `src/references/types.ts` re-exports them -so components import from `@references/types` as before. - -The failure mode this replaces is a client `types.ts` that does +to annotate its returns, and components import the same names straight +from `@virtool/contracts`. Don't add a feature `types.ts` re-export for +these — unlike `@server/*` below, importing `@virtool/contracts` +directly crosses no project boundary, so the re-export bought nothing +but a second place a type must be kept listed. `references/types.ts` +and `samples/types.ts` hold only genuinely client-only shapes +(`ReferenceNested`, `CreateSampleRequest`, `SampleUpdate`, and friends). + +The failure mode to avoid is a client `types.ts` that does `import type { Reference } from "@server/references/data"`. It type-checks — the arrow client → `@server/*` is legal — but it makes the browser's view of a shape depend on a *data-layer* module's emitted -declarations, so the shape is only nameable if that module's whole -inferred surface stays portable, and a `data.ts` refactor becomes a -client type break. The `.d.ts` remap also has to succeed for the client -to see a plain object type. Both problems disappear when the type has a -home neither side reaches through the other. +declarations, so a `data.ts` refactor becomes a client type break. That +problem disappears when the type has a home neither side reaches +through the other — `@virtool/contracts`, imported directly. Two shapes generalised out of that move and now live in the package alongside the reference contracts: `UserNested` (`{ id, handle }`, the diff --git a/docs/auth.md b/docs/auth.md index 938a62001..9fe3e0be2 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -177,7 +177,7 @@ may do*. That second question is answered by a policy, declared as middleware on the function itself, from `server/auth/policy.ts`: ```ts -export const deleteGroup = createServerFn({ method: "POST" }) +export const deleteGroupFn = createServerFn({ method: "POST" }) .middleware([adminRole("base")]) .validator(groupIdSchema) .handler(async ({ context, data }) => { ... }); @@ -210,7 +210,7 @@ A policy states the **floor**. A rule that depends on the row being touched cannot be expressed at the door — an administrator editing another administrator needs the `full` role, and that is only knowable after the target user is read. Those checks stay in the handler, after -the read, with `requireAdminRole`. `updateUser` is the worked example. +the read, with `requireAdminRole`. `updateUserFn` is the worked example. `data.ts` remains a pure persistence layer that assumes its caller has already been authorized — never put a role check there. @@ -396,8 +396,8 @@ zod rule. `checkConfiguredPasswordLength(db, password)` (`server/auth/service.ts`) is the single enforcement point. Every path that **sets** a password -calls it: `createFirstUserFn`, `resetPasswordFn`, `createUser`, and -`updateUser`. It reads the setting, applies the pure +calls it: `createFirstUserFn`, `resetPasswordFn`, `createUserFn`, and +`updateUserFn`. It reads the setting, applies the pure `checkPasswordLength` from `passwordPolicy.ts`, and throws `PasswordTooShortError`; each handler maps that to a 400 carrying the error's message. diff --git a/docs/code-style.md b/docs/code-style.md index 66d21c8e2..2d465d37e 100644 --- a/docs/code-style.md +++ b/docs/code-style.md @@ -82,25 +82,29 @@ is not an `is` or `has`. Prepositional names like `lifetimeFor` or `dataFor` are not in the rule — prefer `getLifetime` / `getData`. -## Naming: do not suffix functions with their layer or mechanism - -Exported function names should describe the domain action or returned -value, not the file, framework, or implementation layer that contains -them. Avoid suffixes like `Fn`, `Core`, `Handler`, or `Impl` in -exported names. Let the module path carry the layer: - -- `server/auth/core.ts` exports pure domain helpers (`login`, `logout`, - `getAuthState`). +## Naming: a server function gets an `Fn` suffix; the domain function it wraps does not + +A `createServerFn`-wrapped export is not a plain function call — every +call site crosses the network, goes through validation and auth +middleware, and returns whatever the RPC layer's plumbing returns +(`loginFn` isn't `login`'s return type, it's a callable RPC handle with +its own `.url`, for instance). That's a real behavioral difference from +the domain function underneath it, worth naming, not hiding: + +- `server/auth/core.ts` exports the pure domain helpers (`login`, + `logout`, `getAuthState`) — no suffix, since these never cross the + RPC boundary. - `server/auth/functions.ts` exports the TanStack Start server - functions that wrap them. + functions that wrap them, suffixed `Fn` (`loginFn`, `logoutFn`) — + every call site, client or test, sees at a glance that the call is a + server round-trip rather than a local one. - React Query hooks living next to the feature's `api.ts` wrap those server calls as `useLoginMutation`, `useAuth`, etc. -When two imported functions with the same domain name meet in one -file, use a local-only alias such as `login as loginImpl` at the -import site. The alias is allowed because it is wiring glue, not -exported API. Framework option names that the library dictates (e.g. -React Query's `queryFn` and `mutationFn`) keep their upstream names. +Because the wrapper and the domain function it wraps now have +different names, no import aliasing is needed for the common case. Only +alias an import (`as` at the import site) for an unrelated, incidental +name collision that isn't this domain-function/server-function pair. Import these model constants by their exported names. Do not alias them with `as` to avoid collisions; instead name other imports diff --git a/docs/database.md b/docs/database.md index 3fb339209..9e4bc550f 100644 --- a/docs/database.md +++ b/docs/database.md @@ -78,7 +78,7 @@ states: | API keys | `api_keys` | Built | | Analyses | `analyses` | Partial mirror | | Indexes | `indexes` | Partial mirror | -| Samples | `legacy_samples` | Partial mirror | +| Samples | `legacy_samples`, `legacy_sample_*`, `sample_*` | Built | | Subtractions | `subtractions`, `subtraction_files` | Built | | References | `legacy_references`, `legacy_reference_*` | Built | | Uploads | `uploads` | Not started | @@ -96,9 +96,13 @@ reverse `job_id` foreign keys on `analyses`, `indexes`, and read path adds three more: `legacy_otus` (an OTU count and the clone manifest), `legacy_history` (contributors and the unbuilt-change count), and the extra `indexes` columns that resolve a reference's latest build. -Each partial mirror declares just the columns its consumer needs. The -`subtractions` mirror is now full — the subtraction domain is served -from this repo — but jobs still reaches it through the same reverse +The samples read path adds three columns to the `analyses` mirror — +`sample_id`, `workflow`, and `ready` — to derive a sample's workflow tags +(a `GROUP BY workflow, bool_or(ready)`) and to power the `workflows=` +filter (a correlated `EXISTS`); the analyses domain itself is still only a +partial mirror. Each partial mirror declares just the columns its consumer +needs. The `subtractions` mirror is now full — the subtraction domain is +served from this repo — but jobs still reaches it through the same reverse `job_id` foreign key. A `legacy_` table prefix marks a table that carries the Mongo-era row diff --git a/docs/queries.md b/docs/queries.md index c148265a3..d6818ef5b 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -370,7 +370,7 @@ Use the split deliberately: export function useUpdateSample(sampleId: string) { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ update }) => updateSample(sampleId, update), + mutationFn: ({ update }) => updateSampleFn(sampleId, update), onSuccess: () => { queryClient.invalidateQueries({ queryKey: samplesQueryKeys.detail(sampleId), diff --git a/docs/server-push.md b/docs/server-push.md index 4c479b513..54d2ab24d 100644 --- a/docs/server-push.md +++ b/docs/server-push.md @@ -187,7 +187,7 @@ application's own doing. Jobs are the only domain that emits an update frame per running job per progress wave, and every job on screen holds its own `detail(id)` query (see the nested-job sites below). Invalidating each frame's detail -therefore cost one `getJob` request per running job per tick — 25 rows, +therefore cost one `getJobFn` request per running job per tick — 25 rows, 25 requests. So `jobs`/`update` frames do not go through `selectQueryKey`. They go to @@ -200,12 +200,12 @@ a queue built by `createJobRefreshQueue` (`jobs/refresh.ts`), one per drift no matter how many details are refreshed. This is a no-op on pages that cache no jobs list. 3. Drops ids whose `detail(id)` has no **active** observer, then reads - the rest through the `getJobs` server function — one request per - wave, chunked at the 100-id cap `getJobs` validates. This is what + the rest through the `getJobsFn` server function — one request per + wave, chunked at the 100-id cap `getJobsFn` validates. This is what keeps the jobs list page, whose rows render from the list query, from fetching a detail per row. -4. Writes each result straight into its `detail(id)` cache. `getJobs` - returns the full `Job`, the same shape `getJob` returns, so +4. Writes each result straight into its `detail(id)` cache. `getJobsFn` + returns the full `Job`, the same shape `getJobFn` returns, so `detail(id)` stays one canonical shape and `JobDetail` — which renders `args` and `steps` — reads the same entry as a sample row rendering only `progress`. @@ -250,7 +250,7 @@ undo by accident: seeded with the nested object, so a `jobs` update frame refreshes `jobQueryKeys.detail(id)`. The round-trip fan-out that used to cost is gone — see "Job updates are batched" above — but the payload is - unchanged: `getJobs` returns the full `Job` (args, claim, steps) for + unchanged: `getJobsFn` returns the full `Job` (args, claim, steps) for every row, where a nested view renders two fields. Trimming it means a second cache shape for `detail(id)`, which `JobDetail` also reads, so it needs its own key rather than a narrower read. Not worth it @@ -261,7 +261,7 @@ undo by accident: `hmm/components/HmmInstall.tsx`) now keep live task `progress`/`step` fresh by mounting `useFetchTask(id)` seeded with the nested task, so a `tasks` update frame invalidates `taskQueryKeys.detail(id)` and - refetches through the `getTask` server function. `hmm` itself is not + refetches through the `getTaskFn` server function. `hmm` itself is not an `SseDomain` and has no `/hmms` refetch trigger; `HmmInstall` bridges that gap by watching the live task's `complete` and invalidating `hmmQueryKeys.lists()` from a `useEffect`. If HMM gains diff --git a/docs/testing.md b/docs/testing.md index d41e68ea8..7b4d8744f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -78,7 +78,7 @@ const handlers = (await import( )) as SplitServerFnModule; await expect( - callServerFn(handlers, "deleteGroup", { groupId }), + callServerFn(handlers, "deleteGroupFn", { groupId }), ).rejects.toBeInstanceOf(ForbiddenError); ``` @@ -268,7 +268,7 @@ exists to prevent — `mockApiGetAccount` never touched HTTP. Each `server-fn/` file mirrors the server module it mocks, not the client feature, because one file maps to one `vi.mock` target. So the -account mocks live in `server-fn/users.ts` — `getAccount` is exported +account mocks live in `server-fn/users.ts` — `getAccountFn` is exported from `@server/users/functions` — and there is no `server-fn/account.ts`. ### Asserting a server function was called diff --git a/packages/contracts/src/groups.ts b/packages/contracts/src/groups.ts new file mode 100644 index 000000000..419529e85 --- /dev/null +++ b/packages/contracts/src/groups.ts @@ -0,0 +1,11 @@ +/** A group reduced to the fields embedded in other resources. */ +export type GroupMinimal = { + /** The unique identifier */ + id: number; + + /** The Mongo-era string id, or null for a Postgres-native group */ + legacy_id: string | null; + + /** The display name */ + name: string; +}; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 6e0781893..0769ece5d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -4,9 +4,11 @@ export * from "./artifacts"; export * from "./auth"; export * from "./banners"; export * from "./errors"; +export * from "./groups"; export * from "./hmms"; export * from "./indexes"; export * from "./jobs"; +export * from "./labels"; export * from "./permissions"; export * from "./permissionsSchema"; export * from "./references"; diff --git a/packages/contracts/src/jobs.ts b/packages/contracts/src/jobs.ts index dd548f78e..0437832d1 100644 --- a/packages/contracts/src/jobs.ts +++ b/packages/contracts/src/jobs.ts @@ -1,6 +1,33 @@ import { z } from "zod"; +import type { UserNested } from "./users"; import { WorkflowName } from "./workflowName"; +/** A job's lifecycle state. Shared by every resource that embeds a job. */ +export type JobState = + | "cancelled" + | "failed" + | "pending" + | "running" + | "succeeded"; + +/** A workflow a job can run. */ +export type JobWorkflow = + | "build_index" + | "create_sample" + | "create_subtraction" + | "nuvs" + | "pathoscope"; + +/** A job embedded in another resource, e.g. a sample's creation job. */ +export type JobNested = { + createdAt: string; + id: number; + progress: number; + state: JobState; + user: UserNested; + workflow: JobWorkflow; +}; + /** Job row returned by the lifecycle endpoints. Provisional shape. */ export const Job = z.object({ id: z.int(), diff --git a/packages/contracts/src/labels.ts b/packages/contracts/src/labels.ts new file mode 100644 index 000000000..4c0d58069 --- /dev/null +++ b/packages/contracts/src/labels.ts @@ -0,0 +1,14 @@ +/** A label reduced to the fields embedded in other resources. */ +export type LabelNested = { + /** The hex encoded color */ + color: string; + + /** The detailed description */ + description: string; + + /** The unique identifier */ + id: number; + + /** The display name */ + name: string; +}; diff --git a/packages/contracts/src/samples.ts b/packages/contracts/src/samples.ts index 53ab5a99f..c0636506d 100644 --- a/packages/contracts/src/samples.ts +++ b/packages/contracts/src/samples.ts @@ -1,17 +1,166 @@ import { z } from "zod"; +import type { GroupMinimal } from "./groups"; +import type { JobNested } from "./jobs"; +import type { LabelNested } from "./labels"; +import type { SearchResultV2 } from "./search"; +import type { SubtractionNested } from "./subtractions"; +import type { UserNested } from "./users"; -/** Library type used by sample-trimming heuristics; mirrors the Python `LibraryType` enum. */ -export const LibraryType = z.enum(["normal", "srna"]); +/** The library preparation used to create a sample. */ +export type LibraryType = "amplicon" | "srna" | "other" | "normal"; -export type LibraryType = z.infer; +/** The state of a single workflow for a sample. */ +export type WorkflowState = "complete" | "pending" | "none" | "incompatible"; -/** Sample metadata returned by `GET /samples/{id}`. Provisional shape — fields land as the runner needs them. */ -export const Sample = z.object({ - id: z.int(), - name: z.string(), - paired: z.boolean(), - library_type: LibraryType, - max_length: z.number().int().nonnegative(), +/** The state of each workflow tracked for a sample. */ +export type SampleWorkflows = { + /** The state of the NuVs workflow */ + nuvs: WorkflowState; + + /** The state of the Pathoscope workflow */ + pathoscope: WorkflowState; +}; + +/** + * The creation job embedded in a sample. A job nested in a sample is always the + * `create_sample` job that built it. + */ +export type SampleJobNested = JobNested & { workflow: "create_sample" }; + +/** An artifact produced while creating a sample. */ +export type SampleArtifact = { + downloadUrl: string; + id: number; + name: string; + size: number; +}; + +/** An input upload embedded in a sample reads file. */ +export type SampleReadUpload = { + id: number; + name: string; + size: number | null; + uploadedAt: string | null; + user: UserNested | null; +}; + +/** A reads file that makes up a sample. */ +export type Read = { + downloadUrl: string; + id: number; + name: string; + nameOnDisk: string; + sample: number; + size: number; + upload?: SampleReadUpload | null; + uploadedAt: string; +}; + +/** The FastQC quality charts associated with a sample. */ +export type Quality = { + /** Data for the per-base quality chart */ + bases: number[][]; + + /** Data for the composition chart */ + composition: number[][]; + + /** The read count of the sample */ + count: number; + + /** The quality-score encoding */ + encoding: string; + + /** The GC content of the sample as a percentage */ + gc: number; + + /** The read-length range */ + length: number[]; + + /** Data for the sequences chart */ + sequences: number[]; +}; + +/** A sample reduced to the fields shown in resource listings. */ +export type SampleMinimal = { + createdAt: string; + host: string; + id: number; + isolate: string; + job?: SampleJobNested; + labels: LabelNested[]; + libraryType: LibraryType; + name: string; + notes: string; + nuvs: boolean | string; + pathoscope: boolean | string; + ready: boolean; + user: UserNested; + workflows: SampleWorkflows; +}; + +/** A complete sample, as returned by the detail endpoint. */ +export type Sample = SampleMinimal & { + allRead: boolean; + allWrite: boolean; + artifacts: SampleArtifact[]; + format: string; + group: GroupMinimal | null; + groupRead: boolean; + groupWrite: boolean; + hold: boolean; + isLegacy: boolean; + locale: string; + paired: boolean; + quality: Quality | null; + reads: Read[]; + subtractions: SubtractionNested[]; +}; + +/** A page of samples with pagination metadata. */ +export type SampleSearchResult = SearchResultV2 & { + items: SampleMinimal[]; +}; + +/** The rights fields that can be changed on a sample. */ +export type SampleRightsUpdate = { + allRead?: boolean; + allWrite?: boolean; + group?: number | string | null; + groupRead?: boolean; + groupWrite?: boolean; +}; + +/** + * Fields accepted when creating a sample. A name is required; the rest default + * to empty. `group` is a group id, a legacy string, or null (`""` and `"none"` + * mean "no group"); `files` is one or two reads uploads. + */ +export const SampleCreateRequest = z.object({ + name: z.string().trim().min(1), + host: z.string().trim().default(""), + isolate: z.string().trim().default(""), + locale: z.string().trim().default(""), + notes: z.string().default(""), + libraryType: z + .enum(["amplicon", "srna", "other", "normal"]) + .default("normal"), + group: z.union([z.number().int(), z.string(), z.null()]).default(null), + subtractions: z.array(z.number().int().positive()).default([]), + labels: z.array(z.number().int().positive()).default([]), + files: z.array(z.number().int().positive()).min(1).max(2), +}); + +export type SampleCreateRequest = z.infer; + +/** Fields accepted when updating a sample. Only those present are changed. */ +export const SampleUpdateRequest = z.object({ + name: z.string().trim().min(1).optional(), + host: z.string().trim().optional(), + isolate: z.string().trim().optional(), + locale: z.string().trim().optional(), + notes: z.string().trim().optional(), + labels: z.array(z.number().int().positive()).optional(), + subtractions: z.array(z.number().int().positive()).optional(), }); -export type Sample = z.infer; +export type SampleUpdateRequest = z.infer; diff --git a/packages/contracts/src/subtractions.ts b/packages/contracts/src/subtractions.ts index 06b9e7309..4b9e38b5d 100644 --- a/packages/contracts/src/subtractions.ts +++ b/packages/contracts/src/subtractions.ts @@ -6,3 +6,12 @@ export const Subtraction = z.object({ }); export type Subtraction = z.infer; + +/** A subtraction reduced to the fields embedded in other resources. */ +export type SubtractionNested = { + /** The unique identifier */ + id: number; + + /** The display name */ + name: string; +};