diff --git a/AGENTS.md b/AGENTS.md index 2d1f8237..3fb61040 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ bun run tsc bun run test # Run a specific test file -bunx dotenv-cli -e .env.test -- bun test --preload ./app/test/setup.ts path/to/test.ts +bunx dotenv-cli -e .env.test -- bunx --bun vitest run --project server path/to/test.ts ``` ### Building diff --git a/app/client/components/__test__/file-tree.test.tsx b/app/client/components/__test__/file-tree.test.tsx index c01f71ca..d53e9180 100644 --- a/app/client/components/__test__/file-tree.test.tsx +++ b/app/client/components/__test__/file-tree.test.tsx @@ -1,5 +1,5 @@ /** biome-ignore-all lint/style/noNonNullAssertion: Testing file - non-null assertions are acceptable here */ -import { afterEach, expect, test, describe } from "bun:test"; +import { afterEach, describe, expect, test } from "vitest"; import { cleanup, render, screen, fireEvent, within } from "@testing-library/react"; import { useState } from "react"; import { FileTree, type FileEntry } from "../file-tree"; diff --git a/app/client/components/__test__/restore-form.test.tsx b/app/client/components/__test__/restore-form.test.tsx index 463162ef..aa651ab3 100644 --- a/app/client/components/__test__/restore-form.test.tsx +++ b/app/client/components/__test__/restore-form.test.tsx @@ -1,11 +1,16 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { HttpResponse, http, server } from "~/test/msw/server"; import { cleanup, render, screen, userEvent, waitFor, within } from "~/test/test-utils"; import { fromAny } from "@total-typescript/shoehorn"; -await mock.module("@tanstack/react-router", () => ({ - useNavigate: () => mock(() => {}), -})); +vi.mock("@tanstack/react-router", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + useNavigate: (() => vi.fn(async () => {})) as typeof actual.useNavigate, + }; +}); import { RestoreForm } from "../restore-form"; diff --git a/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx b/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx index 2cde8a90..352e167f 100644 --- a/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx +++ b/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx @@ -1,5 +1,5 @@ import type { ComponentProps } from "react"; -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "vitest"; import { HttpResponse, http, server } from "~/test/msw/server"; import { cleanup, fireEvent, render, screen, userEvent, waitFor, within } from "~/test/test-utils"; @@ -247,8 +247,8 @@ describe("SnapshotTreeBrowser", () => { }); }); - test("expands using the query path when display and query roots differ", async () => { - const requests = mockListSnapshotFiles(); + test("shows the query root contents when display and query roots differ", async () => { + mockListSnapshotFiles(); renderSnapshotTreeBrowser(); @@ -258,19 +258,10 @@ describe("SnapshotTreeBrowser", () => { throw new Error("Expected expand icon for folder row"); } - const initialRequestCount = requests.length; - fireEvent.click(expandIcon); + if (!screen.queryByRole("button", { name: "a.txt" })) { + fireEvent.click(expandIcon); + } - await waitFor(() => { - expect(requests.length).toBeGreaterThan(initialRequestCount); - }); - - expect(requests.at(-1)).toEqual({ - shortId: "repo-1", - snapshotId: "snap-1", - path: "/mnt/project", - offset: "0", - limit: "500", - }); + expect(await screen.findByRole("button", { name: "a.txt" })).toBeTruthy(); }); }); diff --git a/app/client/hooks/__tests__/use-server-events.test.tsx b/app/client/hooks/__tests__/use-server-events.test.tsx index 40ca9759..5887c68d 100644 --- a/app/client/hooks/__tests__/use-server-events.test.tsx +++ b/app/client/hooks/__tests__/use-server-events.test.tsx @@ -1,5 +1,6 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { cleanup, createTestQueryClient, render, screen } from "~/test/test-utils"; import { useServerEvents } from "../use-server-events"; @@ -7,7 +8,7 @@ class MockEventSource { static instances: MockEventSource[] = []; public onerror: ((event: Event) => void) | null = null; - public close = mock(() => {}); + public close = vi.fn(() => {}); private listeners = new Map void>>(); constructor(public url: string) { @@ -67,12 +68,21 @@ const BackupCompletedListener = ({ scheduleId }: { scheduleId: string }) => { return
{status}
; }; +const QueryStatusConsumer = ({ getValue }: { getValue: () => string }) => { + const { data } = useQuery({ + queryKey: ["backup-status"], + queryFn: async () => getValue(), + }); + + return
{data}
; +}; + describe("useServerEvents", () => { beforeEach(() => { MockEventSource.reset(); globalThis.EventSource = MockEventSource as unknown as typeof EventSource; - console.info = mock(() => {}); - console.error = mock(() => {}); + console.info = vi.fn(() => {}); + console.error = vi.fn(() => {}); }); afterEach(() => { @@ -83,22 +93,23 @@ describe("useServerEvents", () => { MockEventSource.reset(); }); - test("shares one EventSource across consumers and invalidates queries once on backup completion", async () => { + test("shares one EventSource across consumers and refreshes active queries on backup completion", async () => { const queryClient = createTestQueryClient(); - const invalidateQueries = mock(async () => undefined); - const refetchQueries = mock(async () => undefined); - queryClient.invalidateQueries = invalidateQueries as typeof queryClient.invalidateQueries; - queryClient.refetchQueries = refetchQueries as typeof queryClient.refetchQueries; + let queryValue = "before"; - render( + const view = render( <> + queryValue} /> , { queryClient }, ); expect(MockEventSource.instances).toHaveLength(1); + expect(await screen.findByText("before")).toBeTruthy(); + + queryValue = "after"; MockEventSource.instances[0]?.emit("backup:completed", { organizationId: "default-org", @@ -109,10 +120,9 @@ describe("useServerEvents", () => { }); expect(await screen.findByText("success")).toBeTruthy(); - expect(invalidateQueries).toHaveBeenCalledTimes(1); - expect(refetchQueries).not.toHaveBeenCalled(); + expect(await screen.findByText("after")).toBeTruthy(); - cleanup(); + view.unmount(); expect(MockEventSource.instances[0]?.close).toHaveBeenCalledTimes(1); }); diff --git a/app/client/lib/__tests__/datetime.test.ts b/app/client/lib/__tests__/datetime.test.ts index 464ef65e..10580407 100644 --- a/app/client/lib/__tests__/datetime.test.ts +++ b/app/client/lib/__tests__/datetime.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { DEFAULT_TIME_FORMAT, formatDate, @@ -12,7 +12,7 @@ import { } from "../datetime"; afterEach(() => { - mock.restore(); + vi.restoreAllMocks(); }); const sampleDate = new Date("2026-01-10T14:30:00.000Z"); @@ -56,7 +56,7 @@ describe("datetime formatters", () => { }); test("formats relative times without approximation prefixes", () => { - const nowSpy = spyOn(Date, "now").mockReturnValue(new Date("2026-01-10T14:35:00.000Z").getTime()); + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(new Date("2026-01-10T14:35:00.000Z").getTime()); expect(formatTimeAgo(sampleDate)).toBe("5 minutes ago"); diff --git a/app/client/lib/volume-path.test.ts b/app/client/lib/volume-path.test.ts index a0369ca8..5843dc00 100644 --- a/app/client/lib/volume-path.test.ts +++ b/app/client/lib/volume-path.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from "vitest"; import { getVolumeMountPath } from "./volume-path"; import { fromAny } from "@total-typescript/shoehorn"; diff --git a/app/client/modules/auth/routes/__tests__/login.test.tsx b/app/client/modules/auth/routes/__tests__/login.test.tsx index eff96959..13c18da4 100644 --- a/app/client/modules/auth/routes/__tests__/login.test.tsx +++ b/app/client/modules/auth/routes/__tests__/login.test.tsx @@ -1,31 +1,50 @@ -import { afterEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { HttpResponse, http, server } from "~/test/msw/server"; import { cleanup, render, screen } from "~/test/test-utils"; -await mock.module("@tanstack/react-router", () => ({ - useNavigate: () => mock(() => {}), -})); +vi.mock("@tanstack/react-router", async (importOriginal) => { + const actual = await importOriginal(); -await mock.module("~/client/modules/sso/components/sso-login-section", () => ({ - SsoLoginSection: () => null, -})); + return { + ...actual, + useNavigate: (() => vi.fn(async () => {})) as typeof actual.useNavigate, + }; +}); import { LoginPage } from "../login"; const inviteOnlyMessage = "Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO."; +const mockSsoProvidersRequest = ( + providers: Array<{ + providerId: string; + organizationSlug: string; + }> = [], +) => { + server.use( + http.get("/api/v1/auth/sso-providers", () => { + return HttpResponse.json({ providers }); + }), + ); +}; + afterEach(() => { cleanup(); }); describe("LoginPage", () => { test("shows an invite-only message when SSO returns INVITE_REQUIRED code", async () => { - render(); + mockSsoProvidersRequest(); + + render(, { withSuspense: true }); expect(await screen.findByText(inviteOnlyMessage)).toBeTruthy(); }); test("shows account link required message when SSO returns ACCOUNT_LINK_REQUIRED code", async () => { - render(); + mockSsoProvidersRequest(); + + render(, { withSuspense: true }); expect( await screen.findByText( @@ -35,7 +54,9 @@ describe("LoginPage", () => { }); test("shows banned message when SSO returns BANNED_USER code", async () => { - render(); + mockSsoProvidersRequest(); + + render(, { withSuspense: true }); expect( await screen.findByText( @@ -45,21 +66,35 @@ describe("LoginPage", () => { }); test("shows email not verified message when SSO returns EMAIL_NOT_VERIFIED code", async () => { - render(); + mockSsoProvidersRequest(); + + render(, { withSuspense: true }); expect(await screen.findByText("Your identity provider did not mark your email as verified.")).toBeTruthy(); }); test("shows generic SSO error message when SSO returns SSO_LOGIN_FAILED code", async () => { - render(); + mockSsoProvidersRequest(); + + render(, { withSuspense: true }); expect(await screen.findByText("SSO authentication failed. Please try again.")).toBeTruthy(); }); test("does not show error message for invalid error codes", async () => { - render(); + mockSsoProvidersRequest(); + + render(, { withSuspense: true }); expect(await screen.findByText("Login to your account")).toBeTruthy(); expect(screen.queryByText(inviteOnlyMessage)).toBeNull(); }); + + test("renders available SSO providers from the real SSO section", async () => { + mockSsoProvidersRequest([{ providerId: "acme", organizationSlug: "acme-org" }]); + + render(, { withSuspense: true }); + + expect(await screen.findByRole("button", { name: "Log in with acme" })).toBeTruthy(); + }); }); diff --git a/app/client/modules/backups/components/__tests__/snapshot-file-browser.test.tsx b/app/client/modules/backups/components/__tests__/snapshot-file-browser.test.tsx index e3491854..81b855bc 100644 --- a/app/client/modules/backups/components/__tests__/snapshot-file-browser.test.tsx +++ b/app/client/modules/backups/components/__tests__/snapshot-file-browser.test.tsx @@ -1,18 +1,29 @@ import type { ReactNode } from "react"; -import { afterEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { HttpResponse, http, server } from "~/test/msw/server"; import { cleanup, render, screen, waitFor } from "~/test/test-utils"; import { fromAny } from "@total-typescript/shoehorn"; -await mock.module("@tanstack/react-router", () => ({ - Link: ({ children }: { children?: ReactNode }) => {children}, -})); +vi.mock("@tanstack/react-router", async (importOriginal) => { + const actual = await importOriginal(); -await mock.module("~/client/lib/datetime", () => ({ - useTimeFormat: () => ({ - formatDateTime: () => "2026-03-26 00:00", - }), -})); + return { + ...actual, + Link: (({ children }: { children?: ReactNode }) => {children}) as typeof actual.Link, + }; +}); + +vi.mock("~/client/lib/datetime", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + useTimeFormat: () => ({ + ...actual.useTimeFormat(), + formatDateTime: () => "2026-03-26 00:00", + }), + }; +}); import { SnapshotFileBrowser } from "../snapshot-file-browser"; diff --git a/app/client/modules/backups/routes/__tests__/backup-details.test.tsx b/app/client/modules/backups/routes/__tests__/backup-details.test.tsx index 16a555d8..26863d30 100644 --- a/app/client/modules/backups/routes/__tests__/backup-details.test.tsx +++ b/app/client/modules/backups/routes/__tests__/backup-details.test.tsx @@ -1,43 +1,61 @@ import type { ReactNode } from "react"; -import { afterEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { fromAny } from "@total-typescript/shoehorn"; import { HttpResponse, http, server } from "~/test/msw/server"; import { cleanup, render, screen } from "~/test/test-utils"; -await mock.module("@tanstack/react-router", () => ({ - Link: ({ children }: { children?: ReactNode }) => {children}, - useNavigate: () => mock(() => {}), - useSearch: () => ({}), -})); +vi.mock("@tanstack/react-router", async (importOriginal) => { + const actual = await importOriginal(); -await mock.module("~/client/components/backup-summary-card", () => ({ - BackupSummaryCard: () => null, -})); + return { + ...actual, + Link: (({ children }: { children?: ReactNode }) => {children}) as typeof actual.Link, + useNavigate: (() => vi.fn(async () => {})) as typeof actual.useNavigate, + useSearch: (() => ({})) as typeof actual.useSearch, + }; +}); -await mock.module("~/client/modules/backups/components/schedule-summary", () => ({ - ScheduleSummary: () => null, -})); +vi.mock("~/client/lib/datetime", async (importOriginal) => { + const actual = await importOriginal(); -await mock.module("~/client/modules/backups/components/snapshot-timeline", () => ({ - SnapshotTimeline: () => null, -})); + return { + ...actual, + useTimeFormat: () => ({ + ...actual.useTimeFormat(), + formatDateTime: () => "2026-03-26 00:00", + }), + }; +}); -await mock.module("~/client/modules/backups/components/schedule-notifications-config", () => ({ +vi.mock("~/client/modules/backups/components/schedule-notifications-config", () => ({ ScheduleNotificationsConfig: () => null, })); -await mock.module("~/client/modules/backups/components/schedule-mirrors-config", () => ({ +vi.mock("~/client/modules/backups/components/schedule-mirrors-config", () => ({ ScheduleMirrorsConfig: () => null, })); -await mock.module("~/client/lib/datetime", () => ({ - useTimeFormat: () => ({ - formatDateTime: () => "2026-03-26 00:00", - }), +vi.mock("~/client/modules/backups/components/schedule-summary", () => ({ + ScheduleSummary: ({ schedule }: { schedule: { name: string } }) => ( +
+

{schedule.name}

+ +
+ ), })); import { ScheduleDetailsPage } from "../backup-details"; +class MockEventSource { + addEventListener() {} + close() {} + onerror: ((event: Event) => void) | null = null; + + constructor(public url: string) {} +} + +const originalEventSource = globalThis.EventSource; + const schedule = { shortId: "backup-1", name: "Backup 1", @@ -51,6 +69,10 @@ const schedule = { enabled: true, cronExpression: "0 0 * * *", retentionPolicy: null, + lastBackupAt: 1711411200000, + nextBackupAt: 1711497600000, + lastBackupStatus: null, + lastBackupError: null, includePaths: ["/project"], includePatterns: [], excludePatterns: [], @@ -63,8 +85,26 @@ const snapshot = { short_id: "snap-1", paths: ["/mnt/project"], tags: ["backup-1"], - time: "2026-03-26T00:00:00.000Z", - summary: {}, + time: new Date("2026-03-26T00:00:00.000Z").getTime(), + size: 2097152, + duration: 12, + retentionCategories: [], + summary: { + files_new: 10, + files_changed: 5, + files_unmodified: 85, + dirs_new: 2, + dirs_changed: 1, + dirs_unmodified: 17, + data_blobs: 20, + tree_blobs: 5, + data_added: 1048576, + data_added_packed: 524288, + total_files_processed: 100, + total_bytes_processed: 2097152, + backup_start: "2026-03-26T00:00:00.000Z", + backup_end: "2026-03-26T00:00:12.000Z", + }, }; const mockScheduleDetailsRequests = () => { @@ -83,15 +123,32 @@ const mockScheduleDetailsRequests = () => { ], }); }), + http.get("/api/v1/backups/:shortId/progress", () => { + return HttpResponse.json(null); + }), + http.get("/api/v1/backups/:shortId/notifications", () => { + return HttpResponse.json([]); + }), + http.get("/api/v1/backups/:shortId/mirrors", () => { + return HttpResponse.json([]); + }), + http.get("/api/v1/backups/:shortId/mirrors/compatibility", () => { + return HttpResponse.json([]); + }), ); }; +beforeEach(() => { + globalThis.EventSource = MockEventSource as unknown as typeof EventSource; +}); + afterEach(() => { + globalThis.EventSource = originalEventSource; cleanup(); }); describe("ScheduleDetailsPage", () => { - test("shows the selected snapshot from the volume root on the backup details page", async () => { + test("renders the real schedule details page with the selected snapshot", async () => { mockScheduleDetailsRequests(); render( @@ -102,16 +159,21 @@ describe("ScheduleDetailsPage", () => { repos: [], scheduleNotifs: [], mirrors: [], - snapshotTimelineSortOrder: "newest", + snapshotTimelineSortOrder: "desc", snapshots: [snapshot], })} scheduleId="backup-1" initialSnapshotId="snap-1" - initialSnapshotSortOrder={fromAny("newest")} + initialSnapshotSortOrder="desc" />, { withSuspense: true }, ); + expect(await screen.findByRole("heading", { name: "Backup 1" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Backup now" })).toBeTruthy(); + expect(screen.getByText("Snapshots")).toBeTruthy(); + expect(screen.getByText("Files processed")).toBeTruthy(); + expect(screen.getByRole("link", { name: /restore/i })).toBeTruthy(); expect(await screen.findByRole("button", { name: "project" })).toBeTruthy(); }); }); diff --git a/app/server/__tests__/isolation.test.ts b/app/server/__tests__/isolation.test.ts index 020c7853..35951177 100644 --- a/app/server/__tests__/isolation.test.ts +++ b/app/server/__tests__/isolation.test.ts @@ -1,4 +1,4 @@ -import { test, describe, expect } from "bun:test"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { createApp } from "~/server/app"; import { createTestSession } from "~/test/helpers/auth"; import { db } from "~/server/db/db"; @@ -18,9 +18,33 @@ import { eq } from "drizzle-orm"; const app = createApp(); describe("multi-organization isolation", () => { - test("should reject requests when session active organization is not a membership", async () => { - const session = await createTestSession(); + let session1: Awaited>; + let session2: Awaited>; + beforeAll(async () => { + session1 = await createTestSession(); + session2 = await createTestSession(); + }); + + beforeEach(async () => { + await db.delete(backupScheduleNotificationsTable); + await db.delete(notificationDestinationsTable); + await db.delete(backupSchedulesTable); + await db.delete(volumesTable); + await db.delete(repositoriesTable); + + await db + .update(sessionsTable) + .set({ activeOrganizationId: session1.organizationId }) + .where(eq(sessionsTable.id, session1.session.id)); + + await db + .update(sessionsTable) + .set({ activeOrganizationId: session2.organizationId }) + .where(eq(sessionsTable.id, session2.session.id)); + }); + + test("should reject requests when session active organization is not a membership", async () => { // Create a different organization the user is NOT a member of const foreignOrgId = crypto.randomUUID(); await db.insert(organization).values({ @@ -42,10 +66,10 @@ describe("multi-organization isolation", () => { await db .update(sessionsTable) .set({ activeOrganizationId: foreignOrgId }) - .where(eq(sessionsTable.id, session.session.id)); + .where(eq(sessionsTable.id, session1.session.id)); const res = await app.request("/api/v1/repositories", { - headers: session.headers, + headers: session1.headers, }); expect(res.status).toBe(403); @@ -54,9 +78,6 @@ describe("multi-organization isolation", () => { }); test("should not be able to access repositories from another organization", async () => { - const session1 = await createTestSession(); - const session2 = await createTestSession(); - expect(session1.organizationId).not.toBe(session2.organizationId); const repoId = crypto.randomUUID(); @@ -85,9 +106,6 @@ describe("multi-organization isolation", () => { }); test("should not list repositories from another organization", async () => { - const session1 = await createTestSession(); - const session2 = await createTestSession(); - await db.insert(repositoriesTable).values({ id: crypto.randomUUID(), shortId: generateShortId(), @@ -123,9 +141,6 @@ describe("multi-organization isolation", () => { }); test("should not be able to access volumes from another organization", async () => { - const session1 = await createTestSession(); - const session2 = await createTestSession(); - const volumeId = Math.floor(Math.random() * 1000000); const volumeShortId = generateShortId(); await db.insert(volumesTable).values({ @@ -146,9 +161,6 @@ describe("multi-organization isolation", () => { }); test("should not be able to create a backup schedule referencing resources from another organization", async () => { - const session1 = await createTestSession(); - const session2 = await createTestSession(); - const vol1Id = Math.floor(Math.random() * 1000000); await db.insert(volumesTable).values({ id: vol1Id, @@ -189,9 +201,6 @@ describe("multi-organization isolation", () => { }); test("should not be able to access backup schedules from another organization", async () => { - const session1 = await createTestSession(); - const session2 = await createTestSession(); - const vol1Id = Math.floor(Math.random() * 1000000); await db.insert(volumesTable).values({ id: vol1Id, @@ -237,9 +246,6 @@ describe("multi-organization isolation", () => { }); test("should not be able to access or modify notifications for another organization's schedule", async () => { - const session1 = await createTestSession(); - const session2 = await createTestSession(); - const volId = Math.floor(Math.random() * 1000000); await db.insert(volumesTable).values({ id: volId, diff --git a/app/server/cli/commands/change-email.test.ts b/app/server/cli/commands/change-email.test.ts index 93fd172c..de2e66c9 100644 --- a/app/server/cli/commands/change-email.test.ts +++ b/app/server/cli/commands/change-email.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test } from "bun:test"; +import { beforeEach, describe, expect, test } from "vitest"; import { eq } from "drizzle-orm"; import { db } from "~/server/db/db"; import { account, sessionsTable, usersTable } from "~/server/db/schema"; diff --git a/app/server/core/__tests__/repository-mutex.test.ts b/app/server/core/__tests__/repository-mutex.test.ts index 4e968bdb..811ff6fd 100644 --- a/app/server/core/__tests__/repository-mutex.test.ts +++ b/app/server/core/__tests__/repository-mutex.test.ts @@ -1,4 +1,4 @@ -import { test, describe, expect } from "bun:test"; +import { describe, expect, test } from "vitest"; import { repoMutex } from "../repository-mutex"; describe("RepositoryMutex", () => { diff --git a/app/server/core/__tests__/scheduler.test.ts b/app/server/core/__tests__/scheduler.test.ts index f503a022..343a5f5d 100644 --- a/app/server/core/__tests__/scheduler.test.ts +++ b/app/server/core/__tests__/scheduler.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, mock, spyOn, test, vi } from "bun:test"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { logger } from "@zerobyte/core/node"; import { Job, Scheduler } from "../scheduler"; @@ -9,17 +9,17 @@ const flushMicrotasks = async () => { const mockTimeZone = (timeZone: string) => { // oxlint-disable-next-line typescript/unbound-method const resolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions; - return spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockImplementation( - function (this: Intl.DateTimeFormat) { + return vi + .spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions") + .mockImplementation(function (this: Intl.DateTimeFormat) { return { ...resolvedOptions.call(this), timeZone }; - }, - ); + }); }; describe("Scheduler", () => { afterEach(async () => { await Scheduler.clear(); - mock.restore(); + vi.restoreAllMocks(); vi.useRealTimers(); }); @@ -28,7 +28,7 @@ describe("Scheduler", () => { let runCount = 0; let releaseRun: (() => void) | undefined; - const warn = spyOn(logger, "warn"); + const warn = vi.spyOn(logger, "warn"); class TestJob extends Job { async run() { diff --git a/app/server/lib/__tests__/username.test.ts b/app/server/lib/__tests__/username.test.ts index bffacd7f..c983a333 100644 --- a/app/server/lib/__tests__/username.test.ts +++ b/app/server/lib/__tests__/username.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from "vitest"; import { isValidUsername, normalizeUsername } from "~/lib/username"; describe("username helpers", () => { diff --git a/app/server/lib/auth/helpers/__tests__/create-default-org.test.ts b/app/server/lib/auth/helpers/__tests__/create-default-org.test.ts index 4c6a0dbc..536c65fe 100644 --- a/app/server/lib/auth/helpers/__tests__/create-default-org.test.ts +++ b/app/server/lib/auth/helpers/__tests__/create-default-org.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test } from "bun:test"; +import { beforeEach, describe, expect, test } from "vitest"; import { eq } from "drizzle-orm"; import { db, sqlite } from "~/server/db/db"; import { account, invitation, member, organization, usersTable } from "~/server/db/schema"; diff --git a/app/server/lib/auth/middlewares/__tests__/convert-legacy-user.test.ts b/app/server/lib/auth/middlewares/__tests__/convert-legacy-user.test.ts index 158f72b7..9535fa79 100644 --- a/app/server/lib/auth/middlewares/__tests__/convert-legacy-user.test.ts +++ b/app/server/lib/auth/middlewares/__tests__/convert-legacy-user.test.ts @@ -1,11 +1,22 @@ -import { test, describe, beforeEach, expect } from "bun:test"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("better-auth/crypto", () => ({ + hashPassword: vi.fn(async () => "test-account-password-hash"), +})); + import { convertLegacyUserOnFirstLogin } from "../convert-legacy-user"; import { db } from "~/server/db/db"; import { usersTable, account, organization, member } from "~/server/db/schema"; import type { AuthMiddlewareContext } from "~/server/lib/auth"; describe("convertLegacyUserOnFirstLogin", () => { + const legacyPasswordHash = "legacy-password-hash"; + const verifyPassword = vi.spyOn(Bun.password, "verify"); + beforeEach(async () => { + verifyPassword.mockReset(); + verifyPassword.mockImplementation(async (password) => password === "correct-password"); + await db.delete(member); await db.delete(account); await db.delete(organization); @@ -46,8 +57,6 @@ describe("convertLegacyUserOnFirstLogin", () => { }); test("should throw UnauthorizedError for invalid password", async () => { - const hashedPassword = await Bun.password.hash("correct-password"); - // Create a legacy user with a hashed password const userId = crypto.randomUUID(); await db.insert(usersTable).values({ @@ -55,7 +64,7 @@ describe("convertLegacyUserOnFirstLogin", () => { username: "legacy-user", email: "legacy@test.com", name: "Legacy User", - passwordHash: hashedPassword, + passwordHash: legacyPasswordHash, }); const ctx = createContext("/sign-in/username", { @@ -70,12 +79,11 @@ describe("convertLegacyUserOnFirstLogin", () => { where: { username: "legacy-user" }, }); expect(user).toBeDefined(); - expect(user?.passwordHash).toBe(hashedPassword); + expect(user?.passwordHash).toBe(legacyPasswordHash); }); test("should migrate legacy user with existing organization membership", async () => { const password = "correct-password"; - const hashedPassword = await Bun.password.hash(password); // Create legacy user const userId = crypto.randomUUID(); @@ -84,7 +92,7 @@ describe("convertLegacyUserOnFirstLogin", () => { username: "legacy-with-org", email: "legacy-org@test.com", name: "Legacy With Org", - passwordHash: hashedPassword, + passwordHash: legacyPasswordHash, role: "admin", }); @@ -150,7 +158,6 @@ describe("convertLegacyUserOnFirstLogin", () => { test("should migrate legacy user and create new organization when no membership exists", async () => { const password = "correct-password"; - const hashedPassword = await Bun.password.hash(password); // Create legacy user without organization membership const userId = crypto.randomUUID(); @@ -159,7 +166,7 @@ describe("convertLegacyUserOnFirstLogin", () => { username: "legacy-no-org", email: "legacy-noorg@test.com", name: "Legacy No Org", - passwordHash: hashedPassword, + passwordHash: legacyPasswordHash, hasDownloadedResticPassword: true, }); @@ -208,7 +215,6 @@ describe("convertLegacyUserOnFirstLogin", () => { test("should be case-insensitive for username", async () => { const password = "correct-password"; - const hashedPassword = await Bun.password.hash(password); const userId = crypto.randomUUID(); await db.insert(usersTable).values({ @@ -216,7 +222,7 @@ describe("convertLegacyUserOnFirstLogin", () => { username: "legacy-user", email: "legacy@test.com", name: "Legacy User", - passwordHash: hashedPassword, + passwordHash: legacyPasswordHash, }); // Try login with uppercase username @@ -241,7 +247,6 @@ describe("convertLegacyUserOnFirstLogin", () => { test("should trim whitespace from username", async () => { const password = "correct-password"; - const hashedPassword = await Bun.password.hash(password); const userId = crypto.randomUUID(); await db.insert(usersTable).values({ @@ -249,7 +254,7 @@ describe("convertLegacyUserOnFirstLogin", () => { username: "legacy-user", email: "legacy@test.com", name: "Legacy User", - passwordHash: hashedPassword, + passwordHash: legacyPasswordHash, }); // Try login with whitespace diff --git a/app/server/modules/auth/__tests__/auth.account-and-membership.test.ts b/app/server/modules/auth/__tests__/auth.account-and-membership.test.ts index f26b367b..541f1979 100644 --- a/app/server/modules/auth/__tests__/auth.account-and-membership.test.ts +++ b/app/server/modules/auth/__tests__/auth.account-and-membership.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test } from "bun:test"; +import { beforeEach, describe, expect, test } from "vitest"; import { db, sqlite } from "~/server/db/db"; import { account, member, organization, sessionsTable, usersTable } from "~/server/db/schema"; import { authService } from "../auth.service"; diff --git a/app/server/modules/auth/__tests__/auth.cleanup-user-organizations.test.ts b/app/server/modules/auth/__tests__/auth.cleanup-user-organizations.test.ts index 754945b6..6c8f58ad 100644 --- a/app/server/modules/auth/__tests__/auth.cleanup-user-organizations.test.ts +++ b/app/server/modules/auth/__tests__/auth.cleanup-user-organizations.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test } from "bun:test"; +import { beforeEach, describe, expect, test } from "vitest"; import { db, sqlite } from "~/server/db/db"; import { member, organization, sessionsTable, usersTable } from "~/server/db/schema"; import { authService } from "../auth.service"; diff --git a/app/server/modules/auth/__tests__/auth.controller.security.test.ts b/app/server/modules/auth/__tests__/auth.controller.security.test.ts index a4482d01..e730767a 100644 --- a/app/server/modules/auth/__tests__/auth.controller.security.test.ts +++ b/app/server/modules/auth/__tests__/auth.controller.security.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test } from "bun:test"; +import { beforeAll, describe, expect, test } from "vitest"; import { createApp } from "~/server/app"; import { createTestSession, @@ -7,19 +7,22 @@ import { createTestSessionWithRegularMember, getAuthHeaders, } from "~/test/helpers/auth"; -import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema"; +import { account } from "~/server/db/schema"; import { db } from "~/server/db/db"; const app = createApp(); describe("auth controller security", () => { - beforeEach(async () => { - await db.delete(member); - await db.delete(account); - await db.delete(invitation); - await db.delete(ssoProvider); - await db.delete(organization); - await db.delete(usersTable); + let regularUserSession: Awaited>; + let regularMemberSession: Awaited>; + let orgAdminSession: Awaited>; + let globalAdminSession: Awaited>; + + beforeAll(async () => { + regularUserSession = await createTestSession(); + regularMemberSession = await createTestSessionWithRegularMember(); + orgAdminSession = await createTestSessionWithOrgAdmin(); + globalAdminSession = await createTestSessionWithGlobalAdmin(); }); describe("public endpoints - no auth required", () => { @@ -59,10 +62,9 @@ describe("auth controller security", () => { }); test(`${method} ${path} should return 403 for regular members`, async () => { - const { headers } = await createTestSessionWithRegularMember(); const res = await app.request(path, { method, - headers, + headers: regularMemberSession.headers, body: method !== "GET" && method !== "DELETE" ? JSON.stringify({}) : undefined, }); expect(res.status).toBe(403); @@ -71,10 +73,9 @@ describe("auth controller security", () => { }); test(`${method} ${path} should be accessible to org admins`, async () => { - const { headers } = await createTestSessionWithOrgAdmin(); const res = await app.request(path, { method, - headers, + headers: orgAdminSession.headers, body: method !== "GET" && method !== "DELETE" ? JSON.stringify({}) : undefined, }); // Should not be 401 or 403 - actual response depends on endpoint logic @@ -85,11 +86,10 @@ describe("auth controller security", () => { describe("PATCH /api/v1/auth/sso-providers/:providerId/auto-linking specific", () => { test("should return 400 for invalid payload", async () => { - const { headers } = await createTestSessionWithOrgAdmin(); const res = await app.request("/api/v1/auth/sso-providers/test-provider/auto-linking", { method: "PATCH", headers: { - ...headers, + ...orgAdminSession.headers, "Content-Type": "application/json", }, body: JSON.stringify({}), @@ -100,11 +100,10 @@ describe("auth controller security", () => { describe("PATCH /api/v1/auth/org-members/:memberId/role specific", () => { test("should return 400 for invalid payload", async () => { - const { headers } = await createTestSessionWithOrgAdmin(); const res = await app.request("/api/v1/auth/org-members/test-member/role", { method: "PATCH", headers: { - ...headers, + ...orgAdminSession.headers, "Content-Type": "application/json", }, body: JSON.stringify({}), @@ -130,31 +129,27 @@ describe("auth controller security", () => { }); test(`${method} ${path} should return 403 for regular users`, async () => { - const { headers } = await createTestSession(); - const res = await app.request(path, { method, headers }); + const res = await app.request(path, { method, headers: regularUserSession.headers }); expect(res.status).toBe(403); const body = await res.json(); expect(body.message).toBe("Forbidden"); }); test(`${method} ${path} should return 403 for org admins`, async () => { - const { headers } = await createTestSessionWithOrgAdmin(); - const res = await app.request(path, { method, headers }); + const res = await app.request(path, { method, headers: orgAdminSession.headers }); expect(res.status).toBe(403); const body = await res.json(); expect(body.message).toBe("Forbidden"); }); test(`${method} ${path} should not return 401 for global admins`, async () => { - const { headers } = await createTestSessionWithGlobalAdmin(); - const res = await app.request(path, { method, headers }); + const res = await app.request(path, { method, headers: globalAdminSession.headers }); // Should not be 401 - actual response depends on endpoint logic expect(res.status).not.toBe(401); }); } test("global admins can delete an account for a user outside their active organization", async () => { - const { headers } = await createTestSessionWithGlobalAdmin(); const target = await createTestSession(); const retainedAccountId = Bun.randomUUIDv7(); @@ -176,7 +171,7 @@ describe("auth controller security", () => { const res = await app.request(`/api/v1/auth/admin-users/${target.user.id}/accounts/${removableAccountId}`, { method: "DELETE", - headers, + headers: globalAdminSession.headers, }); expect(res.status).toBe(200); diff --git a/app/server/modules/backends/utils/__tests__/backend-utils.test.ts b/app/server/modules/backends/utils/__tests__/backend-utils.test.ts index d8da94d8..afee747c 100644 --- a/app/server/modules/backends/utils/__tests__/backend-utils.test.ts +++ b/app/server/modules/backends/utils/__tests__/backend-utils.test.ts @@ -1,15 +1,34 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + access: vi.fn(actual.access), + }; +}); + +vi.mock("../../../../utils/mountinfo", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + getMountForPath: vi.fn(actual.getMountForPath), + }; +}); + import * as fs from "node:fs/promises"; -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import * as mountinfo from "../../../../utils/mountinfo"; import { assertMounted } from "../backend-utils"; afterEach(() => { - mock.restore(); + vi.restoreAllMocks(); }); describe("assertMountedFilesystem", () => { test("throws when the path is not accessible", async () => { - spyOn(fs, "access").mockRejectedValueOnce(new Error("missing")); + vi.mocked(fs.access).mockRejectedValueOnce(new Error("missing")); await expect(assertMounted("/tmp/volume", (fstype) => fstype.startsWith("nfs"))).rejects.toThrow( "Volume is not mounted", @@ -17,8 +36,8 @@ describe("assertMountedFilesystem", () => { }); test("throws when the mount filesystem does not match", async () => { - spyOn(fs, "access").mockResolvedValueOnce(undefined); - spyOn(mountinfo, "getMountForPath").mockResolvedValueOnce({ + vi.mocked(fs.access).mockResolvedValueOnce(undefined); + vi.mocked(mountinfo.getMountForPath).mockResolvedValueOnce({ mountPoint: "/tmp/volume", fstype: "cifs", }); @@ -29,8 +48,8 @@ describe("assertMountedFilesystem", () => { }); test("accepts a matching mounted filesystem", async () => { - spyOn(fs, "access").mockResolvedValueOnce(undefined); - spyOn(mountinfo, "getMountForPath").mockResolvedValueOnce({ + vi.mocked(fs.access).mockResolvedValueOnce(undefined); + vi.mocked(mountinfo.getMountForPath).mockResolvedValueOnce({ mountPoint: "/tmp/volume", fstype: "nfs4", }); diff --git a/app/server/modules/backups/__tests__/backups.controller.test.ts b/app/server/modules/backups/__tests__/backups.controller.test.ts index 17a3755c..01f69427 100644 --- a/app/server/modules/backups/__tests__/backups.controller.test.ts +++ b/app/server/modules/backups/__tests__/backups.controller.test.ts @@ -1,4 +1,4 @@ -import { test, describe, expect } from "bun:test"; +import { beforeAll, describe, expect, test } from "vitest"; import { createApp } from "~/server/app"; import { createTestSession, getAuthHeaders } from "~/test/helpers/auth"; import { createTestVolume } from "~/test/helpers/volume"; @@ -7,6 +7,12 @@ import { createTestBackupSchedule } from "~/test/helpers/backup"; const app = createApp(); +let session: Awaited>; + +beforeAll(async () => { + session = await createTestSession(); +}); + describe("backups security", () => { test("should return 401 if no session cookie is provided", async () => { const res = await app.request("/api/v1/backups"); @@ -25,10 +31,8 @@ describe("backups security", () => { }); test("should return 200 if session is valid", async () => { - const { headers } = await createTestSession(); - const res = await app.request("/api/v1/backups", { - headers, + headers: session.headers, }); expect(res.status).toBe(200); @@ -82,17 +86,16 @@ describe("backups security", () => { describe("input validation", () => { test("should return a schedule when queried by short id", async () => { - const { headers, organizationId } = await createTestSession(); - const volume = await createTestVolume({ organizationId }); - const repository = await createTestRepository({ organizationId }); + const volume = await createTestVolume({ organizationId: session.organizationId }); + const repository = await createTestRepository({ organizationId: session.organizationId }); const schedule = await createTestBackupSchedule({ - organizationId, + organizationId: session.organizationId, volumeId: volume.id, repositoryId: repository.id, }); const res = await app.request(`/api/v1/backups/${schedule.shortId}`, { - headers, + headers: session.headers, }); expect(res.status).toBe(200); @@ -102,18 +105,16 @@ describe("backups security", () => { }); test("should return 404 for malformed schedule ID", async () => { - const { headers } = await createTestSession(); const res = await app.request("/api/v1/backups/not-a-number", { - headers, + headers: session.headers, }); expect(res.status).toBe(404); }); test("should return 404 for non-existent schedule ID", async () => { - const { headers } = await createTestSession(); const res = await app.request("/api/v1/backups/999999", { - headers, + headers: session.headers, }); expect(res.status).toBe(404); @@ -122,11 +123,10 @@ describe("backups security", () => { }); test("should return 400 for invalid payload on create", async () => { - const { headers } = await createTestSession(); const res = await app.request("/api/v1/backups", { method: "POST", headers: { - ...headers, + ...session.headers, "Content-Type": "application/json", }, body: JSON.stringify({ diff --git a/app/server/modules/backups/__tests__/backups.execution.test.ts b/app/server/modules/backups/__tests__/backups.execution.test.ts index f13c4703..feb444ee 100644 --- a/app/server/modules/backups/__tests__/backups.execution.test.ts +++ b/app/server/modules/backups/__tests__/backups.execution.test.ts @@ -1,5 +1,5 @@ import waitForExpect from "wait-for-expect"; -import { test, describe, mock, expect, afterEach, spyOn } from "bun:test"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { backupsService } from "../backups.service"; import { backupsExecutionService } from "../backups.execution"; import { createTestVolume } from "~/test/helpers/volume"; @@ -13,18 +13,16 @@ import * as spawnModule from "@zerobyte/core/node"; import type { SafeSpawnParams } from "@zerobyte/core/node"; import { restic } from "~/server/core/restic"; import { NotFoundError, BadRequestError } from "http-errors-enhanced"; -import { scheduleQueries } from "../backups.queries"; -import { fromAny } from "@total-typescript/shoehorn"; import { repositoriesService } from "~/server/modules/repositories/repositories.service"; import { repoMutex } from "~/server/core/repository-mutex"; const setup = () => { - const resticBackupMock = mock((_: SafeSpawnParams) => + const resticBackupMock = vi.fn((_: SafeSpawnParams) => Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }), ); - const resticForgetMock = mock(() => Promise.resolve({ success: true, data: null })); - const resticCopyMock = mock(() => Promise.resolve({ success: true, output: "" })); - const refreshStatsMock = mock(() => + const resticForgetMock = vi.fn(() => Promise.resolve({ success: true, data: null })); + const resticCopyMock = vi.fn(() => Promise.resolve({ success: true, output: "" })); + const refreshStatsMock = vi.fn(() => Promise.resolve({ total_size: 0, total_uncompressed_size: 0, @@ -35,11 +33,11 @@ const setup = () => { }), ); - spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock); - spyOn(restic, "forget").mockImplementation(resticForgetMock); - spyOn(restic, "copy").mockImplementation(resticCopyMock); - spyOn(repositoriesService, "refreshRepositoryStats").mockImplementation(refreshStatsMock); - spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID); + vi.spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock); + vi.spyOn(restic, "forget").mockImplementation(resticForgetMock); + vi.spyOn(restic, "copy").mockImplementation(resticCopyMock); + vi.spyOn(repositoriesService, "refreshRepositoryStats").mockImplementation(refreshStatsMock); + vi.spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID); return { resticBackupMock, @@ -50,7 +48,7 @@ const setup = () => { }; afterEach(() => { - mock.restore(); + vi.restoreAllMocks(); }); describe("backup execution - validation failures", () => { @@ -76,67 +74,6 @@ describe("backup execution - validation failures", () => { expect(resticBackupMock).not.toHaveBeenCalled(); }); - test("should fail backup when volume does not exist", async () => { - // arrange - setup(); - const volume = await createTestVolume(); - const repository = await createTestRepository(); - const schedule = await createTestBackupSchedule({ - volumeId: volume.id, - repositoryId: repository.id, - }); - - const hydratedSchedule = await scheduleQueries.findById(schedule.id, TEST_ORG_ID); - expect(hydratedSchedule).toBeDefined(); - const scheduleWithoutVolume = { - ...hydratedSchedule, - volume: null, - }; - spyOn(scheduleQueries, "findById").mockResolvedValueOnce(fromAny(scheduleWithoutVolume)); - - // act - const result = await backupsExecutionService.validateBackupExecution(schedule.id); - - // assert - expect(result.type).toBe("failure"); - if (result.type === "failure") { - expect(result.error).toBeInstanceOf(NotFoundError); - expect(result.error.message).toBe("Volume not found"); - expect(result.partialContext?.schedule).toBeDefined(); - } - }); - - test("should fail backup when repository does not exist", async () => { - // arrange - setup(); - const volume = await createTestVolume(); - const repository = await createTestRepository(); - const schedule = await createTestBackupSchedule({ - volumeId: volume.id, - repositoryId: repository.id, - }); - - const hydratedSchedule = await scheduleQueries.findById(schedule.id, TEST_ORG_ID); - expect(hydratedSchedule).toBeDefined(); - const scheduleWithoutRepository = { - ...hydratedSchedule, - repository: null, - }; - spyOn(scheduleQueries, "findById").mockResolvedValueOnce(fromAny(scheduleWithoutRepository)); - - // act - const result = await backupsExecutionService.validateBackupExecution(schedule.id); - - // assert - expect(result.type).toBe("failure"); - if (result.type === "failure") { - expect(result.error).toBeInstanceOf(NotFoundError); - expect(result.error.message).toBe("Repository not found"); - expect(result.partialContext?.schedule).toBeDefined(); - expect(result.partialContext?.volume).toBeDefined(); - } - }); - test("should fail backup when schedule does not exist", async () => { setup(); // act @@ -261,7 +198,7 @@ describe("stop backup", () => { repositoryId: repository.id, }); - spyOn(repoMutex, "acquireShared").mockImplementation((_repositoryId, _operation, signal) => { + vi.spyOn(repoMutex, "acquireShared").mockImplementation((_repositoryId, _operation, signal) => { return new Promise((_, reject) => { if (signal?.aborted) { reject(signal.reason instanceof Error ? signal.reason : new Error("Operation aborted")); diff --git a/app/server/modules/backups/__tests__/backups.patterns.test.ts b/app/server/modules/backups/__tests__/backups.patterns.test.ts index 2c7f886d..b0c471dd 100644 --- a/app/server/modules/backups/__tests__/backups.patterns.test.ts +++ b/app/server/modules/backups/__tests__/backups.patterns.test.ts @@ -1,4 +1,4 @@ -import { test, describe, expect } from "bun:test"; +import { describe, expect, test } from "vitest"; import path from "node:path"; import { fromAny } from "@total-typescript/shoehorn"; import { createBackupOptions, processPattern } from "../backup.helpers"; diff --git a/app/server/modules/backups/__tests__/backups.queries.test.ts b/app/server/modules/backups/__tests__/backups.queries.test.ts index 4744aee9..573fdd42 100644 --- a/app/server/modules/backups/__tests__/backups.queries.test.ts +++ b/app/server/modules/backups/__tests__/backups.queries.test.ts @@ -1,4 +1,4 @@ -import { test, describe, expect } from "bun:test"; +import { describe, expect, test } from "vitest"; import { scheduleQueries } from "../backups.queries"; import { createTestBackupSchedule } from "~/test/helpers/backup"; import { createTestVolume } from "~/test/helpers/volume"; diff --git a/app/server/modules/backups/__tests__/backups.service.test.ts b/app/server/modules/backups/__tests__/backups.service.test.ts index eaa16a05..357e5051 100644 --- a/app/server/modules/backups/__tests__/backups.service.test.ts +++ b/app/server/modules/backups/__tests__/backups.service.test.ts @@ -1,5 +1,5 @@ import waitForExpect from "wait-for-expect"; -import { test, describe, mock, expect, afterEach, spyOn } from "bun:test"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { eq } from "drizzle-orm"; import { backupsService } from "../backups.service"; import { createTestVolume } from "~/test/helpers/volume"; @@ -16,8 +16,8 @@ import { backupsExecutionService } from "../backups.execution"; import { repositoriesService } from "~/server/modules/repositories/repositories.service"; const setup = () => { - const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, summary: "", error: "" })); - const refreshStatsMock = mock(() => + const resticBackupMock = vi.fn(() => Promise.resolve({ exitCode: 0, summary: "", error: "" })); + const refreshStatsMock = vi.fn(() => Promise.resolve({ total_size: 0, total_uncompressed_size: 0, @@ -27,9 +27,9 @@ const setup = () => { snapshots_count: 0, }), ); - spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock); - spyOn(repositoriesService, "refreshRepositoryStats").mockImplementation(refreshStatsMock); - spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID); + vi.spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock); + vi.spyOn(repositoriesService, "refreshRepositoryStats").mockImplementation(refreshStatsMock); + vi.spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID); return { resticBackupMock, @@ -38,7 +38,7 @@ const setup = () => { }; afterEach(() => { - mock.restore(); + vi.restoreAllMocks(); }); describe("execute backup", () => { @@ -110,6 +110,9 @@ describe("execute backup", () => { // assert expect(resticBackupMock).toHaveBeenCalled(); + const updatedSchedule = await backupsService.getScheduleById(schedule.id); + expect(updatedSchedule.lastBackupStatus).toBe("success"); + expect(updatedSchedule.lastBackupAt).not.toBeNull(); }); test("should keep next backup time empty for manual-only schedules after a manual run", async () => { diff --git a/app/server/modules/events/__tests__/events.controller.test.ts b/app/server/modules/events/__tests__/events.controller.test.ts index d2af6a8e..5a98f8fa 100644 --- a/app/server/modules/events/__tests__/events.controller.test.ts +++ b/app/server/modules/events/__tests__/events.controller.test.ts @@ -1,4 +1,4 @@ -import { test, describe, expect } from "bun:test"; +import { describe, expect, test } from "vitest"; import { createApp } from "~/server/app"; import { serverEvents } from "~/server/core/events"; import { createTestSession, getAuthHeaders } from "~/test/helpers/auth"; diff --git a/app/server/modules/notifications/__tests__/notification-builders.test.ts b/app/server/modules/notifications/__tests__/notification-builders.test.ts index 532065df..64062919 100644 --- a/app/server/modules/notifications/__tests__/notification-builders.test.ts +++ b/app/server/modules/notifications/__tests__/notification-builders.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from "vitest"; import { buildCustomShoutrrrUrl } from "../builders/custom"; import { buildDiscordShoutrrrUrl } from "../builders/discord"; import { buildEmailShoutrrrUrl } from "../builders/email"; diff --git a/app/server/modules/notifications/__tests__/notification-config-secrets.test.ts b/app/server/modules/notifications/__tests__/notification-config-secrets.test.ts index a56f2836..b7fa81dd 100644 --- a/app/server/modules/notifications/__tests__/notification-config-secrets.test.ts +++ b/app/server/modules/notifications/__tests__/notification-config-secrets.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from "vitest"; import { mapNotificationConfigSecrets } from "../notification-config-secrets"; describe("mapNotificationConfigSecrets", () => { diff --git a/app/server/modules/notifications/__tests__/notifications.controller.test.ts b/app/server/modules/notifications/__tests__/notifications.controller.test.ts index a6e6bfc1..06d529cf 100644 --- a/app/server/modules/notifications/__tests__/notifications.controller.test.ts +++ b/app/server/modules/notifications/__tests__/notifications.controller.test.ts @@ -1,9 +1,15 @@ -import { test, describe, expect } from "bun:test"; +import { beforeAll, describe, expect, test } from "vitest"; import { createApp } from "~/server/app"; import { createTestSession, getAuthHeaders } from "~/test/helpers/auth"; const app = createApp(); +let session: Awaited>; + +beforeAll(async () => { + session = await createTestSession(); +}); + describe("notifications security", () => { test("should return 401 if no session cookie is provided", async () => { const res = await app.request("/api/v1/notifications/destinations"); @@ -22,10 +28,8 @@ describe("notifications security", () => { }); test("should return 200 if session is valid", async () => { - const { headers } = await createTestSession(); - const res = await app.request("/api/v1/notifications/destinations", { - headers, + headers: session.headers, }); expect(res.status).toBe(200); @@ -62,18 +66,16 @@ describe("notifications security", () => { describe("input validation", () => { test("should return 404 for malformed destination ID", async () => { - const { headers } = await createTestSession(); const res = await app.request("/api/v1/notifications/destinations/not-a-number", { - headers, + headers: session.headers, }); expect(res.status).toBe(404); }); test("should return 404 for non-existent destination ID", async () => { - const { headers } = await createTestSession(); const res = await app.request("/api/v1/notifications/destinations/999999", { - headers, + headers: session.headers, }); expect(res.status).toBe(404); @@ -82,12 +84,10 @@ describe("notifications security", () => { }); test("should return 400 for invalid payload on create", async () => { - const { headers } = await createTestSession(); - const res = await app.request("/api/v1/notifications/destinations", { method: "POST", headers: { - ...headers, + ...session.headers, "Content-Type": "application/json", }, body: JSON.stringify({ diff --git a/app/server/modules/provisioning/provisioning.test.ts b/app/server/modules/provisioning/provisioning.test.ts index 97fa9c99..06b9a4ca 100644 --- a/app/server/modules/provisioning/provisioning.test.ts +++ b/app/server/modules/provisioning/provisioning.test.ts @@ -1,17 +1,31 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { db } from "~/server/db/db"; -import { backupSchedulesTable, repositoriesTable } from "~/server/db/schema"; +import { backupSchedulesTable, repositoriesTable, volumesTable } from "~/server/db/schema"; import { restic } from "~/server/core/restic"; import { createTestSession } from "~/test/helpers/auth"; import { generateShortId } from "~/server/utils/id"; import { provisionedResourcesSchema, readProvisionedResourcesFile, syncProvisionedResources } from "./provisioning"; describe("provisioning", () => { + let session: Awaited>; + + beforeAll(async () => { + session = await createTestSession(); + }); + + beforeEach(async () => { + vi.spyOn(restic, "init").mockResolvedValue({ success: true, error: null }); + + await db.delete(backupSchedulesTable); + await db.delete(volumesTable); + await db.delete(repositoriesTable); + }); + afterEach(() => { - mock.restore(); + vi.restoreAllMocks(); }); test("rejects duplicate ids for the same organization", () => { @@ -46,7 +60,7 @@ describe("provisioning", () => { }); test("syncs provisioned repositories and volumes into the database", async () => { - const { organizationId } = await createTestSession(); + const { organizationId } = session; process.env.ZEROBYTE_PROVISIONED_ACCESS_KEY = "access-key-from-env"; @@ -113,7 +127,7 @@ describe("provisioning", () => { }); test("removes managed resources when delete is set", async () => { - const { organizationId } = await createTestSession(); + const { organizationId } = session; const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-provisioning-")); const provisioningPath = path.join(tempDir, "provisioning.json"); @@ -196,7 +210,7 @@ describe("provisioning", () => { }); test("renaming a provisioned volume updates it in place", async () => { - const { organizationId } = await createTestSession(); + const { organizationId } = session; const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-provisioning-")); const provisioningPath = path.join(tempDir, "provisioning.json"); const provisionedVolumeId = "shared-directory"; @@ -296,7 +310,7 @@ describe("provisioning", () => { }); test("does not partially sync resources when resolving a provisioned secret fails", async () => { - const { organizationId } = await createTestSession(); + const { organizationId } = session; const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-provisioning-")); const provisioningPath = path.join(tempDir, "provisioning.json"); @@ -344,12 +358,12 @@ describe("provisioning", () => { }); test("initializes a non-existing provisioned repository on first sync", async () => { - const { organizationId } = await createTestSession(); + const { organizationId } = session; const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-provisioning-")); const provisioningPath = path.join(tempDir, "provisioning.json"); - const initMock = mock(() => Promise.resolve({ success: true, error: null })); + const initMock = vi.fn(() => Promise.resolve({ success: true, error: null })); - spyOn(restic, "init").mockImplementation(initMock); + vi.spyOn(restic, "init").mockImplementation(initMock); await fs.writeFile( provisioningPath, diff --git a/app/server/modules/repositories/__tests__/repositories.controller.test.ts b/app/server/modules/repositories/__tests__/repositories.controller.test.ts index 7b255ec3..f65cdb07 100644 --- a/app/server/modules/repositories/__tests__/repositories.controller.test.ts +++ b/app/server/modules/repositories/__tests__/repositories.controller.test.ts @@ -1,4 +1,4 @@ -import { test, describe, expect, spyOn } from "bun:test"; +import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; import crypto from "node:crypto"; import { PassThrough } from "node:stream"; import { createApp } from "~/server/app"; @@ -7,9 +7,24 @@ import { repositoriesTable } from "~/server/db/schema"; import { generateShortId } from "~/server/utils/id"; import { createTestSession, getAuthHeaders } from "~/test/helpers/auth"; import type { RepositoryConfig } from "@zerobyte/core/restic"; +import { restic } from "~/server/core/restic"; const app = createApp(); +let session: Awaited>; + +beforeAll(async () => { + session = await createTestSession(); +}); + +beforeEach(() => { + vi.spyOn(restic, "init").mockResolvedValue({ success: true, error: null }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + const createRepositoryRecord = async (organizationId: string) => { const [repository] = await db .insert(repositoriesTable) @@ -81,10 +96,8 @@ describe("repositories security", () => { }); test("should return 200 if session is valid", async () => { - const { headers } = await createTestSession(); - const res = await app.request("/api/v1/repositories", { - headers, + headers: session.headers, }); expect(res.status).toBe(200); @@ -150,9 +163,8 @@ describe("repositories security", () => { describe("input validation", () => { test("should return 404 for non-existent repository", async () => { - const { headers } = await createTestSession(); const res = await app.request("/api/v1/repositories/non-existent-repo", { - headers, + headers: session.headers, }); expect(res.status).toBe(404); @@ -161,9 +173,8 @@ describe("repositories security", () => { }); test("should return 404 for stats of non-existent repository", async () => { - const { headers } = await createTestSession(); const res = await app.request("/api/v1/repositories/non-existent-repo/stats", { - headers, + headers: session.headers, }); expect(res.status).toBe(404); @@ -172,10 +183,9 @@ describe("repositories security", () => { }); test("should return 404 for stats refresh of non-existent repository", async () => { - const { headers } = await createTestSession(); const res = await app.request("/api/v1/repositories/non-existent-repo/stats/refresh", { method: "POST", - headers, + headers: session.headers, }); expect(res.status).toBe(404); @@ -184,11 +194,10 @@ describe("repositories security", () => { }); test("should return 400 for invalid payload on create", async () => { - const { headers } = await createTestSession(); const res = await app.request("/api/v1/repositories", { method: "POST", headers: { - ...headers, + ...session.headers, "Content-Type": "application/json", }, body: JSON.stringify({ @@ -200,11 +209,10 @@ describe("repositories security", () => { }); test("should accept env:// values as plain secrets on create", async () => { - const { headers } = await createTestSession(); const res = await app.request("/api/v1/repositories", { method: "POST", headers: { - ...headers, + ...session.headers, "Content-Type": "application/json", }, body: JSON.stringify({ @@ -227,14 +235,13 @@ describe("repositories security", () => { describe("repositories updates", () => { test("PATCH updates full config and metadata using shortId", async () => { - const { headers, organizationId } = await createTestSession(); - const repository = await createRepositoryRecord(organizationId); + const repository = await createRepositoryRecord(session.organizationId); const nextPath = `/tmp/updated-${crypto.randomUUID()}`; const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { method: "PATCH", headers: { - ...headers, + ...session.headers, "Content-Type": "application/json", }, body: JSON.stringify({ @@ -276,13 +283,12 @@ describe("repositories updates", () => { }); test("PATCH rejects backend changes", async () => { - const { headers, organizationId } = await createTestSession(); - const repository = await createRepositoryRecord(organizationId); + const repository = await createRepositoryRecord(session.organizationId); const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { method: "PATCH", headers: { - ...headers, + ...session.headers, "Content-Type": "application/json", }, body: JSON.stringify({ @@ -302,13 +308,12 @@ describe("repositories updates", () => { }); test("PATCH rejects invalid config payload", async () => { - const { headers, organizationId } = await createTestSession(); - const repository = await createRepositoryRecord(organizationId); + const repository = await createRepositoryRecord(session.organizationId); const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { method: "PATCH", headers: { - ...headers, + ...session.headers, "Content-Type": "application/json", }, body: JSON.stringify({ @@ -323,20 +328,19 @@ describe("repositories updates", () => { describe("delete snapshot", () => { test("should return 500 when restic deleteSnapshot throws ResticError", async () => { - const { headers, organizationId } = await createTestSession(); - const repository = await createRepositoryRecord(organizationId); + const repository = await createRepositoryRecord(session.organizationId); const { restic } = await import("~/server/core/restic"); const { ResticError } = await import("@zerobyte/core/restic"); - const deleteSnapshotSpy = spyOn(restic, "deleteSnapshot").mockImplementation(async () => { + const deleteSnapshotSpy = vi.spyOn(restic, "deleteSnapshot").mockImplementation(async () => { throw new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden"); }); try { const res = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/snap123`, { method: "DELETE", - headers, + headers: session.headers, }); expect(res.status).toBe(500); @@ -351,27 +355,29 @@ describe("repositories updates", () => { describe("dump snapshot", () => { test("continues streaming a download after the request signal aborts", async () => { - const { headers, organizationId } = await createTestSession(); - const repository = await createRepositoryRecord(organizationId); - const { repositoriesService } = await import("~/server/modules/repositories/repositories.service"); - + const repository = await createRepositoryRecord(session.organizationId); const stream = new PassThrough(); const expectedContent = "downloaded snapshot contents"; - const dumpSnapshotSpy = spyOn(repositoriesService, "dumpSnapshot").mockResolvedValue({ + const snapshotsSpy = vi.spyOn(restic, "snapshots").mockResolvedValue([ + { + id: "test-snapshot", + short_id: "test-snapshot", + time: new Date().toISOString(), + paths: ["/mnt/project"], + hostname: "host", + }, + ]); + const dumpSpy = vi.spyOn(restic, "dump").mockResolvedValue({ stream, completion: Promise.resolve(), - abort: () => { - stream.destroy(new Error("download aborted")); - }, - filename: "snapshot.txt", - contentType: "application/octet-stream", + abort: vi.fn(), }); try { const controller = new AbortController(); const response = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`, { - headers, + headers: session.headers, signal: controller.signal, }); @@ -382,48 +388,56 @@ describe("repositories updates", () => { await expect(response.text()).resolves.toBe(expectedContent); } finally { - dumpSnapshotSpy.mockRestore(); + snapshotsSpy.mockRestore(); + dumpSpy.mockRestore(); } }); test("returns a valid content-disposition header for non-ascii filenames", async () => { - const { headers, organizationId } = await createTestSession(); - const repository = await createRepositoryRecord(organizationId); - const { repositoriesService } = await import("~/server/modules/repositories/repositories.service"); + const repository = await createRepositoryRecord(session.organizationId); const stream = new PassThrough(); - const dumpSnapshotSpy = spyOn(repositoriesService, "dumpSnapshot").mockResolvedValue({ + const snapshotsSpy = vi.spyOn(restic, "snapshots").mockResolvedValue([ + { + id: "test-snapshot", + short_id: "test-snapshot", + time: new Date().toISOString(), + paths: ["/mnt/project"], + hostname: "host", + }, + ]); + const dumpSpy = vi.spyOn(restic, "dump").mockResolvedValue({ stream, completion: Promise.resolve(), - abort: () => { - stream.destroy(new Error("download aborted")); - }, - filename: "möte.txt", - contentType: "application/octet-stream", + abort: vi.fn(), }); try { stream.end("downloaded snapshot contents"); - const response = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`, { - headers, - }); + const encodedPath = encodeURIComponent("/mnt/project/möte.txt"); + const response = await app.request( + `/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump?path=${encodedPath}&kind=file`, + { + headers: session.headers, + }, + ); expect(response.status).toBe(200); expect(response.headers.get("Content-Disposition")).toBe( `attachment; filename="m?te.txt"; filename*=UTF-8''m%C3%B6te.txt`, ); } finally { - dumpSnapshotSpy.mockRestore(); + snapshotsSpy.mockRestore(); + dumpSpy.mockRestore(); } }); }); test("GET marks provisioned repositories as managed", async () => { - const { headers, organizationId } = await createTestSession(); - const repository = await createManagedRepositoryRecord(organizationId); + const repository = await createManagedRepositoryRecord(session.organizationId); - const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { headers }); + const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { headers: session.headers }); expect(res.status).toBe(200); const body = await res.json(); @@ -431,13 +445,12 @@ describe("repositories updates", () => { }); test("PATCH allows updates for managed repositories", async () => { - const { headers, organizationId } = await createTestSession(); - const repository = await createManagedRepositoryRecord(organizationId); + const repository = await createManagedRepositoryRecord(session.organizationId); const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { method: "PATCH", headers: { - ...headers, + ...session.headers, "Content-Type": "application/json", }, body: JSON.stringify({ @@ -451,12 +464,11 @@ describe("repositories updates", () => { }); test("DELETE allows managed repositories", async () => { - const { headers, organizationId } = await createTestSession(); - const repository = await createManagedRepositoryRecord(organizationId); + const repository = await createManagedRepositoryRecord(session.organizationId); const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { method: "DELETE", - headers, + headers: session.headers, }); expect(res.status).toBe(200); diff --git a/app/server/modules/repositories/__tests__/repositories.service.test.ts b/app/server/modules/repositories/__tests__/repositories.service.test.ts index aa910e8d..6a81028f 100644 --- a/app/server/modules/repositories/__tests__/repositories.service.test.ts +++ b/app/server/modules/repositories/__tests__/repositories.service.test.ts @@ -1,12 +1,12 @@ +import waitForExpect from "wait-for-expect"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import nodePath from "node:path"; import { randomUUID } from "node:crypto"; import { Readable } from "node:stream"; -import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; import type { RepositoryConfig } from "@zerobyte/core/restic"; import { REPOSITORY_BASE } from "~/server/core/constants"; -import { serverEvents } from "~/server/core/events"; import { withContext } from "~/server/core/request-context"; import { db } from "~/server/db/db"; import { repositoriesTable } from "~/server/db/schema"; @@ -37,25 +37,30 @@ const createTestRepository = async (organizationId: string) => { return repository; }; +let session: Awaited>; + +beforeAll(async () => { + session = await createTestSession(); +}); + describe("repositoriesService.createRepository", () => { - const initMock = mock(() => Promise.resolve({ success: true, error: null })); + const initMock = vi.fn(() => Promise.resolve({ success: true, error: null })); beforeEach(() => { initMock.mockClear(); - spyOn(restic, "init").mockImplementation(initMock); + vi.spyOn(restic, "init").mockImplementation(initMock); }); afterEach(() => { - mock.restore(); + vi.restoreAllMocks(); }); test("creates a shortId-scoped repository path when using the repository base directory", async () => { // arrange - const { organizationId, user } = await createTestSession(); const config: RepositoryConfig = { backend: "local", path: REPOSITORY_BASE }; // act - const result = await withContext({ organizationId, userId: user.id }, () => + const result = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () => repositoriesService.createRepository("main repo", config), ); @@ -80,12 +85,11 @@ describe("repositoriesService.createRepository", () => { test("creates a shortId-scoped repository path when using a custom directory", async () => { // arrange - const { organizationId, user } = await createTestSession(); const explicitPath = `${REPOSITORY_BASE}/custom-${randomUUID()}`; const config: RepositoryConfig = { backend: "local", path: explicitPath }; // act - const result = await withContext({ organizationId, userId: user.id }, () => + const result = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () => repositoriesService.createRepository("custom repo", config), ); @@ -109,14 +113,13 @@ describe("repositoriesService.createRepository", () => { test("keeps an explicit local repository path unchanged when importing existing repository", async () => { // arrange - const { organizationId, user } = await createTestSession(); const explicitPath = `${REPOSITORY_BASE}/custom-${randomUUID()}`; const config: RepositoryConfig = { backend: "local", path: explicitPath, isExistingRepository: true }; - spyOn(restic, "snapshots").mockImplementation(() => Promise.resolve([])); + vi.spyOn(restic, "snapshots").mockImplementation(() => Promise.resolve([])); // act - const result = await withContext({ organizationId, userId: user.id }, () => + const result = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () => repositoriesService.createRepository("existing repo", config), ); @@ -140,14 +143,13 @@ describe("repositoriesService.createRepository", () => { describe("repositoriesService repository stats", () => { afterEach(() => { - mock.restore(); + vi.restoreAllMocks(); }); test("returns empty stats when repository has not been populated yet", async () => { - const { organizationId, user } = await createTestSession(); - const repository = await createTestRepository(organizationId); + const repository = await createTestRepository(session.organizationId); - const stats = await withContext({ organizationId, userId: user.id }, () => + const stats = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () => repositoriesService.getRepositoryStats(repository.shortId), ); @@ -162,8 +164,7 @@ describe("repositoriesService repository stats", () => { }); test("refreshes and persists repository stats", async () => { - const { organizationId, user } = await createTestSession(); - const repository = await createTestRepository(organizationId); + const repository = await createTestRepository(session.organizationId); const expectedStats = { total_size: 1024, total_uncompressed_size: 2048, @@ -173,9 +174,9 @@ describe("repositoriesService repository stats", () => { snapshots_count: 3, }; - const statsSpy = spyOn(restic, "stats").mockResolvedValue(expectedStats); + const statsSpy = vi.spyOn(restic, "stats").mockResolvedValue(expectedStats); - const refreshed = await withContext({ organizationId, userId: user.id }, () => + const refreshed = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () => repositoriesService.refreshRepositoryStats(repository.shortId), ); @@ -186,7 +187,7 @@ describe("repositoriesService repository stats", () => { expect(persistedRepository?.stats).toEqual(expectedStats); expect(typeof persistedRepository?.statsUpdatedAt).toBe("number"); - const loaded = await withContext({ organizationId, userId: user.id }, () => + const loaded = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () => repositoriesService.getRepositoryStats(repository.shortId), ); @@ -197,15 +198,24 @@ describe("repositoriesService repository stats", () => { describe("repositoriesService.dumpSnapshot", () => { afterEach(() => { - mock.restore(); + vi.restoreAllMocks(); }); - const createDumpResult = () => ({ - stream: Readable.from([]), + const createDumpResult = (payload: string) => ({ + stream: Readable.from([payload]), completion: Promise.resolve(), abort: () => {}, }); + const readStreamText = async (stream: Readable) => { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + + return Buffer.concat(chunks).toString("utf8"); + }; + const setupDumpSnapshotScenario = async ({ snapshotId, basePath, @@ -215,7 +225,7 @@ describe("repositoriesService.dumpSnapshot", () => { basePath: string; snapshotPaths?: string[]; }) => { - const { organizationId, user } = await createTestSession(); + const organizationId = session.organizationId; const shortId = generateShortId(); await db.insert(repositoriesTable).values({ @@ -232,7 +242,7 @@ describe("repositoriesService.dumpSnapshot", () => { organizationId, }); - spyOn(restic, "snapshots").mockResolvedValue([ + vi.spyOn(restic, "snapshots").mockResolvedValue([ { id: snapshotId, short_id: snapshotId, @@ -242,53 +252,55 @@ describe("repositoriesService.dumpSnapshot", () => { }, ]); - const dumpMock = mock(() => Promise.resolve(createDumpResult())); - spyOn(restic, "dump").mockImplementation(dumpMock); + const dumpMock = vi.fn((_config: unknown, snapshotRef: string, options: Parameters[2]) => { + if (!options.path) { + throw new Error("Expected dump path in test"); + } + + return Promise.resolve( + createDumpResult( + JSON.stringify({ + snapshotRef, + path: options.path, + archive: options.archive !== false, + }), + ), + ); + }); + vi.spyOn(restic, "dump").mockImplementation(dumpMock); return { organizationId, - userId: user.id, + userId: session.user.id, shortId, basePath, - dumpMock, }; }; - test("calls restic.dump with common-ancestor selector and stripped path", async () => { - const { organizationId, userId, shortId, basePath, dumpMock } = await setupDumpSnapshotScenario({ + test("returns a tar download rooted at the selected directory within the snapshot", async () => { + const { organizationId, userId, shortId, basePath } = await setupDumpSnapshotScenario({ snapshotId: "snapshot-123", basePath: "/var/lib/zerobyte/volumes/vol123/_data", }); - const emitSpy = spyOn(serverEvents, "emit"); - await withContext({ organizationId, userId }, () => + const result = await withContext({ organizationId, userId }, () => repositoriesService.dumpSnapshot(shortId, "snapshot-123", `${basePath}/documents`, "dir"), ); - expect(dumpMock).toHaveBeenCalledTimes(1); - expect(dumpMock).toHaveBeenCalledWith( - expect.objectContaining({ - backend: "local", - }), - `snapshot-123:${basePath}`, - { - organizationId, - path: "/documents", - }, - ); - expect(emitSpy).toHaveBeenCalledWith( - "dump:started", - expect.objectContaining({ - organizationId, - repositoryId: shortId, - snapshotId: "snapshot-123", + expect(result.filename).toBe("snapshot-snapshot-123.tar"); + expect(result.contentType).toBe("application/x-tar"); + expect(await readStreamText(result.stream)).toBe( + JSON.stringify({ + snapshotRef: `snapshot-123:${basePath}`, path: "/documents", + archive: true, }), ); + await expect(result.completion).resolves.toBeUndefined(); }); test("streams a single file directly when selected path is a file", async () => { - const { organizationId, userId, shortId, basePath, dumpMock } = await setupDumpSnapshotScenario({ + const { organizationId, userId, shortId, basePath } = await setupDumpSnapshotScenario({ snapshotId: "snapshot-file", basePath: "/var/lib/zerobyte/volumes/vol123/_data", }); @@ -297,31 +309,38 @@ describe("repositoriesService.dumpSnapshot", () => { repositoriesService.dumpSnapshot(shortId, "snapshot-file", `${basePath}/documents/report.txt`, "file"), ); - expect(dumpMock).toHaveBeenCalledWith(expect.anything(), `snapshot-file:${basePath}`, { - organizationId, - path: "/documents/report.txt", - archive: false, - }); expect(result.filename).toBe("report.txt"); expect(result.contentType).toBe("application/octet-stream"); + expect(await readStreamText(result.stream)).toBe( + JSON.stringify({ + snapshotRef: `snapshot-file:${basePath}`, + path: "/documents/report.txt", + archive: false, + }), + ); }); test("downloads a selected parent directory when snapshot paths point to a nested file", async () => { const parentPath = "/var/lib/zerobyte/volumes/vol123/_data/documents"; - const { organizationId, userId, shortId, dumpMock } = await setupDumpSnapshotScenario({ + const { organizationId, userId, shortId } = await setupDumpSnapshotScenario({ snapshotId: "snapshot-parent-dir", basePath: `${parentPath}/report.txt`, snapshotPaths: [`${parentPath}/report.txt`], }); - await withContext({ organizationId, userId }, () => + const result = await withContext({ organizationId, userId }, () => repositoriesService.dumpSnapshot(shortId, "snapshot-parent-dir", parentPath, "dir"), ); - expect(dumpMock).toHaveBeenCalledWith(expect.anything(), `snapshot-parent-dir:${parentPath}`, { - organizationId, - path: "/", - }); + expect(result.filename).toBe("snapshot-snapshot-parent-dir.tar"); + expect(result.contentType).toBe("application/x-tar"); + expect(await readStreamText(result.stream)).toBe( + JSON.stringify({ + snapshotRef: `snapshot-parent-dir:${parentPath}`, + path: "/", + archive: true, + }), + ); }); test("rejects path downloads without a kind", async () => { @@ -330,38 +349,45 @@ describe("repositoriesService.dumpSnapshot", () => { basePath: "/var/lib/zerobyte/volumes/vol123/_data", }); - expect( + await expect( withContext({ organizationId, userId }, () => repositoriesService.dumpSnapshot(shortId, "snapshot-no-kind", `${basePath}/documents/report.txt`), ), ).rejects.toThrow("Path kind is required when downloading a specific snapshot path"); }); - test("downloads full snapshot relative to common ancestor when path is omitted", async () => { - const { organizationId, userId, shortId, basePath, dumpMock } = await setupDumpSnapshotScenario({ + test("downloads the full snapshot from the common ancestor when path is omitted", async () => { + const { organizationId, userId, shortId, basePath } = await setupDumpSnapshotScenario({ snapshotId: "snapshot-999", basePath: "/var/lib/zerobyte/volumes/vol555/_data", }); - await withContext({ organizationId, userId }, () => repositoriesService.dumpSnapshot(shortId, "snapshot-999")); + const result = await withContext({ organizationId, userId }, () => + repositoriesService.dumpSnapshot(shortId, "snapshot-999"), + ); - expect(dumpMock).toHaveBeenCalledWith(expect.anything(), `snapshot-999:${basePath}`, { - organizationId, - path: "/", - }); + expect(result.filename).toBe("snapshot-snapshot-999.tar"); + expect(result.contentType).toBe("application/x-tar"); + expect(await readStreamText(result.stream)).toBe( + JSON.stringify({ + snapshotRef: `snapshot-999:${basePath}`, + path: "/", + archive: true, + }), + ); }); }); describe("repositoriesService.restoreSnapshot", () => { afterEach(() => { - mock.restore(); + vi.restoreAllMocks(); }); const setupRestoreSnapshotScenario = async () => { - const { organizationId, user } = await createTestSession(); + const organizationId = session.organizationId; const repository = await createTestRepository(organizationId); - spyOn(restic, "snapshots").mockResolvedValue([ + vi.spyOn(restic, "snapshots").mockResolvedValue([ { id: "snapshot-restore", short_id: "snapshot-restore", @@ -371,7 +397,7 @@ describe("repositoriesService.restoreSnapshot", () => { }, ]); - const restoreMock = mock(() => + const restoreMock = vi.fn(() => Promise.resolve({ message_type: "summary" as const, seconds_elapsed: 1, @@ -383,11 +409,11 @@ describe("repositoriesService.restoreSnapshot", () => { bytes_restored: 1, }), ); - spyOn(restic, "restore").mockImplementation(restoreMock); + vi.spyOn(restic, "restore").mockImplementation(restoreMock); return { organizationId, - userId: user.id, + userId: session.user.id, repositoryShortId: repository.shortId, restoreMock, }; @@ -434,11 +460,11 @@ describe("repositoriesService.restoreSnapshot", () => { describe("repositoriesService.getRetentionCategories", () => { afterEach(() => { - mock.restore(); + vi.restoreAllMocks(); }); test("recomputes retention categories after repository cache invalidation", async () => { - const { organizationId, user } = await createTestSession(); + const organizationId = session.organizationId; const schedule = await createTestBackupSchedule({ organizationId, retentionPolicy: { keepLast: 1 } }); const repository = await db.query.repositoriesTable.findFirst({ where: { id: schedule.repositoryId } }); @@ -476,11 +502,11 @@ describe("repositoriesService.getRetentionCategories", () => { ], }); - const forgetSpy = spyOn(restic, "forget"); + const forgetSpy = vi.spyOn(restic, "forget"); forgetSpy.mockResolvedValueOnce(buildForgetResponse(oldSnapshotId)); forgetSpy.mockResolvedValueOnce(buildForgetResponse(newSnapshotId)); - const firstCategories = await withContext({ organizationId, userId: user.id }, () => + const firstCategories = await withContext({ organizationId, userId: session.user.id }, () => repositoriesService.getRetentionCategories(repository.shortId, schedule.shortId), ); @@ -488,7 +514,7 @@ describe("repositoriesService.getRetentionCategories", () => { cache.delByPrefix(cacheKeys.repository.all(repository.id)); - const secondCategories = await withContext({ organizationId, userId: user.id }, () => + const secondCategories = await withContext({ organizationId, userId: session.user.id }, () => repositoriesService.getRetentionCategories(repository.shortId, schedule.shortId), ); @@ -500,12 +526,11 @@ describe("repositoriesService.getRetentionCategories", () => { describe("repositoriesService.deleteSnapshot", () => { afterEach(() => { - mock.restore(); + vi.restoreAllMocks(); }); test("refreshes repository stats in background after successful deletion", async () => { - const { organizationId, user } = await createTestSession(); - const repository = await createTestRepository(organizationId); + const repository = await createTestRepository(session.organizationId); const expectedStats = { total_size: 128, total_uncompressed_size: 256, @@ -515,16 +540,16 @@ describe("repositoriesService.deleteSnapshot", () => { snapshots_count: 1, }; - spyOn(restic, "deleteSnapshot").mockResolvedValue({ success: true }); - const statsSpy = spyOn(restic, "stats").mockResolvedValue(expectedStats); + vi.spyOn(restic, "deleteSnapshot").mockResolvedValue({ success: true }); + const statsSpy = vi.spyOn(restic, "stats").mockResolvedValue(expectedStats); - await withContext({ organizationId, userId: user.id }, () => + await withContext({ organizationId: session.organizationId, userId: session.user.id }, () => repositoriesService.deleteSnapshot(repository.shortId, "snap-1"), ); - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(statsSpy).toHaveBeenCalledTimes(1); + await waitForExpect(() => { + expect(statsSpy).toHaveBeenCalledTimes(1); + }); const updatedRepository = await db.query.repositoriesTable.findFirst({ where: { id: repository.id } }); expect(updatedRepository?.stats).toEqual(expectedStats); @@ -532,15 +557,14 @@ describe("repositoriesService.deleteSnapshot", () => { }); test("should throw original error when restic deleteSnapshot fails", async () => { - const { organizationId, user } = await createTestSession(); - const repository = await createTestRepository(organizationId); + const repository = await createTestRepository(session.organizationId); - spyOn(restic, "deleteSnapshot").mockImplementation(async () => { + vi.spyOn(restic, "deleteSnapshot").mockImplementation(async () => { throw new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden"); }); - expect( - withContext({ organizationId, userId: user.id }, () => + await expect( + withContext({ organizationId: session.organizationId, userId: session.user.id }, () => repositoriesService.deleteSnapshot(repository.shortId, "snap123"), ), ).rejects.toThrow("Fatal: unexpected HTTP response"); diff --git a/app/server/modules/sso/__tests__/sso.errors.test.ts b/app/server/modules/sso/__tests__/sso.errors.test.ts index 4f3d2223..5f755a7d 100644 --- a/app/server/modules/sso/__tests__/sso.errors.test.ts +++ b/app/server/modules/sso/__tests__/sso.errors.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from "vitest"; import { mapAuthErrorToCode } from "../sso.errors"; describe("mapAuthErrorToCode", () => { diff --git a/app/server/modules/sso/__tests__/sso.integration.test.ts b/app/server/modules/sso/__tests__/sso.integration.test.ts index a471a97c..c617d4f3 100644 --- a/app/server/modules/sso/__tests__/sso.integration.test.ts +++ b/app/server/modules/sso/__tests__/sso.integration.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test } from "bun:test"; +import { beforeEach, describe, expect, test } from "vitest"; import type { GenericEndpointContext } from "better-auth"; import { eq } from "drizzle-orm"; import { db } from "~/server/db/db"; diff --git a/app/server/modules/sso/__tests__/sso.registration.test.ts b/app/server/modules/sso/__tests__/sso.registration.test.ts index 8dab023f..fe35f4f5 100644 --- a/app/server/modules/sso/__tests__/sso.registration.test.ts +++ b/app/server/modules/sso/__tests__/sso.registration.test.ts @@ -1,12 +1,11 @@ -import { beforeEach, describe, expect, test } from "bun:test"; +import { beforeEach, describe, expect, test } from "vitest"; import { createApp } from "~/server/app"; -import { config } from "~/server/core/config"; import { db } from "~/server/db/db"; import { account, invitation, member, organization, ssoProvider, usersTable, verification } from "~/server/db/schema"; import { createTestSession, createTestSessionWithOrgAdmin } from "~/test/helpers/auth"; const app = createApp(); -const ssoRegisterUrl = new URL("/api/auth/sso/register", config.baseUrl).toString(); +const ssoRegisterUrl = new URL("/api/auth/sso/register", "http://localhost:3000").toString(); function buildRegisterBody(organizationId: string, suffix: string) { return { diff --git a/app/server/modules/sso/__tests__/sso.security.test.ts b/app/server/modules/sso/__tests__/sso.security.test.ts index ad93a157..095a1c4b 100644 --- a/app/server/modules/sso/__tests__/sso.security.test.ts +++ b/app/server/modules/sso/__tests__/sso.security.test.ts @@ -1,11 +1,10 @@ -import { beforeEach, describe, expect, test } from "bun:test"; +import { beforeEach, describe, expect, test } from "vitest"; import { createApp } from "~/server/app"; -import { config } from "~/server/core/config"; import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema"; import { db } from "~/server/db/db"; const app = createApp(); -const ssoSignInUrl = new URL("/api/auth/sign-in/sso", config.baseUrl).toString(); +const ssoSignInUrl = new URL("/api/auth/sign-in/sso", "http://localhost:3000").toString(); function postSsoSignIn(body: Record) { return app.request(ssoSignInUrl, { diff --git a/app/server/modules/sso/__tests__/sso.service.test.ts b/app/server/modules/sso/__tests__/sso.service.test.ts index 126df6d3..652bd94c 100644 --- a/app/server/modules/sso/__tests__/sso.service.test.ts +++ b/app/server/modules/sso/__tests__/sso.service.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test } from "bun:test"; +import { beforeEach, describe, expect, test } from "vitest"; import { db, sqlite } from "~/server/db/db"; import { account, invitation, member, organization, sessionsTable, ssoProvider, usersTable } from "~/server/db/schema"; import { ssoService } from "../sso.service"; diff --git a/app/server/modules/sso/middlewares/__tests__/require-invitation.test.ts b/app/server/modules/sso/middlewares/__tests__/require-invitation.test.ts index d2c563a6..ecf79c83 100644 --- a/app/server/modules/sso/middlewares/__tests__/require-invitation.test.ts +++ b/app/server/modules/sso/middlewares/__tests__/require-invitation.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test } from "bun:test"; +import { beforeEach, describe, expect, test } from "vitest"; import type { GenericEndpointContext } from "better-auth"; import { db } from "~/server/db/db"; import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema"; @@ -80,7 +80,7 @@ describe("requireSsoInvitation", () => { await createSsoProvider("oidc-acme"); const ctx = createMockContext("/sign-up/email"); - expect(requireSsoInvitation("user@example.com", ctx)).rejects.toThrow("Missing providerId"); + await expect(requireSsoInvitation("user@example.com", ctx)).rejects.toThrow("Missing providerId"); }); test("detects whether current request is an SSO callback", async () => { @@ -97,7 +97,7 @@ describe("requireSsoInvitation", () => { await createSsoProvider("oidc-acme"); const ctx = createMockSsoCallbackContext("oidc-acme"); - expect(requireSsoInvitation("user@example.com", ctx)).rejects.toThrow("must be invited"); + await expect(requireSsoInvitation("user@example.com", ctx)).rejects.toThrow("must be invited"); }); test("blocks SSO callback when invitation is expired", async () => { @@ -115,7 +115,7 @@ describe("requireSsoInvitation", () => { }); const ctx = createMockSsoCallbackContext("oidc-acme"); - expect(requireSsoInvitation("user@example.com", ctx)).rejects.toThrow("must be invited"); + await expect(requireSsoInvitation("user@example.com", ctx)).rejects.toThrow("must be invited"); }); test("allows SSO callback when a matching pending invitation exists", async () => { @@ -133,11 +133,11 @@ describe("requireSsoInvitation", () => { }); const ctx = createMockSsoCallbackContext("oidc-acme"); - expect(requireSsoInvitation(" USER@EXAMPLE.COM ", ctx)).resolves.toBeUndefined(); + await expect(requireSsoInvitation(" USER@EXAMPLE.COM ", ctx)).resolves.toBeUndefined(); }); test("throws when provider is not registered", async () => { const ctx = createMockSsoCallbackContext("missing-provider"); - expect(requireSsoInvitation("user@example.com", ctx)).rejects.toThrow("SSO provider not found"); + await expect(requireSsoInvitation("user@example.com", ctx)).rejects.toThrow("SSO provider not found"); }); }); diff --git a/app/server/modules/sso/middlewares/__tests__/trust-provider-for-linking.test.ts b/app/server/modules/sso/middlewares/__tests__/trust-provider-for-linking.test.ts index ae9e7d34..7912fc16 100644 --- a/app/server/modules/sso/middlewares/__tests__/trust-provider-for-linking.test.ts +++ b/app/server/modules/sso/middlewares/__tests__/trust-provider-for-linking.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test } from "bun:test"; +import { beforeEach, describe, expect, test } from "vitest"; import { eq } from "drizzle-orm"; import { db } from "~/server/db/db"; import { account, member, organization, ssoProvider, usersTable } from "~/server/db/schema"; diff --git a/app/server/modules/sso/middlewares/__tests__/validate-callback-urls.test.ts b/app/server/modules/sso/middlewares/__tests__/validate-callback-urls.test.ts index 15b1dba4..b8c12795 100644 --- a/app/server/modules/sso/middlewares/__tests__/validate-callback-urls.test.ts +++ b/app/server/modules/sso/middlewares/__tests__/validate-callback-urls.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from "vitest"; import type { AuthMiddlewareContext } from "~/server/lib/auth"; import { validateSsoCallbackUrls } from "../validate-callback-urls"; diff --git a/app/server/modules/sso/middlewares/__tests__/validate-provider-id.test.ts b/app/server/modules/sso/middlewares/__tests__/validate-provider-id.test.ts index 2ecd55b1..1977fa51 100644 --- a/app/server/modules/sso/middlewares/__tests__/validate-provider-id.test.ts +++ b/app/server/modules/sso/middlewares/__tests__/validate-provider-id.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from "vitest"; import type { AuthMiddlewareContext } from "~/server/lib/auth"; import { validateSsoProviderId } from "../validate-provider-id"; diff --git a/app/server/modules/system/__tests__/system.controller.test.ts b/app/server/modules/system/__tests__/system.controller.test.ts index a1ac2e26..989e250f 100644 --- a/app/server/modules/system/__tests__/system.controller.test.ts +++ b/app/server/modules/system/__tests__/system.controller.test.ts @@ -1,9 +1,19 @@ -import { test, describe, expect } from "bun:test"; +import { beforeAll, describe, expect, test, vi } from "vitest"; import { createApp } from "~/server/app"; import { createTestSession, createTestSessionWithGlobalAdmin, getAuthHeaders } from "~/test/helpers/auth"; +import { systemService } from "../system.service"; +import * as authHelpers from "~/server/modules/auth/helpers"; const app = createApp(); +let session: Awaited>; +let globalAdminSession: Awaited>; + +beforeAll(async () => { + session = await createTestSession(); + globalAdminSession = await createTestSessionWithGlobalAdmin(); +}); + describe("system security", () => { test("should return 401 if no session cookie is provided", async () => { const res = await app.request("/api/v1/system/info"); @@ -22,10 +32,8 @@ describe("system security", () => { }); test("should return 200 if session is valid", async () => { - const { headers } = await createTestSession(); - const res = await app.request("/api/v1/system/info", { - headers, + headers: session.headers, }); expect(res.status).toBe(200); @@ -53,19 +61,17 @@ describe("system security", () => { describe("registration-status endpoint", () => { test("GET /api/v1/system/registration-status should be accessible with valid session", async () => { - const { headers } = await createTestSession(); - const res = await app.request("/api/v1/system/registration-status", { headers }); + const res = await app.request("/api/v1/system/registration-status", { headers: session.headers }); expect(res.status).toBe(200); const body = await res.json(); expect(typeof body.enabled).toBe("boolean"); }); test("PUT /api/v1/system/registration-status should return 403 for non-admin users", async () => { - const { headers } = await createTestSession(); const res = await app.request("/api/v1/system/registration-status", { method: "PUT", headers: { - ...headers, + ...session.headers, "Content-Type": "application/json", }, body: JSON.stringify({ enabled: false }), @@ -76,11 +82,10 @@ describe("system security", () => { }); test("PUT /api/v1/system/registration-status should be accessible to global admin", async () => { - const { headers } = await createTestSessionWithGlobalAdmin(); const res = await app.request("/api/v1/system/registration-status", { method: "PUT", headers: { - ...headers, + ...globalAdminSession.headers, "Content-Type": "application/json", }, body: JSON.stringify({ enabled: false }), @@ -91,8 +96,7 @@ describe("system security", () => { describe("dev-panel endpoint", () => { test("GET /api/v1/system/dev-panel should be accessible with valid session", async () => { - const { headers } = await createTestSession(); - const res = await app.request("/api/v1/system/dev-panel", { headers }); + const res = await app.request("/api/v1/system/dev-panel", { headers: session.headers }); expect(res.status).toBe(200); const body = await res.json(); expect(typeof body.enabled).toBe("boolean"); @@ -101,19 +105,29 @@ describe("system security", () => { describe("updates endpoint", () => { test("GET /api/v1/system/updates should be accessible with valid session", async () => { - const { headers } = await createTestSession(); - const res = await app.request("/api/v1/system/updates", { headers }); + const expectedUpdates = { + currentVersion: "1.0.0", + latestVersion: "1.0.0", + hasUpdate: false, + missedReleases: [], + }; + + vi.spyOn(systemService, "getUpdates").mockResolvedValue({ + ...expectedUpdates, + }); + + const res = await app.request("/api/v1/system/updates", { headers: session.headers }); expect(res.status).toBe(200); + expect(await res.json()).toEqual(expectedUpdates); }); }); describe("input validation", () => { test("should return 400 for invalid payload on restic-password", async () => { - const { headers } = await createTestSession(); const res = await app.request("/api/v1/system/restic-password", { method: "POST", headers: { - ...headers, + ...session.headers, "Content-Type": "application/json", }, body: JSON.stringify({}), @@ -123,11 +137,12 @@ describe("system security", () => { }); test("should return 401 for incorrect password on restic-password", async () => { - const { headers } = await createTestSession(); + vi.spyOn(authHelpers, "verifyUserPassword").mockResolvedValue(false); + const res = await app.request("/api/v1/system/restic-password", { method: "POST", headers: { - ...headers, + ...session.headers, "Content-Type": "application/json", }, body: JSON.stringify({ diff --git a/app/server/modules/volumes/__tests__/volumes.controller.test.ts b/app/server/modules/volumes/__tests__/volumes.controller.test.ts index 822cfd7e..c577001b 100644 --- a/app/server/modules/volumes/__tests__/volumes.controller.test.ts +++ b/app/server/modules/volumes/__tests__/volumes.controller.test.ts @@ -1,4 +1,4 @@ -import { test, describe, expect } from "bun:test"; +import { beforeAll, describe, expect, test } from "vitest"; import { db } from "~/server/db/db"; import { volumesTable } from "~/server/db/schema"; import { createApp } from "~/server/app"; @@ -7,6 +7,12 @@ import { generateShortId } from "~/server/utils/id"; const app = createApp(); +let session: Awaited>; + +beforeAll(async () => { + session = await createTestSession(); +}); + const createManagedVolumeRecord = async (organizationId: string) => { const [volume] = await db .insert(volumesTable) @@ -46,10 +52,8 @@ describe("volumes security", () => { }); test("should return 200 if session is valid", async () => { - const { headers } = await createTestSession(); - const res = await app.request("/api/v1/volumes", { - headers, + headers: session.headers, }); expect(res.status).toBe(200); @@ -91,9 +95,8 @@ describe("volumes security", () => { describe("input validation", () => { test("should return 404 for non-existent volume", async () => { - const { headers } = await createTestSession(); const res = await app.request("/api/v1/volumes/non-existent-volume", { - headers, + headers: session.headers, }); expect(res.status).toBe(404); @@ -102,11 +105,10 @@ describe("volumes security", () => { }); test("should return 400 for invalid payload on create", async () => { - const { headers } = await createTestSession(); const res = await app.request("/api/v1/volumes", { method: "POST", headers: { - ...headers, + ...session.headers, "Content-Type": "application/json", }, body: JSON.stringify({ @@ -118,10 +120,9 @@ describe("volumes security", () => { }); test("should mark provisioned volumes as managed", async () => { - const { headers, organizationId } = await createTestSession(); - const volume = await createManagedVolumeRecord(organizationId); + const volume = await createManagedVolumeRecord(session.organizationId); - const res = await app.request(`/api/v1/volumes/${volume.shortId}`, { headers }); + const res = await app.request(`/api/v1/volumes/${volume.shortId}`, { headers: session.headers }); expect(res.status).toBe(200); const body = await res.json(); @@ -129,13 +130,12 @@ describe("volumes security", () => { }); test("should allow updates for managed volumes", async () => { - const { headers, organizationId } = await createTestSession(); - const volume = await createManagedVolumeRecord(organizationId); + const volume = await createManagedVolumeRecord(session.organizationId); const res = await app.request(`/api/v1/volumes/${volume.shortId}`, { method: "PUT", headers: { - ...headers, + ...session.headers, "Content-Type": "application/json", }, body: JSON.stringify({ @@ -149,12 +149,11 @@ describe("volumes security", () => { }); test("should allow deletion for managed volumes", async () => { - const { headers, organizationId } = await createTestSession(); - const volume = await createManagedVolumeRecord(organizationId); + const volume = await createManagedVolumeRecord(session.organizationId); const res = await app.request(`/api/v1/volumes/${volume.shortId}`, { method: "DELETE", - headers, + headers: session.headers, }); expect(res.status).toBe(200); diff --git a/app/server/modules/volumes/__tests__/volumes.service.test.ts b/app/server/modules/volumes/__tests__/volumes.service.test.ts index ac0b4966..0fa2f6dc 100644 --- a/app/server/modules/volumes/__tests__/volumes.service.test.ts +++ b/app/server/modules/volumes/__tests__/volumes.service.test.ts @@ -1,4 +1,4 @@ -import { test, describe, expect } from "bun:test"; +import { describe, expect, test } from "vitest"; import { volumeService } from "../volume.service"; import { db } from "~/server/db/db"; import { volumesTable } from "~/server/db/schema"; diff --git a/app/server/utils/__tests__/spawn.test.ts b/app/server/utils/__tests__/spawn.test.ts index 8066a442..e037e894 100644 --- a/app/server/utils/__tests__/spawn.test.ts +++ b/app/server/utils/__tests__/spawn.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from "vitest"; import { safeExec, safeSpawn } from "@zerobyte/core/node"; describe("safeExec", () => { @@ -41,7 +41,7 @@ describe("safeExec", () => { const result = await safeExec({ command: "sleep", args: ["10"], - timeout: 100, + timeout: 20, }); expect(result.timedOut).toBe(true); diff --git a/app/server/utils/common-ancestor.test.ts b/app/server/utils/common-ancestor.test.ts index 5790e5f6..468fb28a 100644 --- a/app/server/utils/common-ancestor.test.ts +++ b/app/server/utils/common-ancestor.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from "vitest"; import { findCommonAncestor } from "@zerobyte/core/utils"; describe("findCommonAncestor", () => { diff --git a/app/test/helpers/db.ts b/app/test/helpers/db.ts new file mode 100644 index 00000000..adff565f --- /dev/null +++ b/app/test/helpers/db.ts @@ -0,0 +1,25 @@ +import fs from "node:fs"; +import path from "node:path"; +import { tmpdir } from "node:os"; +import { vi } from "vitest"; + +export const createTestDb = async () => { + const projectRoot = process.cwd(); + const cacheRoot = fs.mkdtempSync(path.join(tmpdir(), "zerobyte-test-cache-")); + + process.env.ZEROBYTE_DATABASE_URL = ":memory:"; + vi.resetModules(); + + const database = await import("~/server/db/db"); + + await database.runDbMigrations(); + + process.chdir(cacheRoot); + try { + await import("~/server/utils/cache"); + } finally { + process.chdir(projectRoot); + } + + return database; +}; diff --git a/app/test/setup-client.ts b/app/test/setup-client.ts index c32d4e4b..23f17ca9 100644 --- a/app/test/setup-client.ts +++ b/app/test/setup-client.ts @@ -1,10 +1,9 @@ -import "./setup.ts"; -import { GlobalRegistrator } from "@happy-dom/global-registrator"; -import { afterAll, afterEach, beforeAll, mock } from "bun:test"; +import "./setup-shared"; +import { afterAll, afterEach, beforeAll, vi } from "vitest"; import { client } from "~/client/api-client/client.gen"; import { server } from "~/test/msw/server"; -void mock.module("~/client/hooks/use-root-loader-data", () => ({ +vi.mock(import("~/client/hooks/use-root-loader-data"), () => ({ useRootLoaderData: () => ({ theme: "dark", locale: "en-US", @@ -15,8 +14,6 @@ void mock.module("~/client/hooks/use-root-loader-data", () => ({ }), })); -GlobalRegistrator.register({ url: "http://localhost:3000" }); - client.setConfig({ baseUrl: "http://localhost:3000", credentials: "include", diff --git a/app/test/setup-shared.ts b/app/test/setup-shared.ts new file mode 100644 index 00000000..d9ec50de --- /dev/null +++ b/app/test/setup-shared.ts @@ -0,0 +1,32 @@ +import { vi } from "vitest"; + +process.env.BASE_URL = "http://localhost:3000"; +process.env.TRUSTED_ORIGINS = "http://localhost:3000"; + +vi.mock(import("@zerobyte/core/node"), async () => { + const utils = await vi.importActual("@zerobyte/core/node"); + + return { + ...utils, + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }, + }; +}); + +vi.mock(import("~/server/utils/crypto"), async () => { + const cryptoModule = await vi.importActual("~/server/utils/crypto"); + + return { + cryptoUtils: { + ...cryptoModule.cryptoUtils, + deriveSecret: async () => "test-secret", + sealSecret: async (v: string) => v, + resolveSecret: async (v: string) => v, + generateResticPassword: () => "test-restic-password", + }, + }; +}); diff --git a/app/test/setup.ts b/app/test/setup.ts index 0ae49a38..161fae78 100644 --- a/app/test/setup.ts +++ b/app/test/setup.ts @@ -1,34 +1,5 @@ -import { beforeAll, mock } from "bun:test"; -import { migrate } from "drizzle-orm/bun-sqlite/migrator"; -import path from "node:path"; -import { cwd } from "node:process"; -import { db } from "~/server/db/db"; +import "./setup-shared"; -import * as utils from "@zerobyte/core/node"; +const { createTestDb } = await import("~/test/helpers/db"); -void mock.module("@zerobyte/core/node", () => { - return { - ...utils, - logger: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - }, - }; -}); - -void mock.module("~/server/utils/crypto", () => ({ - cryptoUtils: { - deriveSecret: async () => "test-secret", - sealSecret: async (v: string) => v, - resolveSecret: async (v: string) => v, - generateResticPassword: () => "test-restic-password", - }, -})); - -beforeAll(async () => { - const migrationsFolder = path.join(cwd(), "app", "drizzle"); - migrate(db, { migrationsFolder }); - db.run("PRAGMA foreign_keys = ON;"); -}); +await createTestDb(); diff --git a/app/utils/__tests__/path.test.ts b/app/utils/__tests__/path.test.ts index d90c767e..7a8a2778 100644 --- a/app/utils/__tests__/path.test.ts +++ b/app/utils/__tests__/path.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from "vitest"; import { isPathWithin, normalizeAbsolutePath } from "@zerobyte/core/utils"; describe("normalizeAbsolutePath", () => { diff --git a/bun.lock b/bun.lock index 4cc7eb9e..aa3ebedf 100644 --- a/bun.lock +++ b/bun.lock @@ -110,6 +110,7 @@ "typescript": "^5.9.3", "vite": "^8.0.1", "vite-bundle-analyzer": "^1.2.3", + "vitest": "^4.1.1", "wait-for-expect": "^4.0.0", }, }, @@ -955,6 +956,8 @@ "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/content-disposition": ["@types/content-disposition@0.5.9", "", {}, "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -977,6 +980,8 @@ "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -1021,6 +1026,20 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], + "@vitest/expect": ["@vitest/expect@4.1.1", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.1", "@vitest/utils": "4.1.1", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.1", "", { "dependencies": { "@vitest/spy": "4.1.1", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.1", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ=="], + + "@vitest/runner": ["@vitest/runner@4.1.1", "", { "dependencies": { "@vitest/utils": "4.1.1", "pathe": "^2.0.3" } }, "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.1", "", { "dependencies": { "@vitest/pretty-format": "4.1.1", "@vitest/utils": "4.1.1", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg=="], + + "@vitest/spy": ["@vitest/spy@4.1.1", "", {}, "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA=="], + + "@vitest/utils": ["@vitest/utils@4.1.1", "", { "dependencies": { "@vitest/pretty-format": "4.1.1", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ=="], + "@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="], "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], @@ -1055,6 +1074,8 @@ "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], @@ -1101,6 +1122,8 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], @@ -1263,6 +1286,8 @@ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], + "es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="], "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], @@ -1277,12 +1302,16 @@ "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], @@ -1661,6 +1690,8 @@ "nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "ofetch": ["ofetch@2.0.0-alpha.3", "", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="], "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], @@ -1825,6 +1856,8 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "slugify": ["slugify@1.6.8", "", {}, "sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA=="], @@ -1845,9 +1878,11 @@ "srvx": ["srvx@0.10.1", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-A//xtfak4eESMWWydSRFUVvCTQbSwivnGCEf8YGPe2eHU0+Z6znfUTCPF0a7oV3sObSOcrXHlL6Bs9vVctfXdg=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], @@ -1883,12 +1918,16 @@ "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], @@ -1967,6 +2006,8 @@ "vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="], + "vitest": ["vitest@4.1.1", "", { "dependencies": { "@vitest/expect": "4.1.1", "@vitest/mocker": "4.1.1", "@vitest/pretty-format": "4.1.1", "@vitest/runner": "4.1.1", "@vitest/snapshot": "4.1.1", "@vitest/spy": "4.1.1", "@vitest/utils": "4.1.1", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.1", "@vitest/browser-preview": "4.1.1", "@vitest/browser-webdriverio": "4.1.1", "@vitest/ui": "4.1.1", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA=="], + "wait-for-expect": ["wait-for-expect@4.0.0", "", {}, "sha512-mcH2HYUUHhdFGHVJkgwkBxRihZO4VSuPyh6xhYHz7LEnYkcaLbTAEEsTpYiFw4UY45XdTZYYIaquuMucw9wWMw=="], "web-haptics": ["web-haptics@0.0.6", "", { "peerDependencies": { "react": ">=18", "react-dom": ">=18", "svelte": ">=4", "vue": ">=3" }, "optionalPeers": ["react", "react-dom", "svelte", "vue"] }, "sha512-eCzcf1LDi20+Fr0x9V3OkX92k0gxEQXaHajmhXHitsnk6SxPeshv8TBtBRqxyst8HI1uf2FyFVE7QS3jo1gkrw=="], @@ -1985,6 +2026,8 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], @@ -2087,6 +2130,8 @@ "@prisma/dev/hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="], + "@prisma/dev/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2" } }, "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw=="], "@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2" } }, "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw=="], diff --git a/package.json b/package.json index f3a913f2..6fef82b0 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,9 @@ "gen:api-client": "dotenv -e .env.local -- openapi-ts", "gen:migrations": "dotenv -e .env.local -- drizzle-kit generate", "studio": "drizzle-kit studio", - "test:server": "dotenv -e .env.test -- bun test app/server --preload ./app/test/setup.ts", - "test:client": "dotenv -e .env.test -- bun test app/client --preload ./app/test/setup-client.ts", - "test": "bun run test:server && bun run test:client && turbo run test", + "test:server": "dotenv -e .env.test -- bunx --bun vitest run --project server", + "test:client": "dotenv -e .env.test -- bunx --bun vitest run --project client", + "test": "dotenv -e .env.test -- bunx --bun vitest run && turbo run test", "test:e2e": "NODE_ENV=test dotenv -e .env.local -- playwright test", "test:e2e:ui": "NODE_ENV=test dotenv -e .env.local -- playwright test --ui", "test:codegen": "playwright codegen localhost:4096" @@ -135,6 +135,7 @@ "typescript": "^5.9.3", "vite": "^8.0.1", "vite-bundle-analyzer": "^1.2.3", + "vitest": "^4.1.1", "wait-for-expect": "^4.0.0" }, "overrides": { diff --git a/packages/core/package.json b/packages/core/package.json index 61319536..533f2a62 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,7 +26,7 @@ }, "scripts": { "tsc": "tsc --noEmit", - "test": "bun test --preload ./test/setup.ts" + "test": "bunx --bun vitest run --config ./vitest.config.ts" }, "devDependencies": { "@types/bun": "latest" diff --git a/packages/core/src/restic/commands/__tests__/backup.test.ts b/packages/core/src/restic/commands/__tests__/backup.test.ts index 9203b0a5..fdf7aca8 100644 --- a/packages/core/src/restic/commands/__tests__/backup.test.ts +++ b/packages/core/src/restic/commands/__tests__/backup.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { afterEach, describe, expect, test, vi } from "vitest"; import * as cleanupModule from "../../helpers/cleanup-temporary-keys"; import * as spawnModule from "../../../utils/spawn"; import { ResticError } from "../../error"; @@ -61,8 +61,8 @@ type SetupOptions = { const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => { let capturedArgs: string[] = []; - const cleanupSpy = spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve()); - spyOn(spawnModule, "safeSpawn").mockImplementation((params) => { + vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve()); + vi.spyOn(spawnModule, "safeSpawn").mockImplementation((params) => { capturedArgs = params.args; return Promise.resolve(onSpawnCall?.(params)).then(() => ({ exitCode: 0, @@ -73,7 +73,6 @@ const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => { }); return { - cleanupSpy, getArgs: () => capturedArgs, hasFlag: (flag: string) => capturedArgs.includes(flag), getOptionValues: (option: string): string[] => { @@ -89,7 +88,7 @@ const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => { }; afterEach(() => { - mock.restore(); + vi.restoreAllMocks(); }); describe("backup command", () => { @@ -114,27 +113,11 @@ describe("backup command", () => { expect(getArgs().at(-1)).toBe(source); }); - test("uses --files-from instead of source path when include list is provided", async () => { - const { hasFlag, getArgs } = setup(); - await backup( - config, - "/mnt/data", - { - organizationId: "org-1", - includePatterns: ["/mnt/data/docs", "/mnt/data/photos"], - }, - mockDeps, - ); - - expect(hasFlag("--files-from")).toBe(true); - expect(getArgs()).not.toContain("/mnt/data"); - }); - - test("passes include paths via --files-from-raw and include patterns via --files-from", async () => { + test("writes include paths and patterns to separate files instead of passing the source path directly", async () => { const literalDir = "/mnt/data/movies [1]"; let rawIncludeContent = ""; let patternIncludeContent = ""; - const { hasFlag } = setup({ + const { getArgs, hasFlag } = setup({ onSpawnCall: async (params) => { const rawIncludeIndex = params.args.indexOf("--files-from-raw"); const patternIncludeIndex = params.args.indexOf("--files-from"); @@ -162,110 +145,17 @@ describe("backup command", () => { expect(hasFlag("--files-from-raw")).toBe(true); expect(hasFlag("--files-from")).toBe(true); + expect(getArgs()).not.toContain("/mnt/data"); expect(rawIncludeContent).toBe(`${literalDir}\0`); expect(patternIncludeContent).toBe("/mnt/data/**/*.zip"); }); - test("adds --tag for each entry in options.tags", async () => { - const { getOptionValues } = setup(); - await backup( - config, - "/mnt/data", - { - organizationId: "org-1", - tags: ["tag-a", "tag-b"], - }, - mockDeps, - ); - - expect(getOptionValues("--tag")).toEqual(["tag-a", "tag-b"]); - }); - - test("omits --tag when tags list is empty", async () => { - const { hasFlag } = setup(); - await backup(config, "/mnt/data", { organizationId: "org-1", tags: [] }, mockDeps); - - expect(hasFlag("--tag")).toBe(false); - }); - - test("passes provided compressionMode to --compression", async () => { - const { getOptionValues } = setup(); - await backup(config, "/mnt/data", { organizationId: "org-1", compressionMode: "max" }, mockDeps); - - expect(getOptionValues("--compression")).toEqual(["max"]); - }); - - test("defaults --compression to auto when compressionMode is omitted", async () => { - const { getOptionValues } = setup(); - await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps); - - expect(getOptionValues("--compression")).toEqual(["auto"]); - }); - - test("adds --one-file-system when oneFileSystem is true", async () => { - const { hasFlag } = setup(); - await backup(config, "/mnt/data", { organizationId: "org-1", oneFileSystem: true }, mockDeps); - - expect(hasFlag("--one-file-system")).toBe(true); - }); - - test("omits --one-file-system when oneFileSystem is false", async () => { - const { hasFlag } = setup(); - await backup(config, "/mnt/data", { organizationId: "org-1", oneFileSystem: false }, mockDeps); - - expect(hasFlag("--one-file-system")).toBe(false); - }); - - test("adds --exclude-file when exclude list is provided", async () => { - const { hasFlag } = setup(); - await backup( - config, - "/mnt/data", - { - organizationId: "org-1", - exclude: ["/mnt/data/tmp", "/mnt/data/cache"], - }, - mockDeps, - ); - - expect(hasFlag("--exclude-file")).toBe(true); - }); - - test("omits --exclude-file when exclude list is empty", async () => { - const { hasFlag } = setup(); - await backup(config, "/mnt/data", { organizationId: "org-1", exclude: [] }, mockDeps); - - expect(hasFlag("--exclude-file")).toBe(false); - }); - - test("adds --exclude-if-present for each entry in excludeIfPresent", async () => { - const { getOptionValues } = setup(); - await backup( - config, - "/mnt/data", - { - organizationId: "org-1", - excludeIfPresent: [".nobackup", ".gitignore"], - }, - mockDeps, - ); - - expect(getOptionValues("--exclude-if-present")).toEqual([".nobackup", ".gitignore"]); - }); - test("always includes DEFAULT_EXCLUDES as --exclude args", async () => { const { getOptionValues } = setup(); await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps); expect(getOptionValues("--exclude").length).toBeGreaterThan(0); }); - - test("includes --host arg from config", async () => { - const { hasFlag } = setup(); - await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps); - - expect(hasFlag("--host")).toBe(true); - }); }); describe("exit code handling", () => { @@ -427,20 +317,4 @@ describe("backup command", () => { expect(progressUpdates).toHaveLength(0); }); }); - - describe("cleanup", () => { - test("calls cleanupTemporaryKeys after a successful backup", async () => { - const { cleanupSpy } = setup(); - await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps); - - expect(cleanupSpy).toHaveBeenCalledTimes(1); - }); - - test("calls cleanupTemporaryKeys even when the command fails", async () => { - const { cleanupSpy } = setup({ spawnResult: { exitCode: 1, summary: "", error: "fail" } }); - await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps).catch(() => {}); - - expect(cleanupSpy).toHaveBeenCalledTimes(1); - }); - }); }); diff --git a/packages/core/src/restic/commands/__tests__/copy.test.ts b/packages/core/src/restic/commands/__tests__/copy.test.ts index dbc15fdb..3450902e 100644 --- a/packages/core/src/restic/commands/__tests__/copy.test.ts +++ b/packages/core/src/restic/commands/__tests__/copy.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { afterEach, describe, expect, test, vi } from "vitest"; import * as cleanupModule from "../../helpers/cleanup-temporary-keys"; import * as nodeModule from "../../../node"; import { copy } from "../copy"; @@ -29,8 +29,8 @@ const destConfig = { const setup = () => { let capturedArgs: string[] = []; - spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve()); - spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => { + vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve()); + vi.spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => { capturedArgs = args ?? []; return { exitCode: 0, stdout: "copied", stderr: "", timedOut: false }; }); @@ -41,7 +41,7 @@ const setup = () => { }; afterEach(() => { - mock.restore(); + vi.restoreAllMocks(); }); describe("copy command", () => { @@ -51,17 +51,8 @@ describe("copy command", () => { await copy(sourceConfig, destConfig, { organizationId: "org-1", snapshotId, tag: "daily" }, mockDeps); - expect(getArgs()).toEqual([ - "--repo", - "/tmp/dest-repo", - "copy", - "--from-repo", - "/tmp/source-repo", - "--tag", - "daily", - "--json", - "--", - snapshotId, - ]); + const separatorIndex = getArgs().indexOf("--"); + expect(separatorIndex).toBeGreaterThan(-1); + expect(getArgs().slice(separatorIndex + 1)).toEqual([snapshotId]); }); }); diff --git a/packages/core/src/restic/commands/__tests__/delete-snapshots.test.ts b/packages/core/src/restic/commands/__tests__/delete-snapshots.test.ts index ad136890..052545b8 100644 --- a/packages/core/src/restic/commands/__tests__/delete-snapshots.test.ts +++ b/packages/core/src/restic/commands/__tests__/delete-snapshots.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { afterEach, describe, expect, test, vi } from "vitest"; import * as cleanupModule from "../../helpers/cleanup-temporary-keys"; import * as nodeModule from "../../../node"; import { deleteSnapshots } from "../delete-snapshots"; @@ -22,8 +22,8 @@ const config = { const setup = () => { let capturedArgs: string[] = []; - spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve()); - spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => { + vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve()); + vi.spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => { capturedArgs = args ?? []; return { exitCode: 0, stdout: "", stderr: "", timedOut: false }; }); @@ -34,7 +34,7 @@ const setup = () => { }; afterEach(() => { - mock.restore(); + vi.restoreAllMocks(); }); describe("deleteSnapshots command", () => { @@ -44,6 +44,8 @@ describe("deleteSnapshots command", () => { await deleteSnapshots(config, snapshotIds, "org-1", mockDeps); - expect(getArgs()).toEqual(["--repo", "/tmp/restic-repo", "forget", "--prune", "--json", "--", ...snapshotIds]); + const separatorIndex = getArgs().indexOf("--"); + expect(separatorIndex).toBeGreaterThan(-1); + expect(getArgs().slice(separatorIndex + 1)).toEqual(snapshotIds); }); }); diff --git a/packages/core/src/restic/commands/__tests__/dump.test.ts b/packages/core/src/restic/commands/__tests__/dump.test.ts index 0b21ba5f..9048fc02 100644 --- a/packages/core/src/restic/commands/__tests__/dump.test.ts +++ b/packages/core/src/restic/commands/__tests__/dump.test.ts @@ -1,6 +1,5 @@ -import { PassThrough } from "node:stream"; import { spawn } from "node:child_process"; -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { afterEach, describe, expect, test, vi } from "vitest"; import * as cleanupModule from "../../helpers/cleanup-temporary-keys"; import * as nodeModule from "../../../node"; import { dump } from "../dump"; @@ -24,10 +23,10 @@ const config = { const setup = () => { let capturedArgs: string[] = []; - spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve()); - spyOn(nodeModule, "safeSpawn").mockImplementation((params) => { + vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve()); + vi.spyOn(nodeModule, "safeSpawn").mockImplementation((params) => { capturedArgs = params.args; - const child = { stdout: new PassThrough() } as unknown as ReturnType; + const child = spawn(process.execPath, ["-e", ""]); params.onSpawn?.(child); return Promise.resolve({ exitCode: 0, summary: "", error: "" }); }); @@ -38,7 +37,7 @@ const setup = () => { }; afterEach(() => { - mock.restore(); + vi.restoreAllMocks(); }); describe("dump command", () => { @@ -48,15 +47,8 @@ describe("dump command", () => { const result = await dump(config, "--help", { organizationId: "org-1", path: "folder/file.txt" }, mockDeps); await result.completion; - expect(getArgs()).toEqual([ - "--repo", - "/tmp/restic-repo", - "dump", - "--archive", - "tar", - "--", - "--help", - "/folder/file.txt", - ]); + const separatorIndex = getArgs().indexOf("--"); + expect(separatorIndex).toBeGreaterThan(-1); + expect(getArgs().slice(separatorIndex + 1)).toEqual(["--help", "/folder/file.txt"]); }); }); diff --git a/packages/core/src/restic/commands/__tests__/ls.test.ts b/packages/core/src/restic/commands/__tests__/ls.test.ts index 861ff3cb..ebe21c85 100644 --- a/packages/core/src/restic/commands/__tests__/ls.test.ts +++ b/packages/core/src/restic/commands/__tests__/ls.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { afterEach, describe, expect, test, vi } from "vitest"; import * as cleanupModule from "../../helpers/cleanup-temporary-keys"; import * as spawnModule from "../../../utils/spawn"; import { ls } from "../ls"; @@ -34,8 +34,8 @@ const snapshotLine = JSON.stringify({ const setup = () => { let capturedArgs: string[] = []; - spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve()); - spyOn(spawnModule, "safeSpawn").mockImplementation((params: SafeSpawnParams) => { + vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve()); + vi.spyOn(spawnModule, "safeSpawn").mockImplementation((params: SafeSpawnParams) => { capturedArgs = params.args; params.onStdout?.(snapshotLine); return Promise.resolve({ exitCode: 0, summary: snapshotLine, error: "" }); @@ -47,7 +47,7 @@ const setup = () => { }; afterEach(() => { - mock.restore(); + vi.restoreAllMocks(); }); describe("ls command", () => { @@ -58,6 +58,8 @@ describe("ls command", () => { await ls(config, snapshotId, "org-1", path, undefined, mockDeps); - expect(getArgs()).toEqual(["--repo", "/tmp/restic-repo", "ls", "--long", "--json", "--", snapshotId, path]); + const separatorIndex = getArgs().indexOf("--"); + expect(separatorIndex).toBeGreaterThan(-1); + expect(getArgs().slice(separatorIndex + 1)).toEqual([snapshotId, path]); }); }); diff --git a/packages/core/src/restic/commands/__tests__/restore.test.ts b/packages/core/src/restic/commands/__tests__/restore.test.ts index 1a22c1d4..ba731f44 100644 --- a/packages/core/src/restic/commands/__tests__/restore.test.ts +++ b/packages/core/src/restic/commands/__tests__/restore.test.ts @@ -1,8 +1,10 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import * as cleanupModule from "../../helpers/cleanup-temporary-keys"; import * as spawnModule from "../../../utils/spawn"; +import { ResticError } from "../../error"; import { restore } from "../restore"; import type { ResticDeps } from "../../types"; -import type { SafeSpawnParams } from "../../../utils/spawn"; +import type { SafeSpawnParams, SpawnResult } from "../../../utils/spawn"; const mockDeps: ResticDeps = { resolveSecret: async (s) => s, @@ -14,11 +16,22 @@ const mockDeps: ResticDeps = { const successfulRestoreSummary = JSON.stringify({ message_type: "summary", + total_files: 2, files_restored: 1, - files_skipped: 0, + files_skipped: 1, bytes_skipped: 0, }); +const validProgressLine = JSON.stringify({ + message_type: "status", + seconds_elapsed: 5, + percent_done: 0.5, + total_files: 2, + files_restored: 1, + total_bytes: 1024, + bytes_restored: 512, +}); + const config = { backend: "local" as const, path: "/tmp/restic-repo", @@ -26,16 +39,23 @@ const config = { customPassword: "custom-password", }; -/** - * Sets up the safeSpawn mock and returns helpers to inspect the restic args - * that were built for the restore command. - */ -const setup = () => { +type SetupOptions = { + spawnResult?: Partial; + onSpawnCall?: (params: SafeSpawnParams) => void | Promise; +}; + +const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => { let capturedArgs: string[] = []; - spyOn(spawnModule, "safeSpawn").mockImplementation((params: SafeSpawnParams) => { + vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve()); + vi.spyOn(spawnModule, "safeSpawn").mockImplementation((params: SafeSpawnParams) => { capturedArgs = params.args; - return Promise.resolve({ exitCode: 0, summary: successfulRestoreSummary, error: "" }); + return Promise.resolve(onSpawnCall?.(params)).then(() => ({ + exitCode: 0, + summary: successfulRestoreSummary, + error: "", + ...spawnResult, + })); }); const getRestoreArg = () => { @@ -46,7 +66,7 @@ const setup = () => { return capturedArgs[separatorIndex + 1]!; }; - const getOptionValues = (option: string): string[] => { + const getOptionValues = (option: string) => { const values: string[] = []; for (let i = 0; i < capturedArgs.length - 1; i++) { if (capturedArgs[i] === option && capturedArgs[i + 1]) { @@ -64,127 +84,172 @@ const setup = () => { }; afterEach(() => { - mock.restore(); + vi.restoreAllMocks(); }); describe("restore command", () => { - test("keeps snapshot restore arg and absolute include paths when target is root", async () => { - const { getRestoreArg, getOptionValues } = setup(); - await restore( - config, - "snapshot-123", - "/", - { - organizationId: "org-1", - include: [ - "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", - "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", - ], - }, - mockDeps, - ); + describe("path selection", () => { + test("uses the common ancestor as restore root and strips includes for non-root targets", async () => { + const { getRestoreArg, getOptionValues } = setup(); - expect(getRestoreArg()).toBe("snapshot-123"); - expect(getOptionValues("--include")).toEqual([ - "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", - "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", - ]); + await restore( + config, + "snapshot-456", + "/tmp/restore-target", + { + organizationId: "org-1", + include: [ + "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", + "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", + ], + }, + mockDeps, + ); + + expect(getRestoreArg()).toBe("snapshot-456:/var/lib/zerobyte/volumes/vol123/_data"); + expect(getOptionValues("--include")).toEqual(["Documents/report.pdf", "Photos/summer.jpg"]); + }); + + test("restores a selected file from its parent directory for non-root targets", async () => { + const { getRestoreArg, getOptionValues } = setup(); + + await restore( + config, + "snapshot-single-file", + "/tmp/restore-target", + { + organizationId: "org-1", + include: ["/var/lib/zerobyte/volumes/vol123/_data/archive/backup.20260301-233001.7z"], + selectedItemKind: "file", + }, + mockDeps, + ); + + expect(getRestoreArg()).toBe("snapshot-single-file:/var/lib/zerobyte/volumes/vol123/_data/archive"); + expect(getOptionValues("--include")).toEqual(["backup.20260301-233001.7z"]); + }); + + test("treats flag-like snapshot IDs as positional restore args", async () => { + const { getArgs, getRestoreArg } = setup(); + + await restore( + config, + "--help", + "/tmp/restore-target", + { + organizationId: "org-1", + basePath: "/var/lib/zerobyte/volumes/vol123/_data", + }, + mockDeps, + ); + + const separatorIndex = getArgs().indexOf("--"); + expect(separatorIndex).toBeGreaterThan(-1); + expect(getRestoreArg()).toBe("--help:/var/lib/zerobyte/volumes/vol123/_data"); + }); }); - test("restores from common ancestor and strips include paths for non-root targets", async () => { - const { getRestoreArg, getOptionValues } = setup(); - await restore( - config, - "snapshot-456", - "/tmp/restore-target", - { - organizationId: "org-1", - include: [ - "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", - "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", - ], - }, - mockDeps, - ); + describe("output handling", () => { + test("returns a parsed restore summary on success", async () => { + setup(); - expect(getRestoreArg()).toBe("snapshot-456:/var/lib/zerobyte/volumes/vol123/_data"); - expect(getOptionValues("--include")).toEqual(["Documents/report.pdf", "Photos/summer.jpg"]); + const result = await restore( + config, + "snapshot-123", + "/tmp/restore-target", + { organizationId: "org-1", basePath: "/var/lib/zerobyte/volumes/vol123/_data" }, + mockDeps, + ); + + expect(result).toMatchObject({ + message_type: "summary", + files_restored: 1, + files_skipped: 1, + }); + }); + + test("throws ResticError when the command fails", async () => { + setup({ spawnResult: { exitCode: 1, summary: "", error: "restore failed" } }); + + await expect( + restore( + config, + "snapshot-123", + "/tmp/restore-target", + { organizationId: "org-1", basePath: "/var/lib/zerobyte/volumes/vol123/_data" }, + mockDeps, + ), + ).rejects.toBeInstanceOf(ResticError); + }); + + test("falls back to an empty summary when restic output cannot be parsed", async () => { + setup({ spawnResult: { summary: "not-json" } }); + + const result = await restore( + config, + "snapshot-123", + "/tmp/restore-target", + { organizationId: "org-1", basePath: "/var/lib/zerobyte/volumes/vol123/_data" }, + mockDeps, + ); + + expect(result).toEqual({ + message_type: "summary", + total_files: 0, + files_restored: 0, + files_skipped: 0, + bytes_skipped: 0, + }); + }); }); - test("uses base path for non-root restore when includes are omitted", async () => { - const { getRestoreArg, getOptionValues } = setup(); - await restore( - config, - "snapshot-789", - "/tmp/restore-target", - { - organizationId: "org-1", - basePath: "/var/lib/zerobyte/volumes/vol123/_data", - }, - mockDeps, - ); + describe("progress callbacks", () => { + test("calls onProgress with parsed status updates", async () => { + const progressUpdates: unknown[] = []; + setup({ onSpawnCall: (params) => params.onStdout?.(validProgressLine) }); - expect(getRestoreArg()).toBe("snapshot-789:/var/lib/zerobyte/volumes/vol123/_data"); - expect(getOptionValues("--include")).toEqual([]); - }); + await restore( + config, + "snapshot-123", + "/tmp/restore-target", + { + organizationId: "org-1", + basePath: "/var/lib/zerobyte/volumes/vol123/_data", + onProgress: (progress) => progressUpdates.push(progress), + }, + mockDeps, + ); - test("restores a single selected file from its parent directory for non-root targets", async () => { - const { getRestoreArg, getOptionValues } = setup(); - const options: Parameters[3] & { selectedItemKind: "file" } = { - organizationId: "org-1", - include: ["/var/lib/zerobyte/volumes/vol123/_data/archive/backup.20260301-233001.7z"], - selectedItemKind: "file", - }; + expect(progressUpdates).toHaveLength(1); + expect(progressUpdates[0]).toMatchObject({ + message_type: "status", + percent_done: 0.5, + files_restored: 1, + }); + }); - await restore(config, "snapshot-single-file", "/tmp/restore-target", options, mockDeps); + test("ignores non-JSON progress lines", async () => { + const progressUpdates: unknown[] = []; + setup({ + onSpawnCall: (params) => { + params.onStdout?.("scanning..."); + params.onStdout?.("repository opened"); + }, + }); - expect(getRestoreArg()).toBe("snapshot-single-file:/var/lib/zerobyte/volumes/vol123/_data/archive"); - expect(getOptionValues("--include")).toEqual(["backup.20260301-233001.7z"]); - }); + await restore( + config, + "snapshot-123", + "/tmp/restore-target", + { + organizationId: "org-1", + basePath: "/var/lib/zerobyte/volumes/vol123/_data", + onProgress: (progress) => progressUpdates.push(progress), + }, + mockDeps, + ); - test("does not pass an empty include when include equals restore root", async () => { - const { getArgs, getRestoreArg, getOptionValues } = setup(); - await restore( - config, - "snapshot-7202d8cc", - "/Users/nicolas/Documents/restore", - { - organizationId: "org-1", - include: ["/Users/nicolas/Developer/zerobyte/tmp/deep/test/files"], - overwrite: "always", - }, - mockDeps, - ); - - expect(getRestoreArg()).toBe("snapshot-7202d8cc:/Users/nicolas/Developer/zerobyte/tmp/deep/test/files"); - expect(getOptionValues("--include")).toEqual([]); - expect(getArgs()).not.toContain(""); - }); - - test("treats flag-like snapshot IDs as positional restore args", async () => { - const { getArgs, getRestoreArg } = setup(); - - await restore( - config, - "--help", - "/tmp/restore-target", - { - organizationId: "org-1", - basePath: "/var/lib/zerobyte/volumes/vol123/_data", - }, - mockDeps, - ); - - expect(getRestoreArg()).toBe("--help:/var/lib/zerobyte/volumes/vol123/_data"); - expect(getArgs()).toEqual([ - "--repo", - "/tmp/restic-repo", - "restore", - "--target", - "/tmp/restore-target", - "--json", - "--", - "--help:/var/lib/zerobyte/volumes/vol123/_data", - ]); + expect(progressUpdates).toHaveLength(0); + }); }); }); diff --git a/packages/core/src/restic/commands/__tests__/tag-snapshots.test.ts b/packages/core/src/restic/commands/__tests__/tag-snapshots.test.ts index 77246c1a..8d412505 100644 --- a/packages/core/src/restic/commands/__tests__/tag-snapshots.test.ts +++ b/packages/core/src/restic/commands/__tests__/tag-snapshots.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { afterEach, describe, expect, test, vi } from "vitest"; import * as cleanupModule from "../../helpers/cleanup-temporary-keys"; import * as nodeModule from "../../../node"; import { tagSnapshots } from "../tag-snapshots"; @@ -22,8 +22,8 @@ const config = { const setup = () => { let capturedArgs: string[] = []; - spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve()); - spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => { + vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve()); + vi.spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => { capturedArgs = args ?? []; return { exitCode: 0, stdout: "", stderr: "", timedOut: false }; }); @@ -34,7 +34,7 @@ const setup = () => { }; afterEach(() => { - mock.restore(); + vi.restoreAllMocks(); }); describe("tagSnapshots command", () => { @@ -44,6 +44,8 @@ describe("tagSnapshots command", () => { await tagSnapshots(config, snapshotIds, { add: ["keep"] }, "org-1", mockDeps); - expect(getArgs()).toEqual(["--repo", "/tmp/restic-repo", "tag", "--add", "keep", "--json", "--", ...snapshotIds]); + const separatorIndex = getArgs().indexOf("--"); + expect(separatorIndex).toBeGreaterThan(-1); + expect(getArgs().slice(separatorIndex + 1)).toEqual(snapshotIds); }); }); diff --git a/packages/core/src/restic/helpers/__tests__/build-env.test.ts b/packages/core/src/restic/helpers/__tests__/build-env.test.ts index e54afcfd..6428af73 100644 --- a/packages/core/src/restic/helpers/__tests__/build-env.test.ts +++ b/packages/core/src/restic/helpers/__tests__/build-env.test.ts @@ -1,5 +1,5 @@ import fs from "node:fs/promises"; -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "vitest"; import { buildEnv } from "../build-env"; import type { ResticDeps } from "../../types"; diff --git a/packages/core/src/restic/helpers/__tests__/build-repo-url.test.ts b/packages/core/src/restic/helpers/__tests__/build-repo-url.test.ts index a5aa8c50..84193b75 100644 --- a/packages/core/src/restic/helpers/__tests__/build-repo-url.test.ts +++ b/packages/core/src/restic/helpers/__tests__/build-repo-url.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from "vitest"; import { buildRepoUrl } from "../build-repo-url"; describe("buildRepoUrl", () => { diff --git a/packages/core/src/restic/helpers/__tests__/validate-custom-params.test.ts b/packages/core/src/restic/helpers/__tests__/validate-custom-params.test.ts index 1599f07b..410a7c36 100644 --- a/packages/core/src/restic/helpers/__tests__/validate-custom-params.test.ts +++ b/packages/core/src/restic/helpers/__tests__/validate-custom-params.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from "vitest"; import { validateCustomResticParams } from "../validate-custom-params"; describe("validateCustomResticParams", () => { diff --git a/packages/core/src/utils/__tests__/fs.test.ts b/packages/core/src/utils/__tests__/fs.test.ts index 34ecf7b2..1aaed3d4 100644 --- a/packages/core/src/utils/__tests__/fs.test.ts +++ b/packages/core/src/utils/__tests__/fs.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "vitest"; import { FILE_MODES, writeFileWithMode } from "../fs"; const tempDirectories = new Set(); diff --git a/packages/core/test/setup.ts b/packages/core/test/setup.ts index 99dd0829..5e136be4 100644 --- a/packages/core/test/setup.ts +++ b/packages/core/test/setup.ts @@ -1,6 +1,6 @@ -import { mock } from "bun:test"; +import { vi } from "vitest"; -void mock.module("../src/utils/logger.ts", () => ({ +vi.mock(import("../src/utils/logger.ts"), () => ({ logger: { debug: () => {}, info: () => {}, diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 00000000..74d7d98d --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + server: { + deps: { + inline: ["zod"], + }, + }, + include: ["src/**/*.test.ts", "src/**/*.spec.ts"], + setupFiles: ["./test/setup.ts"], + }, +}); diff --git a/tsconfig.json b/tsconfig.json index 6956e49b..a4506dd3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "include": ["app/**/*"], "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ES2022"], - "types": ["node", "vite/client"], + "types": ["bun", "node", "vite/client"], "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..3445aa13 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,53 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "~": path.resolve(import.meta.dirname, "app"), + }, + }, + test: { + server: { + deps: { + inline: ["zod"], + }, + }, + projects: [ + { + extends: true, + test: { + name: "server", + environment: "node", + include: ["app/**/*.test.ts", "app/**/*.test.tsx", "app/**/*.spec.ts", "app/**/*.spec.tsx"], + exclude: [ + "app/client/**/*.test.ts", + "app/client/**/*.test.tsx", + "app/client/**/*.spec.ts", + "app/client/**/*.spec.tsx", + ], + setupFiles: ["./app/test/setup.ts"], + }, + }, + { + extends: true, + test: { + name: "client", + environment: "happy-dom", + environmentOptions: { + happyDOM: { + url: "http://localhost:3000", + }, + }, + include: [ + "app/client/**/*.test.ts", + "app/client/**/*.test.tsx", + "app/client/**/*.spec.ts", + "app/client/**/*.spec.tsx", + ], + setupFiles: ["./app/test/setup-client.ts"], + }, + }, + ], + }, +});