chore: Migrate to vitest (#12272)

* wip

* Remove obsolete snapshots

* simplify

* chore(test): Convert mocks to TypeScript and tighten fetch mock types

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Remove unneccessary patches

* Migrate to msw instead of custom fetch mock

* Address PR review comments

- Split chained vi.useFakeTimers().setSystemTime() into separate calls.
- Switch test setup to dynamic imports so EventEmitter.defaultMaxListeners
  assignment runs before module init (static imports were hoisted above it).
- Drop redundant NODE_ENV guard in monkeyPatchSequelizeErrorsForJest; its
  sole caller already gates on env.isTest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-05-06 21:10:51 -04:00
committed by GitHub
parent 0139b91b5d
commit 091346dfe8
91 changed files with 2298 additions and 2961 deletions
+3 -18
View File
@@ -34,6 +34,7 @@ jobs:
config: config:
- '.github/**' - '.github/**'
- 'vite.config.ts' - 'vite.config.ts'
- 'vitest.config.ts'
server: server:
- 'server/**' - 'server/**'
- 'shared/**' - 'shared/**'
@@ -82,15 +83,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- uses: ./.github/actions/install - uses: ./.github/actions/install
- name: Restore Jest transform cache - run: yarn test:${{ matrix.test-group }}
uses: actions/cache@v4
with:
path: /tmp/jest_runner
key: jest-${{ matrix.test-group }}-${{ hashFiles('.babelrc', '.jestconfig.json', 'yarn.lock', 'app/**/*.ts', 'app/**/*.tsx', 'shared/**/*.ts', 'shared/**/*.tsx') }}
restore-keys: |
jest-${{ matrix.test-group }}-${{ hashFiles('.babelrc', '.jestconfig.json', 'yarn.lock') }}-
jest-${{ matrix.test-group }}-
- run: yarn test:${{ matrix.test-group }} --cacheDirectory=/tmp/jest_runner
test-server: test-server:
needs: changes needs: changes
@@ -118,17 +111,9 @@ jobs:
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- uses: ./.github/actions/install - uses: ./.github/actions/install
- name: Restore Jest transform cache
uses: actions/cache@v4
with:
path: /tmp/jest_runner
key: jest-server-${{ matrix.shard }}-${{ hashFiles('.babelrc', '.jestconfig.json', 'yarn.lock', 'server/**/*.ts', 'shared/**/*.ts', 'plugins/**/*.ts') }}
restore-keys: |
jest-server-${{ matrix.shard }}-${{ hashFiles('.babelrc', '.jestconfig.json', 'yarn.lock') }}-
jest-server-${{ matrix.shard }}-
- run: yarn sequelize db:migrate - run: yarn sequelize db:migrate
- name: Run server tests - name: Run server tests
run: yarn test:server --maxWorkers=2 --shard=${{ matrix.shard }}/4 --cacheDirectory=/tmp/jest_runner run: yarn test:server --maxWorkers=2 --shard=${{ matrix.shard }}/4
bundle-size: bundle-size:
needs: changes needs: changes
-63
View File
@@ -1,63 +0,0 @@
{
"workerIdleMemoryLimit": "0.75",
"maxWorkers": "50%",
"transformIgnorePatterns": ["node_modules/(?!(franc|trigram-utils)/)"],
"projects": [
{
"displayName": "server",
"roots": ["<rootDir>/server", "<rootDir>/plugins"],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": [
"<rootDir>/__mocks__/console.js",
"<rootDir>/server/test/setupMocks.js"
],
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
"testEnvironment": "node"
},
{
"displayName": "app",
"roots": ["<rootDir>/app"],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"modulePaths": ["<rootDir>/app"],
"setupFiles": ["<rootDir>/__mocks__/window.js"],
"setupFilesAfterEnv": ["<rootDir>/app/test/setup.ts"],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
}
},
{
"displayName": "shared-node",
"roots": ["<rootDir>/shared"],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
"testEnvironment": "node"
},
{
"displayName": "shared-jsdom",
"roots": ["<rootDir>/shared"],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"setupFiles": ["<rootDir>/__mocks__/window.js"],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
}
}
]
}
+3 -3
View File
@@ -1,4 +1,6 @@
/* oxlint-disable */ /* oxlint-disable */
import "reflect-metadata";
import { vi } from "vitest";
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module '../.... Remove this comment to see the full error message // @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module '../.... Remove this comment to see the full error message
import localStorage from "../../__mocks__/localStorage"; import localStorage from "../../__mocks__/localStorage";
import { initI18n } from "../utils/i18n"; import { initI18n } from "../utils/i18n";
@@ -7,6 +9,4 @@ initI18n();
global.localStorage = localStorage; global.localStorage = localStorage;
require("jest-fetch-mock").enableMocks(); vi.mock("~/utils/ApiClient");
jest.mock("~/utils/ApiClient");
+3 -1
View File
@@ -1,6 +1,8 @@
/* oxlint-disable */ /* oxlint-disable */
import { vi } from "vitest";
export const client = { export const client = {
post: jest.fn(() => post: vi.fn(() =>
Promise.resolve({ Promise.resolve({
data: { data: {
user: {}, user: {},
+14 -10
View File
@@ -28,10 +28,11 @@
"db:rollback": "sequelize db:migrate:undo", "db:rollback": "sequelize db:migrate:undo",
"db:reset": "sequelize db:drop && sequelize db:create && sequelize db:migrate", "db:reset": "sequelize db:drop && sequelize db:create && sequelize db:migrate",
"upgrade": "git fetch && git pull && yarn install && yarn heroku-postbuild", "upgrade": "git fetch && git pull && yarn install && yarn heroku-postbuild",
"test": "TZ=UTC jest --config=.jestconfig.json", "test": "TZ=UTC vitest run",
"test:app": "TZ=UTC jest --config=.jestconfig.json --selectProjects app", "test:app": "TZ=UTC vitest run --project app",
"test:shared": "TZ=UTC jest --config=.jestconfig.json --selectProjects shared-node shared-jsdom", "test:shared": "TZ=UTC vitest run --project shared-node --project shared-jsdom",
"test:server": "TZ=UTC jest --config=.jestconfig.json --selectProjects server", "test:server": "TZ=UTC vitest run --project server",
"test:watch": "TZ=UTC vitest",
"vite:dev": "VITE_CJS_IGNORE_WARNING=true vite", "vite:dev": "VITE_CJS_IGNORE_WARNING=true vite",
"vite:build": "VITE_CJS_IGNORE_WARNING=true vite build", "vite:build": "VITE_CJS_IGNORE_WARNING=true vite build",
"vite:preview": "VITE_CJS_IGNORE_WARNING=true vite preview" "vite:preview": "VITE_CJS_IGNORE_WARNING=true vite preview"
@@ -283,6 +284,7 @@
"@babel/preset-typescript": "^7.28.5", "@babel/preset-typescript": "^7.28.5",
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.1", "@relative-ci/agent": "^4.3.1",
"@swc/core": "^1.15.32",
"@types/addressparser": "^1.0.3", "@types/addressparser": "^1.0.3",
"@types/cookie": "0.6.0", "@types/cookie": "0.6.0",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
@@ -298,8 +300,8 @@
"@types/google.analytics": "^0.0.46", "@types/google.analytics": "^0.0.46",
"@types/invariant": "^2.2.37", "@types/invariant": "^2.2.37",
"@types/ioredis-mock": "^8.2.7", "@types/ioredis-mock": "^8.2.7",
"@types/jest": "^29.5.14",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/jsdom": "^28.0.1",
"@types/jsonwebtoken": "^8.5.9", "@types/jsonwebtoken": "^8.5.9",
"@types/katex": "^0.16.8", "@types/katex": "^0.16.8",
"@types/koa": "^2.15.0", "@types/koa": "^2.15.0",
@@ -346,7 +348,7 @@
"@types/utf8": "^3.0.3", "@types/utf8": "^3.0.3",
"@types/validator": "^13.15.10", "@types/validator": "^13.15.10",
"@types/yauzl": "^2.10.3", "@types/yauzl": "^2.10.3",
"babel-jest": "^30.3.0", "@vitest/ui": "^4.1.5",
"babel-plugin-module-resolver": "^5.0.3", "babel-plugin-module-resolver": "^5.0.3",
"babel-plugin-styled-components": "^2.1.4", "babel-plugin-styled-components": "^2.1.4",
"babel-plugin-transform-inline-environment-variables": "^0.4.4", "babel-plugin-transform-inline-environment-variables": "^0.4.4",
@@ -357,10 +359,8 @@
"husky": "^8.0.3", "husky": "^8.0.3",
"i18next-parser": "^8.13.0", "i18next-parser": "^8.13.0",
"ioredis-mock": "^8.13.1", "ioredis-mock": "^8.13.1",
"jest-cli": "^30.3.0",
"jest-environment-jsdom": "^30.3.0",
"jest-fetch-mock": "^3.0.3",
"lint-staged": "^16.4.0", "lint-staged": "^16.4.0",
"msw": "^2.14.2",
"nodemon": "^3.1.14", "nodemon": "^3.1.14",
"oxlint": "1.50.0", "oxlint": "1.50.0",
"oxlint-tsgolint": "0.14.2", "oxlint-tsgolint": "0.14.2",
@@ -370,7 +370,11 @@
"rimraf": "^6.1.3", "rimraf": "^6.1.3",
"rollup-plugin-webpack-stats": "2.1.11", "rollup-plugin-webpack-stats": "2.1.11",
"terser": "^5.44.1", "terser": "^5.44.1",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"unplugin-swc": "^1.5.9",
"vite-plugin-babel": "^1.6.0",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.1.5"
}, },
"resolutions": { "resolutions": {
"@types/react": "17.0.91", "@types/react": "17.0.91",
+9 -9
View File
@@ -28,7 +28,7 @@ describe("email", () => {
}); });
it("should respond with redirect location when user is SSO enabled", async () => { it("should respond with redirect location when user is SSO enabled", async () => {
const spy = jest.spyOn(WelcomeEmail.prototype, "schedule"); const spy = vi.spyOn(WelcomeEmail.prototype, "schedule");
const subdomain = faker.internet.domainWord(); const subdomain = faker.internet.domainWord();
const team = await buildTeam({ subdomain }); const team = await buildTeam({ subdomain });
const user = await buildUser({ teamId: team.id }); const user = await buildUser({ teamId: team.id });
@@ -48,7 +48,7 @@ describe("email", () => {
}); });
it("should respond with success and email to be sent when user has SSO but disabled", async () => { it("should respond with success and email to be sent when user has SSO but disabled", async () => {
const spy = jest.spyOn(SigninEmail.prototype, "schedule"); const spy = vi.spyOn(SigninEmail.prototype, "schedule");
const subdomain = faker.internet.domainWord(); const subdomain = faker.internet.domainWord();
const team = await buildTeam({ subdomain }); const team = await buildTeam({ subdomain });
const user = await buildUser({ teamId: team.id }); const user = await buildUser({ teamId: team.id });
@@ -83,7 +83,7 @@ describe("email", () => {
it("should not send email when user is on another subdomain but respond with success", async () => { it("should not send email when user is on another subdomain but respond with success", async () => {
const user = await buildUser(); const user = await buildUser();
const spy = jest.spyOn(WelcomeEmail.prototype, "schedule"); const spy = vi.spyOn(WelcomeEmail.prototype, "schedule");
const subdomain = faker.internet.domainWord(); const subdomain = faker.internet.domainWord();
await buildTeam({ subdomain }); await buildTeam({ subdomain });
const res = await server.post("/auth/email", { const res = await server.post("/auth/email", {
@@ -103,7 +103,7 @@ describe("email", () => {
}); });
it("should respond with success and email to be sent when user is not SSO enabled", async () => { it("should respond with success and email to be sent when user is not SSO enabled", async () => {
const spy = jest.spyOn(SigninEmail.prototype, "schedule"); const spy = vi.spyOn(SigninEmail.prototype, "schedule");
const subdomain = faker.internet.domainWord(); const subdomain = faker.internet.domainWord();
const team = await buildTeam({ subdomain }); const team = await buildTeam({ subdomain });
const user = await buildGuestUser({ const user = await buildGuestUser({
@@ -125,7 +125,7 @@ describe("email", () => {
}); });
it("should respond with success regardless of whether successful to prevent crawling email logins", async () => { it("should respond with success regardless of whether successful to prevent crawling email logins", async () => {
const spy = jest.spyOn(WelcomeEmail.prototype, "schedule"); const spy = vi.spyOn(WelcomeEmail.prototype, "schedule");
const subdomain = faker.internet.domainWord(); const subdomain = faker.internet.domainWord();
await buildTeam({ subdomain }); await buildTeam({ subdomain });
const res = await server.post("/auth/email", { const res = await server.post("/auth/email", {
@@ -145,7 +145,7 @@ describe("email", () => {
describe("with multiple users matching email", () => { describe("with multiple users matching email", () => {
it("should default to current subdomain with SSO", async () => { it("should default to current subdomain with SSO", async () => {
const spy = jest.spyOn(SigninEmail.prototype, "schedule"); const spy = vi.spyOn(SigninEmail.prototype, "schedule");
const email = "sso-user@example.org"; const email = "sso-user@example.org";
const subdomain = faker.internet.domainWord(); const subdomain = faker.internet.domainWord();
const team = await buildTeam({ const team = await buildTeam({
@@ -174,7 +174,7 @@ describe("email", () => {
}); });
it("should default to current subdomain with guest email", async () => { it("should default to current subdomain with guest email", async () => {
const spy = jest.spyOn(SigninEmail.prototype, "schedule"); const spy = vi.spyOn(SigninEmail.prototype, "schedule");
const email = "guest-user@example.org"; const email = "guest-user@example.org";
const subdomain = faker.internet.domainWord(); const subdomain = faker.internet.domainWord();
const team = await buildTeam({ const team = await buildTeam({
@@ -203,7 +203,7 @@ describe("email", () => {
}); });
it("should default to custom domain with SSO", async () => { it("should default to custom domain with SSO", async () => {
const spy = jest.spyOn(WelcomeEmail.prototype, "schedule"); const spy = vi.spyOn(WelcomeEmail.prototype, "schedule");
const email = "sso-user-2@example.org"; const email = "sso-user-2@example.org";
const domain = faker.internet.domainName(); const domain = faker.internet.domainName();
const team = await buildTeam({ const team = await buildTeam({
@@ -232,7 +232,7 @@ describe("email", () => {
}); });
it("should default to custom domain with guest email", async () => { it("should default to custom domain with guest email", async () => {
const spy = jest.spyOn(SigninEmail.prototype, "schedule"); const spy = vi.spyOn(SigninEmail.prototype, "schedule");
const email = "guest-user-2@example.org"; const email = "guest-user-2@example.org";
const domain = faker.internet.domainName(); const domain = faker.internet.domainName();
const team = await buildTeam({ const team = await buildTeam({
@@ -2,12 +2,19 @@ import { Node } from "prosemirror-model";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper"; import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import nodesWithEmptyTextNode from "@server/test/fixtures/notion-page-with-empty-text-nodes.json"; import nodesWithEmptyTextNode from "@server/test/fixtures/notion-page-with-empty-text-nodes.json";
import allNodes from "@server/test/fixtures/notion-page.json"; import allNodes from "@server/test/fixtures/notion-page.json";
import type { ProsemirrorData, ProsemirrorDoc } from "@shared/types";
import type { NotionPage } from "./NotionConverter"; import type { NotionPage } from "./NotionConverter";
import { NotionConverter } from "./NotionConverter"; import { NotionConverter } from "./NotionConverter";
jest.mock("node:crypto", () => ({ const generatedId = "550e8400-e29b-41d4-a716-446655440000";
randomUUID: jest.fn(() => "550e8400-e29b-41d4-a716-446655440000"),
})); function normalizeGeneratedIds(node: ProsemirrorDoc | ProsemirrorData) {
if (node.type === "container_toggle" && node.attrs) {
node.attrs.id = generatedId;
}
node.content?.forEach(normalizeGeneratedIds);
}
describe("NotionConverter", () => { describe("NotionConverter", () => {
it("converts a page", () => { it("converts a page", () => {
@@ -15,6 +22,7 @@ describe("NotionConverter", () => {
children: allNodes, children: allNodes,
} as NotionPage); } as NotionPage);
normalizeGeneratedIds(response);
expect(response).toMatchSnapshot(); expect(response).toMatchSnapshot();
expect(ProsemirrorHelper.toProsemirror(response)).toBeInstanceOf(Node); expect(ProsemirrorHelper.toProsemirror(response)).toBeInstanceOf(Node);
}); });
@@ -24,6 +32,7 @@ describe("NotionConverter", () => {
children: nodesWithEmptyTextNode, children: nodesWithEmptyTextNode,
} as NotionPage); } as NotionPage);
normalizeGeneratedIds(response);
expect(response).toMatchSnapshot(); expect(response).toMatchSnapshot();
expect(ProsemirrorHelper.toProsemirror(response)).toBeInstanceOf(Node); expect(ProsemirrorHelper.toProsemirror(response)).toBeInstanceOf(Node);
}); });
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`NotionConverter converts a page 1`] = ` exports[`NotionConverter > converts a page 1`] = `
{ {
"content": [ "content": [
{ {
@@ -2093,7 +2093,7 @@ exports[`NotionConverter converts a page 1`] = `
} }
`; `;
exports[`NotionConverter converts a page with empty text nodes 1`] = ` exports[`NotionConverter > converts a page with empty text nodes 1`] = `
{ {
"content": [ "content": [
{ {
+77 -50
View File
@@ -1,9 +1,22 @@
import fetchMock from "jest-fetch-mock"; import {
http,
HttpResponse,
type DefaultBodyType,
type StrictRequest,
} from "msw";
import { server } from "@server/test/msw";
import { fetchOIDCConfiguration } from "./oidcDiscovery"; import { fetchOIDCConfiguration } from "./oidcDiscovery";
beforeEach(() => { const captureRequest = (url: string, response: Response | (() => Response)) => {
fetchMock.resetMocks(); const captured: { request?: StrictRequest<DefaultBodyType> } = {};
}); server.use(
http.get(url, ({ request }) => {
captured.request = request;
return typeof response === "function" ? response() : response;
})
);
return captured;
};
describe("fetchOIDCConfiguration", () => { describe("fetchOIDCConfiguration", () => {
it("should fetch and parse OIDC configuration successfully", async () => { it("should fetch and parse OIDC configuration successfully", async () => {
@@ -19,20 +32,18 @@ describe("fetchOIDCConfiguration", () => {
grant_types_supported: ["authorization_code"], grant_types_supported: ["authorization_code"],
}; };
fetchMock.mockResponseOnce(JSON.stringify(mockConfig)); const captured = captureRequest(
"https://example.com/.well-known/openid-configuration",
() => HttpResponse.json(mockConfig)
);
const result = await fetchOIDCConfiguration("https://example.com"); const result = await fetchOIDCConfiguration("https://example.com");
expect(fetchMock).toHaveBeenCalledWith( expect(captured.request?.url).toBe(
"https://example.com/.well-known/openid-configuration", "https://example.com/.well-known/openid-configuration"
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({
Accept: "application/json",
}),
})
); );
expect(captured.request?.method).toBe("GET");
expect(captured.request?.headers.get("Accept")).toBe("application/json");
expect(result).toEqual(mockConfig); expect(result).toEqual(mockConfig);
}); });
@@ -44,26 +55,37 @@ describe("fetchOIDCConfiguration", () => {
userinfo_endpoint: "https://example.com/userinfo", userinfo_endpoint: "https://example.com/userinfo",
}; };
fetchMock.mockResponseOnce(JSON.stringify(mockConfig)); const captured = captureRequest(
"https://example.com/.well-known/openid-configuration",
() => HttpResponse.json(mockConfig)
);
await fetchOIDCConfiguration("https://example.com/"); await fetchOIDCConfiguration("https://example.com/");
expect(fetchMock).toHaveBeenCalledWith( expect(captured.request?.url).toBe(
"https://example.com/.well-known/openid-configuration", "https://example.com/.well-known/openid-configuration"
expect.any(Object)
); );
}); });
it("should throw error when HTTP request fails", async () => { it("should throw error when HTTP request fails", async () => {
fetchMock.mockRejectOnce(new Error("Network error")); server.use(
http.get("https://example.com/.well-known/openid-configuration", () =>
await expect(fetchOIDCConfiguration("https://example.com")).rejects.toThrow( HttpResponse.error()
"Network error" )
); );
await expect(
fetchOIDCConfiguration("https://example.com")
).rejects.toThrow();
}); });
it("should throw error when response is not ok", async () => { it("should throw error when response is not ok", async () => {
fetchMock.mockResponseOnce("Not Found", { status: 404 }); server.use(
http.get(
"https://example.com/.well-known/openid-configuration",
() => new HttpResponse("Not Found", { status: 404 })
)
);
await expect(fetchOIDCConfiguration("https://example.com")).rejects.toThrow( await expect(fetchOIDCConfiguration("https://example.com")).rejects.toThrow(
"Failed to fetch OIDC configuration: 404 Not Found" "Failed to fetch OIDC configuration: 404 Not Found"
@@ -77,7 +99,11 @@ describe("fetchOIDCConfiguration", () => {
// Missing token_endpoint and userinfo_endpoint // Missing token_endpoint and userinfo_endpoint
}; };
fetchMock.mockResponseOnce(JSON.stringify(incompleteConfig)); server.use(
http.get("https://example.com/.well-known/openid-configuration", () =>
HttpResponse.json(incompleteConfig)
)
);
await expect(fetchOIDCConfiguration("https://example.com")).rejects.toThrow( await expect(fetchOIDCConfiguration("https://example.com")).rejects.toThrow(
"Missing token_endpoint in OIDC configuration" "Missing token_endpoint in OIDC configuration"
@@ -91,7 +117,11 @@ describe("fetchOIDCConfiguration", () => {
userinfo_endpoint: "https://example.com/userinfo", userinfo_endpoint: "https://example.com/userinfo",
}; };
fetchMock.mockResponseOnce(JSON.stringify(configMissingAuth)); server.use(
http.get("https://example.com/.well-known/openid-configuration", () =>
HttpResponse.json(configMissingAuth)
)
);
await expect(fetchOIDCConfiguration("https://example.com")).rejects.toThrow( await expect(fetchOIDCConfiguration("https://example.com")).rejects.toThrow(
"Missing authorization_endpoint in OIDC configuration" "Missing authorization_endpoint in OIDC configuration"
@@ -108,22 +138,20 @@ describe("fetchOIDCConfiguration", () => {
"https://auth.example.com/application/o/outline/userinfo", "https://auth.example.com/application/o/outline/userinfo",
}; };
fetchMock.mockResponseOnce(JSON.stringify(mockConfig)); const captured = captureRequest(
"https://auth.example.com/application/o/outline/.well-known/openid-configuration",
() => HttpResponse.json(mockConfig)
);
const result = await fetchOIDCConfiguration( const result = await fetchOIDCConfiguration(
"https://auth.example.com/application/o/outline/" "https://auth.example.com/application/o/outline/"
); );
expect(fetchMock).toHaveBeenCalledWith( expect(captured.request?.url).toBe(
"https://auth.example.com/application/o/outline/.well-known/openid-configuration", "https://auth.example.com/application/o/outline/.well-known/openid-configuration"
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({
Accept: "application/json",
}),
})
); );
expect(captured.request?.method).toBe("GET");
expect(captured.request?.headers.get("Accept")).toBe("application/json");
expect(result).toEqual(mockConfig); expect(result).toEqual(mockConfig);
}); });
@@ -137,22 +165,20 @@ describe("fetchOIDCConfiguration", () => {
"https://auth.example.com/application/o/outline/userinfo", "https://auth.example.com/application/o/outline/userinfo",
}; };
fetchMock.mockResponseOnce(JSON.stringify(mockConfig)); const captured = captureRequest(
"https://auth.example.com/application/o/outline/.well-known/openid-configuration",
() => HttpResponse.json(mockConfig)
);
const result = await fetchOIDCConfiguration( const result = await fetchOIDCConfiguration(
"https://auth.example.com/application/o/outline" "https://auth.example.com/application/o/outline"
); );
expect(fetchMock).toHaveBeenCalledWith( expect(captured.request?.url).toBe(
"https://auth.example.com/application/o/outline/.well-known/openid-configuration", "https://auth.example.com/application/o/outline/.well-known/openid-configuration"
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({
Accept: "application/json",
}),
})
); );
expect(captured.request?.method).toBe("GET");
expect(captured.request?.headers.get("Accept")).toBe("application/json");
expect(result).toEqual(mockConfig); expect(result).toEqual(mockConfig);
}); });
@@ -164,17 +190,18 @@ describe("fetchOIDCConfiguration", () => {
userinfo_endpoint: "https://example.com/userinfo", userinfo_endpoint: "https://example.com/userinfo",
}; };
fetchMock.mockResponseOnce(JSON.stringify(mockConfig)); const captured = captureRequest(
"https://example.com/.well-known/openid-configuration",
() => HttpResponse.json(mockConfig)
);
const result = await fetchOIDCConfiguration( const result = await fetchOIDCConfiguration(
"https://example.com/.well-known/openid-configuration" "https://example.com/.well-known/openid-configuration"
); );
expect(fetchMock).toHaveBeenCalledWith( expect(captured.request?.url).toBe(
"https://example.com/.well-known/openid-configuration", "https://example.com/.well-known/openid-configuration"
expect.any(Object)
); );
expect(result).toEqual(mockConfig); expect(result).toEqual(mockConfig);
}); });
}); });
@@ -21,7 +21,7 @@ import PostgresSearchProvider from "./PostgresSearchProvider";
const provider = SearchProviderManager.getProvider(); const provider = SearchProviderManager.getProvider();
beforeEach(async () => { beforeEach(async () => {
jest.resetAllMocks(); vi.resetAllMocks();
await buildDocument(); await buildDocument();
}); });
+8 -4
View File
@@ -6,12 +6,16 @@ import { getTestServer } from "@server/test/support";
import env from "../env"; import env from "../env";
import * as Slack from "../slack"; import * as Slack from "../slack";
jest.mock("../slack", () => ({
post: jest.fn(),
}));
const server = getTestServer(); const server = getTestServer();
beforeEach(() => {
vi.spyOn(Slack, "post").mockResolvedValue({ ok: true });
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("#hooks.unfurl", () => { describe("#hooks.unfurl", () => {
it("should return documents with matching SSO user", async () => { it("should return documents with matching SSO user", async () => {
const user = await buildUser(); const user = await buildUser();
+1 -1
View File
@@ -20,7 +20,7 @@ import { randomUUID } from "node:crypto";
const server = getTestServer(); const server = getTestServer();
// Increase timeout for all tests in this file // Increase timeout for all tests in this file
jest.setTimeout(10000); vi.setConfig({ testTimeout: 10000 });
describe("#files.create", () => { describe("#files.create", () => {
it("should fail with status 400 bad request if key is invalid", async () => { it("should fail with status 400 bad request if key is invalid", async () => {
@@ -1,16 +1,10 @@
import { buildUser, buildWebhookSubscription } from "@server/test/factories"; import { buildUser, buildWebhookSubscription } from "@server/test/factories";
import { mockTaskSchedule } from "@server/test/support";
import type { UserEvent } from "@server/types"; import type { UserEvent } from "@server/types";
import DeliverWebhookTask from "../tasks/DeliverWebhookTask";
import WebhookProcessor from "./WebhookProcessor"; import WebhookProcessor from "./WebhookProcessor";
jest.mock("../tasks/DeliverWebhookTask");
const ip = "127.0.0.1"; const ip = "127.0.0.1";
const schedule = jest.fn(); const schedule = mockTaskSchedule();
beforeEach(() => {
jest.resetAllMocks();
DeliverWebhookTask.prototype.schedule = schedule;
});
describe("WebhookProcessor", () => { describe("WebhookProcessor", () => {
it("it schedules a delivery for the event", async () => { it("it schedules a delivery for the event", async () => {
@@ -1,4 +1,10 @@
import fetchMock from "jest-fetch-mock"; import {
http,
HttpResponse,
type DefaultBodyType,
type StrictRequest,
} from "msw";
import { server } from "@server/test/msw";
import { WebhookDelivery } from "@server/models"; import { WebhookDelivery } from "@server/models";
import { import {
buildUser, buildUser,
@@ -8,14 +14,28 @@ import {
import type { UserEvent } from "@server/types"; import type { UserEvent } from "@server/types";
import DeliverWebhookTask from "./DeliverWebhookTask"; import DeliverWebhookTask from "./DeliverWebhookTask";
beforeEach(async () => {
jest.resetAllMocks();
fetchMock.resetMocks();
fetchMock.doMock();
});
const ip = "127.0.0.1"; const ip = "127.0.0.1";
type CapturedRequest = {
request: StrictRequest<DefaultBodyType>;
body: string;
};
const captureWebhook = (
url: string,
response: () => Response = () => new HttpResponse(null, { status: 200 })
) => {
const captured: CapturedRequest[] = [];
server.use(
http.post(url, async ({ request }) => {
const cloned = request.clone();
captured.push({ request, body: await cloned.text() });
return response();
})
);
return captured;
};
describe("DeliverWebhookTask", () => { describe("DeliverWebhookTask", () => {
test("should hit the subscription url and record a delivery", async () => { test("should hit the subscription url and record a delivery", async () => {
const subscription = await buildWebhookSubscription({ const subscription = await buildWebhookSubscription({
@@ -25,7 +45,10 @@ describe("DeliverWebhookTask", () => {
const signedInUser = await buildUser({ teamId: subscription.teamId }); const signedInUser = await buildUser({ teamId: subscription.teamId });
const processor = new DeliverWebhookTask(); const processor = new DeliverWebhookTask();
fetchMock.mockResponse("SUCCESS", { status: 200 }); const captured = captureWebhook(
"http://example.com",
() => new HttpResponse("SUCCESS", { status: 200 })
);
const event: UserEvent = { const event: UserEvent = {
name: "users.signin", name: "users.signin",
@@ -39,12 +62,9 @@ describe("DeliverWebhookTask", () => {
event, event,
}); });
expect(fetchMock).toHaveBeenCalledTimes(1); expect(captured.length).toBe(1);
expect(fetchMock).toHaveBeenCalledWith( expect(captured[0].request.url).toBe("http://example.com/");
"http://example.com", const parsedBody = JSON.parse(captured[0].body);
expect.anything()
);
const parsedBody = JSON.parse(fetchMock.mock.calls[0]![1]!.body as string);
expect(parsedBody.webhookSubscriptionId).toBe(subscription.id); expect(parsedBody.webhookSubscriptionId).toBe(subscription.id);
expect(parsedBody.event).toBe("users.signin"); expect(parsedBody.event).toBe("users.signin");
expect(parsedBody.payload.id).toBe(signedInUser.id); expect(parsedBody.payload.id).toBe(signedInUser.id);
@@ -70,6 +90,8 @@ describe("DeliverWebhookTask", () => {
const signedInUser = await buildUser({ teamId: subscription.teamId }); const signedInUser = await buildUser({ teamId: subscription.teamId });
const processor = new DeliverWebhookTask(); const processor = new DeliverWebhookTask();
const captured = captureWebhook("http://example.com");
const event: UserEvent = { const event: UserEvent = {
name: "users.signin", name: "users.signin",
userId: signedInUser.id, userId: signedInUser.id,
@@ -82,13 +104,10 @@ describe("DeliverWebhookTask", () => {
event, event,
}); });
const headers = fetchMock.mock.calls[0]![1]!.headers! as Record< expect(captured.length).toBe(1);
string, expect(captured[0].request.headers.get("Outline-Signature")).toMatch(
string /^t=[0-9]+,s=[a-z0-9]+$/
>; );
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(headers["Outline-Signature"]).toMatch(/^t=[0-9]+,s=[a-z0-9]+$/);
}); });
test("should hit the subscription url when the eventing model doesn't exist", async () => { test("should hit the subscription url when the eventing model doesn't exist", async () => {
@@ -108,17 +127,16 @@ describe("DeliverWebhookTask", () => {
ip, ip,
}; };
const captured = captureWebhook("http://example.com");
await task.perform({ await task.perform({
event, event,
subscriptionId: subscription.id, subscriptionId: subscription.id,
}); });
expect(fetchMock).toHaveBeenCalledTimes(1); expect(captured.length).toBe(1);
expect(fetchMock).toHaveBeenCalledWith( expect(captured[0].request.url).toBe("http://example.com/");
"http://example.com", const parsedBody = JSON.parse(captured[0].body);
expect.anything()
);
const parsedBody = JSON.parse(fetchMock.mock.calls[0]![1]!.body as string);
expect(parsedBody.webhookSubscriptionId).toBe(subscription.id); expect(parsedBody.webhookSubscriptionId).toBe(subscription.id);
expect(parsedBody.event).toBe("users.delete"); expect(parsedBody.event).toBe("users.delete");
expect(parsedBody.payload.id).toBe(deletedUserId); expect(parsedBody.payload.id).toBe(deletedUserId);
@@ -140,7 +158,10 @@ describe("DeliverWebhookTask", () => {
events: ["*"], events: ["*"],
}); });
fetchMock.mockResponse("FAILED", { status: 500 }); captureWebhook(
"http://example.com",
() => new HttpResponse("FAILED", { status: 500 })
);
const signedInUser = await buildUser({ teamId: subscription.teamId }); const signedInUser = await buildUser({ teamId: subscription.teamId });
const task = new DeliverWebhookTask(); const task = new DeliverWebhookTask();
@@ -186,9 +207,9 @@ describe("DeliverWebhookTask", () => {
}); });
} }
fetchMock.mockResponse(JSON.stringify({ message: "Failure" }), { captureWebhook("http://example.com", () =>
status: 500, HttpResponse.json({ message: "Failure" }, { status: 500 })
}); );
const signedInUser = await buildUser({ teamId: subscription.teamId }); const signedInUser = await buildUser({ teamId: subscription.teamId });
const task = new DeliverWebhookTask(); const task = new DeliverWebhookTask();
-9
View File
@@ -1,9 +0,0 @@
import fetchMock from "jest-fetch-mock";
fetchMock.enableMocks();
// changes default behavior of fetchMock to use the real 'fetch' implementation.
// Mocks can now be enabled in each individual test with fetchMock.doMock()
fetchMock.dontMock();
export default fetch;
-10
View File
@@ -1,10 +0,0 @@
// Mock for franc language detection library
const franc = jest.fn((text) => {
// Return 'eng' (English) by default, or 'und' (undetermined) for empty text
if (!text || text.trim().length === 0) {
return "und";
}
return "eng";
});
module.exports = { franc };
+9
View File
@@ -0,0 +1,9 @@
import { vi } from "vitest";
export const franc = vi.fn((text: string) => {
if (!text || text.trim().length === 0) {
return "und";
}
return "eng";
});
@@ -1,5 +1,4 @@
// Mock for iso-639-3 language code conversion library export const iso6393To1: Record<string, string | undefined> = {
const iso6393To1 = {
eng: "en", eng: "en",
fra: "fr", fra: "fr",
deu: "de", deu: "de",
@@ -12,7 +11,5 @@ const iso6393To1 = {
ara: "ar", ara: "ar",
hin: "hi", hin: "hi",
ben: "bn", ben: "bn",
und: undefined, // undetermined und: undefined,
}; };
module.exports = { iso6393To1 };
@@ -0,0 +1,25 @@
import http from "node:http";
import https from "node:https";
interface MockAgentOptions
extends http.AgentOptions, Pick<https.AgentOptions, "maxCachedSessions"> {}
export function useAgent(url: string, options: MockAgentOptions = {}) {
const parsedUrl = new URL(url);
const agentOptions = {
keepAlive: options.keepAlive,
timeout: options.timeout,
keepAliveMsecs: options.keepAliveMsecs,
maxSockets: options.maxSockets,
maxFreeSockets: options.maxFreeSockets,
};
if (parsedUrl.protocol === "https:") {
return new https.Agent({
...agentOptions,
maxCachedSessions: options.maxCachedSessions,
});
}
return new http.Agent(agentOptions);
}
@@ -5,7 +5,10 @@ import { sleep } from "@shared/utils/timers";
import { ConnectionLimitExtension } from "./ConnectionLimitExtension"; import { ConnectionLimitExtension } from "./ConnectionLimitExtension";
import { EditorVersionExtension } from "./EditorVersionExtension"; import { EditorVersionExtension } from "./EditorVersionExtension";
jest.mock("@server/env", () => ({ vi.mock("@server/env", () => ({
default: {
COLLABORATION_MAX_CLIENTS_PER_DOCUMENT: 2,
},
COLLABORATION_MAX_CLIENTS_PER_DOCUMENT: 2, COLLABORATION_MAX_CLIENTS_PER_DOCUMENT: 2,
})); }));
@@ -13,7 +16,7 @@ describe("ConnectionLimitExtension", () => {
let server: typeof Server; let server: typeof Server;
let extension: ConnectionLimitExtension; let extension: ConnectionLimitExtension;
const port = 12345; const port = 12345;
const url = `ws://localhost:${port}`; const url = `ws://127.0.0.1:${port}`;
const documentName = "test"; const documentName = "test";
beforeEach(async () => { beforeEach(async () => {
+1 -1
View File
@@ -6,7 +6,7 @@ import { sequelize } from "@server/storage/database";
import { buildUser } from "@server/test/factories"; import { buildUser } from "@server/test/factories";
import documentImporter from "./documentImporter"; import documentImporter from "./documentImporter";
jest.mock("@server/storage/files"); vi.mock("@server/storage/files");
describe("documentImporter", () => { describe("documentImporter", () => {
it("should convert Word Document to markdown", async () => { it("should convert Word Document to markdown", async () => {
@@ -1,17 +1,10 @@
import { subDays } from "date-fns"; import { subDays } from "date-fns";
import { Attachment, Document } from "@server/models"; import { Attachment, Document } from "@server/models";
import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
import { buildAttachment, buildDocument } from "@server/test/factories"; import { buildAttachment, buildDocument } from "@server/test/factories";
import { mockTaskSchedule } from "@server/test/support";
import documentPermanentDeleter from "./documentPermanentDeleter"; import documentPermanentDeleter from "./documentPermanentDeleter";
jest.mock("@server/queues/tasks/DeleteAttachmentTask"); const schedule = mockTaskSchedule();
const schedule = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
DeleteAttachmentTask.prototype.schedule = schedule;
});
describe("documentPermanentDeleter", () => { describe("documentPermanentDeleter", () => {
it("should destroy documents", async () => { it("should destroy documents", async () => {
+2 -2
View File
@@ -68,7 +68,7 @@ describe("documentUpdater", () => {
}); });
it("should notify collaboration server when text changes", async () => { it("should notify collaboration server when text changes", async () => {
const notifyUpdateSpy = jest const notifyUpdateSpy = vi
.spyOn(APIUpdateExtension, "notifyUpdate") .spyOn(APIUpdateExtension, "notifyUpdate")
.mockResolvedValue(undefined); .mockResolvedValue(undefined);
@@ -95,7 +95,7 @@ describe("documentUpdater", () => {
}); });
it("should not notify collaboration server when only title changes", async () => { it("should not notify collaboration server when only title changes", async () => {
const notifyUpdateSpy = jest const notifyUpdateSpy = vi
.spyOn(APIUpdateExtension, "notifyUpdate") .spyOn(APIUpdateExtension, "notifyUpdate")
.mockResolvedValue(undefined); .mockResolvedValue(undefined);
+8 -6
View File
@@ -1,22 +1,24 @@
import { Hook, PluginManager } from "@server/utils/PluginManager"; import { Hook, PluginManager } from "@server/utils/PluginManager";
import { requireDirectory } from "@server/utils/fs"; import { requireDirectory } from "@server/utils/fs";
import { createLazyRegistry } from "@server/utils/lazyRegistry";
import type BaseEmail from "./BaseEmail"; import type BaseEmail from "./BaseEmail";
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- registry of heterogeneous template Props subtypes; BaseEmail<EmailProps> isn't assignable from BaseEmail<Subtype>. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- registry of heterogeneous template Props subtypes; BaseEmail<EmailProps> isn't assignable from BaseEmail<Subtype>.
const emails: Record<string, typeof BaseEmail<any>> = {}; const emails = createLazyRegistry<typeof BaseEmail<any>>(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const registry: Record<string, typeof BaseEmail<any>> = {};
requireDirectory<{ default: typeof BaseEmail }>(__dirname).forEach( requireDirectory<{ default: typeof BaseEmail }>(__dirname).forEach(
([module, id]) => { ([module, id]) => {
if (id === "index") { if (id === "index") {
return; return;
} }
registry[id] = module.default;
emails[id] = module.default;
} }
); );
PluginManager.getHooks(Hook.EmailTemplate).forEach((hook) => { PluginManager.getHooks(Hook.EmailTemplate).forEach((hook) => {
emails[hook.value.name] = hook.value; registry[hook.value.name] = hook.value;
});
return registry;
}); });
export default emails; export default emails;
+30 -30
View File
@@ -21,12 +21,12 @@ describe("Authentication middleware", () => {
{ {
// @ts-expect-error mock request // @ts-expect-error mock request
request: { request: {
get: jest.fn(() => `Bearer ${user.getJwtToken()}`), get: vi.fn(() => `Bearer ${user.getJwtToken()}`),
}, },
state, state,
cache: {}, cache: {},
}, },
jest.fn() vi.fn()
); );
expect(state.auth.user.id).toEqual(user.id); expect(state.auth.user.id).toEqual(user.id);
}); });
@@ -41,12 +41,12 @@ describe("Authentication middleware", () => {
{ {
// @ts-expect-error mock request // @ts-expect-error mock request
request: { request: {
get: jest.fn(() => `Bearer ${user.getJwtToken()}error`), get: vi.fn(() => `Bearer ${user.getJwtToken()}error`),
}, },
state, state,
cache: {}, cache: {},
}, },
jest.fn() vi.fn()
); );
} catch (e) { } catch (e) {
expect(e.message).toBe("Invalid token"); expect(e.message).toBe("Invalid token");
@@ -65,12 +65,12 @@ describe("Authentication middleware", () => {
{ {
// @ts-expect-error mock request // @ts-expect-error mock request
request: { request: {
get: jest.fn(() => `Bearer ${user.getJwtToken()}`), get: vi.fn(() => `Bearer ${user.getJwtToken()}`),
}, },
state, state,
cache: {}, cache: {},
}, },
jest.fn() vi.fn()
); );
} catch (e) { } catch (e) {
expect(e.message).toBe("Invalid authentication type"); expect(e.message).toBe("Invalid authentication type");
@@ -88,12 +88,12 @@ describe("Authentication middleware", () => {
{ {
// @ts-expect-error mock request // @ts-expect-error mock request
request: { request: {
get: jest.fn(() => `Bearer ${key.value}`), get: vi.fn(() => `Bearer ${key.value}`),
}, },
state, state,
cache: {}, cache: {},
}, },
jest.fn() vi.fn()
); );
expect(state.auth.user.id).toEqual(user.id); expect(state.auth.user.id).toEqual(user.id);
}); });
@@ -112,12 +112,12 @@ describe("Authentication middleware", () => {
// @ts-expect-error mock request // @ts-expect-error mock request
request: { request: {
url: "/auth.info", url: "/auth.info",
get: jest.fn(() => `Bearer ${key.value}`), get: vi.fn(() => `Bearer ${key.value}`),
}, },
state, state,
cache: {}, cache: {},
}, },
jest.fn() vi.fn()
); );
expect(state.auth.user.id).toEqual(user.id); expect(state.auth.user.id).toEqual(user.id);
}); });
@@ -138,12 +138,12 @@ describe("Authentication middleware", () => {
// @ts-expect-error mock request // @ts-expect-error mock request
request: { request: {
url: "/documents.create", url: "/documents.create",
get: jest.fn(() => `Bearer ${key.value}`), get: vi.fn(() => `Bearer ${key.value}`),
}, },
state, state,
cache: {}, cache: {},
}, },
jest.fn() vi.fn()
); );
throw new Error("Expected error to be thrown"); throw new Error("Expected error to be thrown");
} catch (e) { } catch (e) {
@@ -162,12 +162,12 @@ describe("Authentication middleware", () => {
{ {
// @ts-expect-error mock request // @ts-expect-error mock request
request: { request: {
get: jest.fn(() => `Bearer ${randomString(38)}`), get: vi.fn(() => `Bearer ${randomString(38)}`),
}, },
state, state,
cache: {}, cache: {},
}, },
jest.fn() vi.fn()
); );
} catch (e) { } catch (e) {
expect(e.message).toBe("Invalid API key"); expect(e.message).toBe("Invalid API key");
@@ -191,12 +191,12 @@ describe("Authentication middleware", () => {
// @ts-expect-error mock request // @ts-expect-error mock request
request: { request: {
url: "/users.info", url: "/users.info",
get: jest.fn(() => `Bearer ${authentication.accessToken}`), get: vi.fn(() => `Bearer ${authentication.accessToken}`),
}, },
state, state,
cache: {}, cache: {},
}, },
jest.fn() vi.fn()
); );
expect(state.auth.user.id).toEqual(user.id); expect(state.auth.user.id).toEqual(user.id);
}); });
@@ -217,12 +217,12 @@ describe("Authentication middleware", () => {
// @ts-expect-error mock request // @ts-expect-error mock request
request: { request: {
url: "/documents.create", url: "/documents.create",
get: jest.fn(() => `Bearer ${authentication.accessToken}`), get: vi.fn(() => `Bearer ${authentication.accessToken}`),
}, },
state, state,
cache: {}, cache: {},
}, },
jest.fn() vi.fn()
); );
} catch (e) { } catch (e) {
expect(e.message).toContain("does not have access to this resource"); expect(e.message).toContain("does not have access to this resource");
@@ -244,7 +244,7 @@ describe("Authentication middleware", () => {
request: { request: {
url: "/users.info", url: "/users.info",
// @ts-expect-error mock request // @ts-expect-error mock request
get: jest.fn(() => null), get: vi.fn(() => null),
body: { body: {
token: authentication.accessToken, token: authentication.accessToken,
}, },
@@ -252,7 +252,7 @@ describe("Authentication middleware", () => {
state, state,
cache: {}, cache: {},
}, },
jest.fn() vi.fn()
); );
} catch (e) { } catch (e) {
expect(e.message).toContain( expect(e.message).toContain(
@@ -271,12 +271,12 @@ describe("Authentication middleware", () => {
{ {
// @ts-expect-error mock request // @ts-expect-error mock request
request: { request: {
get: jest.fn(() => "error"), get: vi.fn(() => "error"),
}, },
state, state,
cache: {}, cache: {},
}, },
jest.fn() vi.fn()
); );
} catch (e) { } catch (e) {
expect(e.message).toBe( expect(e.message).toBe(
@@ -293,7 +293,7 @@ describe("Authentication middleware", () => {
{ {
request: { request: {
// @ts-expect-error mock request // @ts-expect-error mock request
get: jest.fn(() => null), get: vi.fn(() => null),
query: { query: {
token: user.getJwtToken(), token: user.getJwtToken(),
}, },
@@ -301,7 +301,7 @@ describe("Authentication middleware", () => {
state, state,
cache: {}, cache: {},
}, },
jest.fn() vi.fn()
); );
expect(state.auth.user.id).toEqual(user.id); expect(state.auth.user.id).toEqual(user.id);
}); });
@@ -314,7 +314,7 @@ describe("Authentication middleware", () => {
{ {
request: { request: {
// @ts-expect-error mock request // @ts-expect-error mock request
get: jest.fn(() => null), get: vi.fn(() => null),
body: { body: {
token: user.getJwtToken(), token: user.getJwtToken(),
}, },
@@ -322,7 +322,7 @@ describe("Authentication middleware", () => {
state, state,
cache: {}, cache: {},
}, },
jest.fn() vi.fn()
); );
expect(state.auth.user.id).toEqual(user.id); expect(state.auth.user.id).toEqual(user.id);
}); });
@@ -342,12 +342,12 @@ describe("Authentication middleware", () => {
{ {
// @ts-expect-error mock request // @ts-expect-error mock request
request: { request: {
get: jest.fn(() => `Bearer ${user.getJwtToken()}`), get: vi.fn(() => `Bearer ${user.getJwtToken()}`),
}, },
state, state,
cache: {}, cache: {},
}, },
jest.fn() vi.fn()
); );
} catch (err) { } catch (err) {
error = err; error = err;
@@ -372,12 +372,12 @@ describe("Authentication middleware", () => {
{ {
// @ts-expect-error mock request // @ts-expect-error mock request
request: { request: {
get: jest.fn(() => `Bearer ${user.getJwtToken()}`), get: vi.fn(() => `Bearer ${user.getJwtToken()}`),
}, },
state, state,
cache: {}, cache: {},
}, },
jest.fn() vi.fn()
); );
} catch (err) { } catch (err) {
error = err; error = err;
+53 -57
View File
@@ -18,7 +18,7 @@ describe("rateLimiter middleware", () => {
afterEach(() => { afterEach(() => {
env.RATE_LIMITER_ENABLED = originalRateLimiterEnabled; env.RATE_LIMITER_ENABLED = originalRateLimiterEnabled;
env.RATE_LIMITER_MULTIPLIER = originalApiMultiplier; env.RATE_LIMITER_MULTIPLIER = originalApiMultiplier;
jest.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("should register and enforce custom rate limiter with matching paths (no mountPath)", async () => { it("should register and enforce custom rate limiter with matching paths (no mountPath)", async () => {
@@ -29,11 +29,11 @@ describe("rateLimiter middleware", () => {
path: "/documents.export", path: "/documents.export",
mountPath: undefined, mountPath: undefined,
ip: "127.0.0.1", ip: "127.0.0.1",
set: jest.fn(), set: vi.fn(),
request: {}, request: {},
} as unknown as Context; } as unknown as Context;
await registerMiddleware(mockCtx, jest.fn()); await registerMiddleware(mockCtx, vi.fn());
const registeredPath = "/documents.export"; const registeredPath = "/documents.export";
expect(RateLimiter.hasRateLimiter(registeredPath)).toBe(true); expect(RateLimiter.hasRateLimiter(registeredPath)).toBe(true);
@@ -51,11 +51,11 @@ describe("rateLimiter middleware", () => {
path: "/documents.export", path: "/documents.export",
mountPath: "/api", mountPath: "/api",
ip: "127.0.0.1", ip: "127.0.0.1",
set: jest.fn(), set: vi.fn(),
request: {}, request: {},
} as unknown as Context; } as unknown as Context;
await registerMiddleware(mockCtxRegister, jest.fn()); await registerMiddleware(mockCtxRegister, vi.fn());
const registrationPath = "/api/documents.export"; const registrationPath = "/api/documents.export";
expect(RateLimiter.hasRateLimiter(registrationPath)).toBe(true); expect(RateLimiter.hasRateLimiter(registrationPath)).toBe(true);
@@ -73,11 +73,11 @@ describe("rateLimiter middleware", () => {
path: "/documents.export", path: "/documents.export",
mountPath: undefined, mountPath: undefined,
ip: "127.0.0.1", ip: "127.0.0.1",
set: jest.fn(), set: vi.fn(),
request: {}, request: {},
} as unknown as Context; } as unknown as Context;
await registerMiddleware(mockCtx, jest.fn()); await registerMiddleware(mockCtx, vi.fn());
const limiter = RateLimiter.getRateLimiter("/documents.export"); const limiter = RateLimiter.getRateLimiter("/documents.export");
expect(limiter.points).toBe(10); expect(limiter.points).toBe(10);
@@ -91,11 +91,11 @@ describe("rateLimiter middleware", () => {
path: "/shares.subscribe", path: "/shares.subscribe",
mountPath: undefined, mountPath: undefined,
ip: "127.0.0.1", ip: "127.0.0.1",
set: jest.fn(), set: vi.fn(),
request: {}, request: {},
} as unknown as Context; } as unknown as Context;
await registerMiddleware(mockCtx, jest.fn()); await registerMiddleware(mockCtx, vi.fn());
const limiter = RateLimiter.getRateLimiter("/shares.subscribe"); const limiter = RateLimiter.getRateLimiter("/shares.subscribe");
expect(limiter.points).toBe(1); expect(limiter.points).toBe(1);
@@ -112,16 +112,16 @@ describe("rateLimiter middleware", () => {
describe("cache-keyed rate limiting", () => { describe("cache-keyed rate limiting", () => {
it("falls back to IP when no token is present", async () => { it("falls back to IP when no token is present", async () => {
const middleware = defaultRateLimiter(); const middleware = defaultRateLimiter();
const consumeSpy = jest const consumeSpy = vi
.spyOn(RateLimiter.defaultRateLimiter, "consume") .spyOn(RateLimiter.defaultRateLimiter, "consume")
.mockResolvedValue({} as never); .mockResolvedValue({} as never);
const cacheSpy = jest.spyOn(RateLimiter, "getCachedUserIdForToken"); const cacheSpy = vi.spyOn(RateLimiter, "getCachedUserIdForToken");
const mockCtx = { const mockCtx = {
path: "/some/path", path: "/some/path",
mountPath: undefined, mountPath: undefined,
ip: "192.168.1.1", ip: "192.168.1.1",
set: jest.fn(), set: vi.fn(),
request: { request: {
get: () => undefined, get: () => undefined,
body: {}, body: {},
@@ -130,7 +130,7 @@ describe("rateLimiter middleware", () => {
cookies: { get: () => undefined }, cookies: { get: () => undefined },
} as unknown as Context; } as unknown as Context;
await middleware(mockCtx, jest.fn()); await middleware(mockCtx, vi.fn());
expect(cacheSpy).not.toHaveBeenCalled(); expect(cacheSpy).not.toHaveBeenCalled();
expect(consumeSpy).toHaveBeenCalledWith("192.168.1.1"); expect(consumeSpy).toHaveBeenCalledWith("192.168.1.1");
@@ -139,22 +139,22 @@ describe("rateLimiter middleware", () => {
it("short-circuits to IP for API key tokens without hitting Redis or JWT verify", async () => { it("short-circuits to IP for API key tokens without hitting Redis or JWT verify", async () => {
const apiKeyToken = `${ApiKey.prefix}${"a".repeat(38)}`; const apiKeyToken = `${ApiKey.prefix}${"a".repeat(38)}`;
const middleware = defaultRateLimiter(); const middleware = defaultRateLimiter();
const consumeSpy = jest const consumeSpy = vi
.spyOn(RateLimiter.defaultRateLimiter, "consume") .spyOn(RateLimiter.defaultRateLimiter, "consume")
.mockResolvedValue({} as never); .mockResolvedValue({} as never);
const cacheReadSpy = jest.spyOn(RateLimiter, "getCachedUserIdForToken"); const cacheReadSpy = vi.spyOn(RateLimiter, "getCachedUserIdForToken");
const verifySpy = jest.spyOn(jwtUtils, "getUserForJWT"); const verifySpy = vi.spyOn(jwtUtils, "getUserForJWT");
const mockCtx = { const mockCtx = {
path: "/some/path", path: "/some/path",
mountPath: undefined, mountPath: undefined,
ip: "192.168.1.1", ip: "192.168.1.1",
set: jest.fn(), set: vi.fn(),
request: { get: () => `Bearer ${apiKeyToken}` }, request: { get: () => `Bearer ${apiKeyToken}` },
cookies: { get: () => undefined }, cookies: { get: () => undefined },
} as unknown as Context; } as unknown as Context;
await middleware(mockCtx, jest.fn()); await middleware(mockCtx, vi.fn());
expect(cacheReadSpy).not.toHaveBeenCalled(); expect(cacheReadSpy).not.toHaveBeenCalled();
expect(verifySpy).not.toHaveBeenCalled(); expect(verifySpy).not.toHaveBeenCalled();
@@ -163,29 +163,27 @@ describe("rateLimiter middleware", () => {
it("falls back to IP when token fails verification (forged or expired)", async () => { it("falls back to IP when token fails verification (forged or expired)", async () => {
const middleware = defaultRateLimiter(); const middleware = defaultRateLimiter();
const consumeSpy = jest const consumeSpy = vi
.spyOn(RateLimiter.defaultRateLimiter, "consume") .spyOn(RateLimiter.defaultRateLimiter, "consume")
.mockResolvedValue({} as never); .mockResolvedValue({} as never);
jest vi.spyOn(RateLimiter, "getCachedUserIdForToken").mockResolvedValue(null);
.spyOn(RateLimiter, "getCachedUserIdForToken") const cacheWriteSpy = vi
.mockResolvedValue(null);
const cacheWriteSpy = jest
.spyOn(RateLimiter, "cacheUserForToken") .spyOn(RateLimiter, "cacheUserForToken")
.mockResolvedValue(); .mockResolvedValue();
jest vi.spyOn(jwtUtils, "getUserForJWT").mockRejectedValue(
.spyOn(jwtUtils, "getUserForJWT") new Error("invalid token")
.mockRejectedValue(new Error("invalid token")); );
const mockCtx = { const mockCtx = {
path: "/some/path", path: "/some/path",
mountPath: undefined, mountPath: undefined,
ip: "192.168.1.1", ip: "192.168.1.1",
set: jest.fn(), set: vi.fn(),
request: { get: () => "Bearer forged-or-unknown-token" }, request: { get: () => "Bearer forged-or-unknown-token" },
cookies: { get: () => undefined }, cookies: { get: () => undefined },
} as unknown as Context; } as unknown as Context;
await middleware(mockCtx, jest.fn()); await middleware(mockCtx, vi.fn());
expect(consumeSpy).toHaveBeenCalledWith("192.168.1.1"); expect(consumeSpy).toHaveBeenCalledWith("192.168.1.1");
expect(cacheWriteSpy).not.toHaveBeenCalled(); expect(cacheWriteSpy).not.toHaveBeenCalled();
@@ -193,16 +191,14 @@ describe("rateLimiter middleware", () => {
it("verifies and caches the user on cache miss, then keys by user", async () => { it("verifies and caches the user on cache miss, then keys by user", async () => {
const middleware = defaultRateLimiter(); const middleware = defaultRateLimiter();
const consumeSpy = jest const consumeSpy = vi
.spyOn(RateLimiter.defaultRateLimiter, "consume") .spyOn(RateLimiter.defaultRateLimiter, "consume")
.mockResolvedValue({} as never); .mockResolvedValue({} as never);
jest vi.spyOn(RateLimiter, "getCachedUserIdForToken").mockResolvedValue(null);
.spyOn(RateLimiter, "getCachedUserIdForToken") const cacheWriteSpy = vi
.mockResolvedValue(null);
const cacheWriteSpy = jest
.spyOn(RateLimiter, "cacheUserForToken") .spyOn(RateLimiter, "cacheUserForToken")
.mockResolvedValue(); .mockResolvedValue();
jest.spyOn(jwtUtils, "getUserForJWT").mockResolvedValue({ vi.spyOn(jwtUtils, "getUserForJWT").mockResolvedValue({
user: { id: "user-abc" }, user: { id: "user-abc" },
} as never); } as never);
@@ -210,12 +206,12 @@ describe("rateLimiter middleware", () => {
path: "/some/path", path: "/some/path",
mountPath: undefined, mountPath: undefined,
ip: "192.168.1.1", ip: "192.168.1.1",
set: jest.fn(), set: vi.fn(),
request: { get: () => "Bearer valid-token" }, request: { get: () => "Bearer valid-token" },
cookies: { get: () => undefined }, cookies: { get: () => undefined },
} as unknown as Context; } as unknown as Context;
await middleware(mockCtx, jest.fn()); await middleware(mockCtx, vi.fn());
expect(cacheWriteSpy).toHaveBeenCalledWith("valid-token", "user-abc"); expect(cacheWriteSpy).toHaveBeenCalledWith("valid-token", "user-abc");
expect(consumeSpy).toHaveBeenCalledWith("user-abc"); expect(consumeSpy).toHaveBeenCalledWith("user-abc");
@@ -223,24 +219,24 @@ describe("rateLimiter middleware", () => {
it("keys on user id when token is in cache without re-verifying", async () => { it("keys on user id when token is in cache without re-verifying", async () => {
const middleware = defaultRateLimiter(); const middleware = defaultRateLimiter();
const consumeSpy = jest const consumeSpy = vi
.spyOn(RateLimiter.defaultRateLimiter, "consume") .spyOn(RateLimiter.defaultRateLimiter, "consume")
.mockResolvedValue({} as never); .mockResolvedValue({} as never);
jest vi.spyOn(RateLimiter, "getCachedUserIdForToken").mockResolvedValue(
.spyOn(RateLimiter, "getCachedUserIdForToken") "user-abc"
.mockResolvedValue("user-abc"); );
const verifySpy = jest.spyOn(jwtUtils, "getUserForJWT"); const verifySpy = vi.spyOn(jwtUtils, "getUserForJWT");
const mockCtx = { const mockCtx = {
path: "/some/path", path: "/some/path",
mountPath: undefined, mountPath: undefined,
ip: "192.168.1.1", ip: "192.168.1.1",
set: jest.fn(), set: vi.fn(),
request: { get: () => "Bearer verified-token" }, request: { get: () => "Bearer verified-token" },
cookies: { get: () => undefined }, cookies: { get: () => undefined },
} as unknown as Context; } as unknown as Context;
await middleware(mockCtx, jest.fn()); await middleware(mockCtx, vi.fn());
expect(verifySpy).not.toHaveBeenCalled(); expect(verifySpy).not.toHaveBeenCalled();
expect(consumeSpy).toHaveBeenCalledWith("user-abc"); expect(consumeSpy).toHaveBeenCalledWith("user-abc");
@@ -248,23 +244,23 @@ describe("rateLimiter middleware", () => {
it("falls back to IP when the cache lookup throws", async () => { it("falls back to IP when the cache lookup throws", async () => {
const middleware = defaultRateLimiter(); const middleware = defaultRateLimiter();
const consumeSpy = jest const consumeSpy = vi
.spyOn(RateLimiter.defaultRateLimiter, "consume") .spyOn(RateLimiter.defaultRateLimiter, "consume")
.mockResolvedValue({} as never); .mockResolvedValue({} as never);
jest vi.spyOn(RateLimiter, "getCachedUserIdForToken").mockRejectedValue(
.spyOn(RateLimiter, "getCachedUserIdForToken") new Error("redis down")
.mockRejectedValue(new Error("redis down")); );
const mockCtx = { const mockCtx = {
path: "/some/path", path: "/some/path",
mountPath: undefined, mountPath: undefined,
ip: "192.168.1.1", ip: "192.168.1.1",
set: jest.fn(), set: vi.fn(),
request: { get: () => "Bearer some-token" }, request: { get: () => "Bearer some-token" },
cookies: { get: () => undefined }, cookies: { get: () => undefined },
} as unknown as Context; } as unknown as Context;
await middleware(mockCtx, jest.fn()); await middleware(mockCtx, vi.fn());
expect(consumeSpy).toHaveBeenCalledWith("192.168.1.1"); expect(consumeSpy).toHaveBeenCalledWith("192.168.1.1");
}); });
@@ -275,30 +271,30 @@ describe("rateLimiter middleware", () => {
path: "/documents.export", path: "/documents.export",
mountPath: "/api", mountPath: "/api",
ip: "127.0.0.1", ip: "127.0.0.1",
set: jest.fn(), set: vi.fn(),
request: {}, request: {},
} as unknown as Context; } as unknown as Context;
await registerMiddleware(registerCtx, jest.fn()); await registerMiddleware(registerCtx, vi.fn());
const customLimiter = RateLimiter.getRateLimiter("/api/documents.export"); const customLimiter = RateLimiter.getRateLimiter("/api/documents.export");
const consumeSpy = jest const consumeSpy = vi
.spyOn(customLimiter, "consume") .spyOn(customLimiter, "consume")
.mockResolvedValue({} as never); .mockResolvedValue({} as never);
jest vi.spyOn(RateLimiter, "getCachedUserIdForToken").mockResolvedValue(
.spyOn(RateLimiter, "getCachedUserIdForToken") "user-abc"
.mockResolvedValue("user-abc"); );
const middleware = defaultRateLimiter(); const middleware = defaultRateLimiter();
const mockCtx = { const mockCtx = {
path: "/documents.export", path: "/documents.export",
mountPath: "/api", mountPath: "/api",
ip: "127.0.0.1", ip: "127.0.0.1",
set: jest.fn(), set: vi.fn(),
request: { get: () => "Bearer verified-token" }, request: { get: () => "Bearer verified-token" },
cookies: { get: () => undefined }, cookies: { get: () => undefined },
} as unknown as Context; } as unknown as Context;
await middleware(mockCtx, jest.fn()); await middleware(mockCtx, vi.fn());
expect(consumeSpy).toHaveBeenCalledWith("/api/documents.export:user-abc"); expect(consumeSpy).toHaveBeenCalledWith("/api/documents.export:user-abc");
}); });
+6 -6
View File
@@ -6,7 +6,7 @@ describe("Timeout middleware", () => {
const originalTimeout = 10000; const originalTimeout = 10000;
const newTimeout = 1800000; // 30 minutes const newTimeout = 1800000; // 30 minutes
const setTimeout = jest.fn(); const setTimeout = vi.fn();
const mockSocket = { const mockSocket = {
timeout: originalTimeout, timeout: originalTimeout,
setTimeout, setTimeout,
@@ -18,7 +18,7 @@ describe("Timeout middleware", () => {
}, },
}; };
const next = jest.fn(); const next = vi.fn();
const middleware = timeout(newTimeout); const middleware = timeout(newTimeout);
await middleware( await middleware(
@@ -39,7 +39,7 @@ describe("Timeout middleware", () => {
const originalTimeout = 10000; const originalTimeout = 10000;
const newTimeout = 1800000; // 30 minutes const newTimeout = 1800000; // 30 minutes
const setTimeout = jest.fn(); const setTimeout = vi.fn();
const mockSocket = { const mockSocket = {
timeout: originalTimeout, timeout: originalTimeout,
setTimeout, setTimeout,
@@ -52,7 +52,7 @@ describe("Timeout middleware", () => {
}; };
const error = new Error("Test error"); const error = new Error("Test error");
const next = jest.fn().mockRejectedValue(error); const next = vi.fn().mockRejectedValue(error);
const middleware = timeout(newTimeout); const middleware = timeout(newTimeout);
await expect( await expect(
@@ -74,7 +74,7 @@ describe("Timeout middleware", () => {
it("should handle undefined original timeout", async () => { it("should handle undefined original timeout", async () => {
const newTimeout = 1800000; // 30 minutes const newTimeout = 1800000; // 30 minutes
const setTimeout = jest.fn(); const setTimeout = vi.fn();
const mockSocket = { const mockSocket = {
timeout: undefined, timeout: undefined,
setTimeout, setTimeout,
@@ -86,7 +86,7 @@ describe("Timeout middleware", () => {
}, },
}; };
const next = jest.fn(); const next = vi.fn();
const middleware = timeout(newTimeout); const middleware = timeout(newTimeout);
await middleware( await middleware(
+2 -2
View File
@@ -13,7 +13,7 @@ import Collection from "./Collection";
import Document from "./Document"; import Document from "./Document";
beforeEach(() => { beforeEach(() => {
jest.resetAllMocks(); vi.resetAllMocks();
}); });
describe("#url", () => { describe("#url", () => {
@@ -311,7 +311,7 @@ describe("#removeDocument", () => {
const document = await buildDocument({ collectionId: collection.id }); const document = await buildDocument({ collectionId: collection.id });
await collection.reload(); await collection.reload();
const saveSpy = jest.spyOn(collection, "save"); const saveSpy = vi.spyOn(collection, "save");
await collection.deleteDocument(document); await collection.deleteDocument(document);
expect(saveSpy).toHaveBeenCalled(); expect(saveSpy).toHaveBeenCalled();
}); });
+1 -1
View File
@@ -15,7 +15,7 @@ import { withAPIContext } from "@server/test/support";
import UserMembership from "./UserMembership"; import UserMembership from "./UserMembership";
beforeEach(() => { beforeEach(() => {
jest.resetAllMocks(); vi.resetAllMocks();
}); });
describe("#getSummary", () => { describe("#getSummary", () => {
+13 -1
View File
@@ -13,7 +13,19 @@ import IdModel from "./base/IdModel";
import { SkipChangeset } from "./decorators/Changeset"; import { SkipChangeset } from "./decorators/Changeset";
import Fix from "./decorators/Fix"; import Fix from "./decorators/Fix";
@Table({ tableName: "document_insights", modelName: "documentInsight" }) @Table({
tableName: "document_insights",
modelName: "documentInsight",
indexes: [
{
fields: ["documentId", "date"],
unique: true,
},
{
fields: ["teamId", "date"],
},
],
})
@Fix @Fix
class DocumentInsight extends IdModel< class DocumentInsight extends IdModel<
InferAttributes<DocumentInsight>, InferAttributes<DocumentInsight>,
+11 -8
View File
@@ -16,7 +16,7 @@ describe("Team", () => {
}); });
it("should normalize domain to lowercase", async () => { it("should normalize domain to lowercase", async () => {
const id = randomUUID(); const id = randomUUID().split("-")[0];
const team = await buildTeam({ domain: `${id}.example.com` }); const team = await buildTeam({ domain: `${id}.example.com` });
const result = await Team.findByDomain(`${id}.Example.COM`); const result = await Team.findByDomain(`${id}.Example.COM`);
expect(result?.id).toEqual(team.id); expect(result?.id).toEqual(team.id);
@@ -70,21 +70,23 @@ describe("Team", () => {
describe("previousSubdomains", () => { describe("previousSubdomains", () => {
it("should list the previous subdomains", async () => { it("should list the previous subdomains", async () => {
const id = randomUUID();
const originalSubdomain = `example-${id}`;
const subdomain = `updated-${id}`;
const subdomain2 = `another-${id}`;
const team = await buildTeam({ const team = await buildTeam({
subdomain: "example", subdomain: originalSubdomain,
}); });
const subdomain = "updated";
await team.update({ subdomain }); await team.update({ subdomain });
expect(team.subdomain).toEqual(subdomain); expect(team.subdomain).toEqual(subdomain);
expect(team.previousSubdomains?.length).toEqual(1); expect(team.previousSubdomains?.length).toEqual(1);
expect(team.previousSubdomains?.[0]).toEqual("example"); expect(team.previousSubdomains?.[0]).toEqual(originalSubdomain);
const subdomain2 = "another";
await team.update({ subdomain: subdomain2 }); await team.update({ subdomain: subdomain2 });
expect(team.subdomain).toEqual(subdomain2); expect(team.subdomain).toEqual(subdomain2);
expect(team.previousSubdomains?.length).toEqual(2); expect(team.previousSubdomains?.length).toEqual(2);
expect(team.previousSubdomains?.[0]).toEqual("example"); expect(team.previousSubdomains?.[0]).toEqual(originalSubdomain);
expect(team.previousSubdomains?.[1]).toEqual(subdomain); expect(team.previousSubdomains?.[1]).toEqual(subdomain);
}); });
}); });
@@ -104,7 +106,8 @@ describe("Team", () => {
}); });
it("should return signed URL for private-bucket attachment redirect", async () => { it("should return signed URL for private-bucket attachment redirect", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-04-16T00:00:00.000Z")); vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-16T00:00:00.000Z"));
try { try {
const team = await buildTeam(); const team = await buildTeam();
const attachment = await buildAttachment({ const attachment = await buildAttachment({
@@ -119,7 +122,7 @@ describe("Team", () => {
const result = await team.publicAvatarUrl(); const result = await team.publicAvatarUrl();
expect(result).toEqual(await attachment.signedUrl); expect(result).toEqual(await attachment.signedUrl);
} finally { } finally {
jest.useRealTimers(); vi.useRealTimers();
} }
}); });
+3 -2
View File
@@ -15,11 +15,12 @@ import User from "./User";
import UserMembership from "./UserMembership"; import UserMembership from "./UserMembership";
beforeAll(() => { beforeAll(() => {
jest.useFakeTimers().setSystemTime(new Date("2018-01-02T00:00:00.000Z")); vi.useFakeTimers();
vi.setSystemTime(new Date("2018-01-02T00:00:00.000Z"));
}); });
afterAll(() => { afterAll(() => {
jest.useRealTimers(); vi.useRealTimers();
}); });
describe("user model", () => { describe("user model", () => {
+3 -3
View File
@@ -6,12 +6,12 @@ import { DocumentHelper } from "./DocumentHelper";
describe("DocumentHelper", () => { describe("DocumentHelper", () => {
beforeAll(() => { beforeAll(() => {
jest.useFakeTimers(); vi.useFakeTimers();
jest.setSystemTime(Date.parse("2021-01-01T00:00:00.000Z")); vi.setSystemTime(Date.parse("2021-01-01T00:00:00.000Z"));
}); });
afterAll(() => { afterAll(() => {
jest.useRealTimers(); vi.useRealTimers();
}); });
describe("replaceInternalUrls", () => { describe("replaceInternalUrls", () => {
@@ -7,7 +7,7 @@ import { buildProseMirrorDoc, buildUser } from "@server/test/factories";
import type { MentionAttrs } from "./ProsemirrorHelper"; import type { MentionAttrs } from "./ProsemirrorHelper";
import { ProsemirrorHelper } from "./ProsemirrorHelper"; import { ProsemirrorHelper } from "./ProsemirrorHelper";
jest.mock("@server/storage/files"); vi.mock("@server/storage/files");
describe("ProsemirrorHelper", () => { describe("ProsemirrorHelper", () => {
describe("processMentions", () => { describe("processMentions", () => {
+60 -3
View File
@@ -1,8 +1,12 @@
import emojiRegex from "emoji-regex"; import emojiRegex from "emoji-regex";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import { chunk, isMatch } from "es-toolkit/compat"; import { chunk, isMatch } from "es-toolkit/compat";
import { EditorState } from "prosemirror-state"; import { EditorState, type Plugin } from "prosemirror-state";
import { EditorView } from "prosemirror-view"; import {
DecorationSet,
EditorView,
type DecorationSource,
} from "prosemirror-view";
import { Node, Fragment } from "prosemirror-model"; import { Node, Fragment } from "prosemirror-model";
import { renderToString } from "react-dom/server"; import { renderToString } from "react-dom/server";
import styled, { ServerStyleSheet, ThemeProvider } from "styled-components"; import styled, { ServerStyleSheet, ThemeProvider } from "styled-components";
@@ -63,6 +67,30 @@ export type MentionAttrs = {
unfurl?: UnfurlResponse[keyof UnfurlResponse]; unfurl?: UnfurlResponse[keyof UnfurlResponse];
}; };
const pluginsWithSafeDecorations = new WeakSet<Plugin>();
function isDecorationSource(value: unknown): value is DecorationSource {
if (typeof value !== "object" || value === null) {
return false;
}
if (!("forChild" in value) || typeof value.forChild !== "function") {
return false;
}
if ("members" in value && Array.isArray(value.members)) {
return value.members.every(
(member) =>
typeof member === "object" &&
member !== null &&
"localsInner" in member &&
typeof member.localsInner === "function"
);
}
return true;
}
@trace() @trace()
export class ProsemirrorHelper extends SharedProsemirrorHelper { export class ProsemirrorHelper extends SharedProsemirrorHelper {
/** /**
@@ -513,10 +541,39 @@ export class ProsemirrorHelper extends SharedProsemirrorHelper {
const diffPlugins = options?.changes const diffPlugins = options?.changes
? new Diff({ changes: options.changes }).plugins ? new Diff({ changes: options.changes }).plugins
: []; : [];
const editorPlugins = [...plugins, ...diffPlugins];
for (const plugin of plugins) {
if (
!plugin.props.decorations ||
pluginsWithSafeDecorations.has(plugin)
) {
continue;
}
plugin.props.decorations = () => DecorationSet.empty;
pluginsWithSafeDecorations.add(plugin);
}
for (const plugin of diffPlugins) {
if (
!plugin.props.decorations ||
pluginsWithSafeDecorations.has(plugin)
) {
continue;
}
const decorations = plugin.props.decorations.bind(plugin);
plugin.props.decorations = (state) => {
const result = decorations(state);
return isDecorationSource(result) ? result : DecorationSet.empty;
};
pluginsWithSafeDecorations.add(plugin);
}
const state = EditorState.create({ const state = EditorState.create({
doc: node, doc: node,
plugins: [...plugins, ...diffPlugins], plugins: editorPlugins,
schema, schema,
}); });
+1 -1
View File
@@ -2,7 +2,7 @@ import { createContext } from "@server/context";
import { buildProseMirrorDoc, buildUser } from "@server/test/factories"; import { buildProseMirrorDoc, buildUser } from "@server/test/factories";
import { ProsemirrorHelper } from "./ProsemirrorHelper"; import { ProsemirrorHelper } from "./ProsemirrorHelper";
jest.mock("@server/storage/files"); vi.mock("@server/storage/files");
describe("ProsemirrorHelper", () => { describe("ProsemirrorHelper", () => {
describe("replaceImagesWithAttachments", () => { describe("replaceImagesWithAttachments", () => {
+10 -9
View File
@@ -1,20 +1,21 @@
import type Koa from "koa"; import type Koa from "koa";
import type { Mock } from "vitest";
import { requestErrorHandler } from "@server/logging/sentry"; import { requestErrorHandler } from "@server/logging/sentry";
import { InternalError, ValidationError, NotFoundError } from "./errors"; import { InternalError, ValidationError, NotFoundError } from "./errors";
import onerror from "./onerror"; import onerror from "./onerror";
// Mock the requestErrorHandler from Sentry // Mock the requestErrorHandler from Sentry
jest.mock("@server/logging/sentry", () => ({ vi.mock("@server/logging/sentry", () => ({
requestErrorHandler: jest.fn(), requestErrorHandler: vi.fn(),
})); }));
type MockCtx = { type MockCtx = {
headers: Record<string, string>; headers: Record<string, string>;
headerSent: boolean; headerSent: boolean;
writable: boolean; writable: boolean;
accepts: jest.Mock; accepts: Mock;
set: jest.Mock; set: Mock;
res: { end: jest.Mock }; res: { end: Mock };
status: number | undefined; status: number | undefined;
type: string | undefined; type: string | undefined;
body: unknown; body: unknown;
@@ -43,10 +44,10 @@ describe("onerror", () => {
headers: {}, headers: {},
headerSent: false, headerSent: false,
writable: true, writable: true,
accepts: jest.fn(() => "json"), accepts: vi.fn(() => "json"),
set: jest.fn(), set: vi.fn(),
res: { res: {
end: jest.fn(), end: vi.fn(),
}, },
status: undefined, status: undefined,
type: undefined, type: undefined,
@@ -54,7 +55,7 @@ describe("onerror", () => {
}; };
// Clear mock calls // Clear mock calls
(requestErrorHandler as jest.Mock).mockClear(); (requestErrorHandler as Mock).mockClear();
}); });
it("should report InternalError to Sentry", () => { it("should report InternalError to Sentry", () => {
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`presents a user 1`] = ` exports[`presents a user 1`] = `
{ {
@@ -42,7 +42,7 @@ describe("SearchIndexProcessor", () => {
}); });
const provider = SearchProviderManager.getProvider(); const provider = SearchProviderManager.getProvider();
const indexSpy = jest.spyOn(provider, "index"); const indexSpy = vi.spyOn(provider, "index");
await processor.perform({ await processor.perform({
name: "documents.publish", name: "documents.publish",
@@ -63,7 +63,7 @@ describe("SearchIndexProcessor", () => {
it("should call provider.remove for documents.permanent_delete", async () => { it("should call provider.remove for documents.permanent_delete", async () => {
const user = await buildUser(); const user = await buildUser();
const provider = SearchProviderManager.getProvider(); const provider = SearchProviderManager.getProvider();
const removeSpy = jest.spyOn(provider, "remove"); const removeSpy = vi.spyOn(provider, "remove");
await processor.perform({ await processor.perform({
name: "documents.permanent_delete", name: "documents.permanent_delete",
+5 -3
View File
@@ -1,11 +1,12 @@
import { Hook, PluginManager } from "@server/utils/PluginManager"; import { Hook, PluginManager } from "@server/utils/PluginManager";
import { requireDirectory } from "@server/utils/fs"; import { requireDirectory } from "@server/utils/fs";
import { createLazyRegistry } from "@server/utils/lazyRegistry";
import type BaseProcessor from "./BaseProcessor"; import type BaseProcessor from "./BaseProcessor";
const processors: Record<string, typeof BaseProcessor> = {};
const AbstractProcessors = ["ImportsProcessor"]; const AbstractProcessors = ["ImportsProcessor"];
const processors = createLazyRegistry(() => {
const processors: Record<string, typeof BaseProcessor> = {};
requireDirectory<{ default: typeof BaseProcessor }>(__dirname).forEach( requireDirectory<{ default: typeof BaseProcessor }>(__dirname).forEach(
([module, id]) => { ([module, id]) => {
if (id === "index" || AbstractProcessors.includes(id)) { if (id === "index" || AbstractProcessors.includes(id)) {
@@ -14,9 +15,10 @@ requireDirectory<{ default: typeof BaseProcessor }>(__dirname).forEach(
processors[id] = module.default; processors[id] = module.default;
} }
); );
PluginManager.getHooks(Hook.Processor).forEach((hook) => { PluginManager.getHooks(Hook.Processor).forEach((hook) => {
processors[hook.value.name] = hook.value; processors[hook.value.name] = hook.value;
}); });
return processors;
});
export default processors; export default processors;
@@ -13,12 +13,12 @@ import DocumentPublishedNotificationsTask from "./DocumentPublishedNotifications
const ip = "127.0.0.1"; const ip = "127.0.0.1";
beforeEach(async () => { beforeEach(async () => {
jest.resetAllMocks(); vi.resetAllMocks();
}); });
describe("documents.publish", () => { describe("documents.publish", () => {
test("should not send a notification to author", async () => { test("should not send a notification to author", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
const user = await buildUser(); const user = await buildUser();
const document = await buildDocument({ const document = await buildDocument({
teamId: user.teamId, teamId: user.teamId,
@@ -40,7 +40,7 @@ describe("documents.publish", () => {
}); });
test("should send a notification to other users in team", async () => { test("should send a notification to other users in team", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
const user = await buildUser(); const user = await buildUser();
const document = await buildDocument({ const document = await buildDocument({
teamId: user.teamId, teamId: user.teamId,
@@ -61,7 +61,7 @@ describe("documents.publish", () => {
}); });
test("should send only one notification in a 12-hour window", async () => { test("should send only one notification in a 12-hour window", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
const user = await buildUser(); const user = await buildUser();
const document = await buildDocument({ const document = await buildDocument({
teamId: user.teamId, teamId: user.teamId,
@@ -98,7 +98,7 @@ describe("documents.publish", () => {
}); });
test("should not send a notification to users without collection access", async () => { test("should not send a notification to users without collection access", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
const user = await buildUser(); const user = await buildUser();
const collection = await buildCollection({ const collection = await buildCollection({
teamId: user.teamId, teamId: user.teamId,
@@ -124,7 +124,7 @@ describe("documents.publish", () => {
}); });
test("should not send a notification for group mentions when disableMentions is true", async () => { test("should not send a notification for group mentions when disableMentions is true", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
const actor = await buildUser(); const actor = await buildUser();
const group = await buildGroup({ const group = await buildGroup({
teamId: actor.teamId, teamId: actor.teamId,
@@ -12,12 +12,12 @@ import GroupMentionedInCommentNotificationsTask from "./GroupMentionedInCommentN
const ip = "127.0.0.1"; const ip = "127.0.0.1";
beforeEach(async () => { beforeEach(async () => {
jest.resetAllMocks(); vi.resetAllMocks();
}); });
describe("GroupMentionedInCommentNotificationsTask", () => { describe("GroupMentionedInCommentNotificationsTask", () => {
it("should send notifications to all group members with access", async () => { it("should send notifications to all group members with access", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
const actor = await buildUser(); const actor = await buildUser();
const document = await buildDocument({ const document = await buildDocument({
teamId: actor.teamId, teamId: actor.teamId,
@@ -91,7 +91,7 @@ describe("GroupMentionedInCommentNotificationsTask", () => {
}); });
it("should not send notification to actor", async () => { it("should not send notification to actor", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
const actor = await buildUser(); const actor = await buildUser();
const document = await buildDocument({ const document = await buildDocument({
teamId: actor.teamId, teamId: actor.teamId,
@@ -133,7 +133,7 @@ describe("GroupMentionedInCommentNotificationsTask", () => {
}); });
it("should not send notification if group has mentions disabled", async () => { it("should not send notification if group has mentions disabled", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
const actor = await buildUser(); const actor = await buildUser();
const document = await buildDocument({ const document = await buildDocument({
teamId: actor.teamId, teamId: actor.teamId,
@@ -177,7 +177,7 @@ describe("GroupMentionedInCommentNotificationsTask", () => {
}); });
it("should not send notification to users without subscription", async () => { it("should not send notification to users without subscription", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
const actor = await buildUser(); const actor = await buildUser();
const document = await buildDocument({ const document = await buildDocument({
teamId: actor.teamId, teamId: actor.teamId,
@@ -222,7 +222,7 @@ describe("GroupMentionedInCommentNotificationsTask", () => {
}); });
it("should handle large groups with batching", async () => { it("should handle large groups with batching", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
const actor = await buildUser(); const actor = await buildUser();
const document = await buildDocument({ const document = await buildDocument({
teamId: actor.teamId, teamId: actor.teamId,
@@ -269,7 +269,7 @@ describe("GroupMentionedInCommentNotificationsTask", () => {
}); });
it("should not send notification if document does not exist", async () => { it("should not send notification if document does not exist", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
const actor = await buildUser(); const actor = await buildUser();
const document = await buildDocument({ const document = await buildDocument({
teamId: actor.teamId, teamId: actor.teamId,
+1 -1
View File
@@ -32,7 +32,7 @@ function mockHandle(fileOperation: FileOperation) {
}; };
}, },
}); });
jest.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation); vi.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
} }
describe("ImportJSONTask", () => { describe("ImportJSONTask", () => {
@@ -22,7 +22,7 @@ describe("ImportMarkdownZipTask", () => {
}; };
}, },
}); });
jest.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation); vi.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
const props = { const props = {
fileOperationId: fileOperation.id, fileOperationId: fileOperation.id,
@@ -53,7 +53,7 @@ describe("ImportMarkdownZipTask", () => {
}; };
}, },
}); });
jest.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation); vi.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
const props = { const props = {
fileOperationId: fileOperation.id, fileOperationId: fileOperation.id,
@@ -84,7 +84,7 @@ describe("ImportMarkdownZipTask", () => {
}; };
}, },
}); });
jest.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation); vi.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
const props = { const props = {
fileOperationId: fileOperation.id, fileOperationId: fileOperation.id,
@@ -118,7 +118,7 @@ describe("ImportMarkdownZipTask", () => {
}; };
}, },
}); });
jest.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation); vi.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
const props = { const props = {
fileOperationId: fileOperation.id, fileOperationId: fileOperation.id,
@@ -5,7 +5,7 @@ import InviteReminderTask from "./InviteReminderTask";
describe("InviteReminderTask", () => { describe("InviteReminderTask", () => {
it("should send reminder emails", async () => { it("should send reminder emails", async () => {
const spy = jest.spyOn(InviteReminderEmail.prototype, "schedule"); const spy = vi.spyOn(InviteReminderEmail.prototype, "schedule");
// too old // too old
await buildInvite({ await buildInvite({
@@ -23,7 +23,7 @@ import RevisionCreatedNotificationsTask from "./RevisionCreatedNotificationsTask
const ip = "127.0.0.1"; const ip = "127.0.0.1";
beforeEach(async () => { beforeEach(async () => {
jest.resetAllMocks(); vi.resetAllMocks();
}); });
function updateDocumentText(document: Document, text: string) { function updateDocumentText(document: Document, text: string) {
@@ -34,7 +34,7 @@ function updateDocumentText(document: Document, text: string) {
describe("revisions.create", () => { describe("revisions.create", () => {
test("should send a notification to other collaborators", async () => { test("should send a notification to other collaborators", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
const user = await buildUser(); const user = await buildUser();
let document = await buildDocument({ let document = await buildDocument({
teamId: user.teamId, teamId: user.teamId,
@@ -64,7 +64,7 @@ describe("revisions.create", () => {
}); });
test("should not send a notification if viewed since update", async () => { test("should not send a notification if viewed since update", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
const user = await buildUser(); const user = await buildUser();
let document = await buildDocument({ let document = await buildDocument({
teamId: user.teamId, teamId: user.teamId,
@@ -99,7 +99,7 @@ describe("revisions.create", () => {
}); });
test("should not send a notification to last editor", async () => { test("should not send a notification to last editor", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
const user = await buildUser(); const user = await buildUser();
let document = await buildDocument({ let document = await buildDocument({
teamId: user.teamId, teamId: user.teamId,
@@ -125,7 +125,7 @@ describe("revisions.create", () => {
}); });
test("should send a notification for subscriptions, even to collaborator", async () => { test("should send a notification for subscriptions, even to collaborator", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
const user = await buildUser(); const user = await buildUser();
let document = await buildDocument({ let document = await buildDocument({
teamId: user.teamId, teamId: user.teamId,
@@ -225,7 +225,7 @@ describe("revisions.create", () => {
}); });
test("should not send multiple emails", async () => { test("should not send multiple emails", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
const collaborator0 = await buildUser(); const collaborator0 = await buildUser();
const collaborator1 = await buildUser({ teamId: collaborator0.teamId }); const collaborator1 = await buildUser({ teamId: collaborator0.teamId });
const collaborator2 = await buildUser({ teamId: collaborator0.teamId }); const collaborator2 = await buildUser({ teamId: collaborator0.teamId });
@@ -265,7 +265,7 @@ describe("revisions.create", () => {
}); });
test("should not create subscriptions if previously unsubscribed", async () => { test("should not create subscriptions if previously unsubscribed", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
const collaborator0 = await buildUser(); const collaborator0 = await buildUser();
const collaborator1 = await buildUser({ teamId: collaborator0.teamId }); const collaborator1 = await buildUser({ teamId: collaborator0.teamId });
const collaborator2 = await buildUser({ teamId: collaborator0.teamId }); const collaborator2 = await buildUser({ teamId: collaborator0.teamId });
@@ -333,7 +333,7 @@ describe("revisions.create", () => {
}); });
test("should send a notification for subscriptions to non-collaborators", async () => { test("should send a notification for subscriptions to non-collaborators", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
let document = await buildDocument(); let document = await buildDocument();
const collaborator = await buildUser({ teamId: document.teamId }); const collaborator = await buildUser({ teamId: document.teamId });
const subscriber = await buildUser({ teamId: document.teamId }); const subscriber = await buildUser({ teamId: document.teamId });
@@ -375,7 +375,7 @@ describe("revisions.create", () => {
}); });
test("should not send a notification for subscriptions to collaborators if unsubscribed", async () => { test("should not send a notification for subscriptions to collaborators if unsubscribed", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
let document = await buildDocument(); let document = await buildDocument();
const collaborator = await buildUser({ teamId: document.teamId }); const collaborator = await buildUser({ teamId: document.teamId });
@@ -421,7 +421,7 @@ describe("revisions.create", () => {
}); });
test("should not send a notification for subscriptions to members outside of the team", async () => { test("should not send a notification for subscriptions to members outside of the team", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
let document = await buildDocument(); let document = await buildDocument();
const collaborator = await buildUser({ teamId: document.teamId }); const collaborator = await buildUser({ teamId: document.teamId });
@@ -470,7 +470,7 @@ describe("revisions.create", () => {
}); });
test("should not send a notification if viewed since update", async () => { test("should not send a notification if viewed since update", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
const document = await buildDocument(); const document = await buildDocument();
const collaborator = await buildUser({ teamId: document.teamId }); const collaborator = await buildUser({ teamId: document.teamId });
@@ -500,7 +500,7 @@ describe("revisions.create", () => {
}); });
test("should not send a notification to last editor", async () => { test("should not send a notification to last editor", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
const user = await buildUser(); const user = await buildUser();
const document = await buildDocument({ const document = await buildDocument({
@@ -525,7 +525,7 @@ describe("revisions.create", () => {
}); });
test("should send a mention notification even when change is below threshold", async () => { test("should send a mention notification even when change is below threshold", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
const actor = await buildUser(); const actor = await buildUser();
const mentioned = await buildUser({ teamId: actor.teamId, name: "Kim" }); const mentioned = await buildUser({ teamId: actor.teamId, name: "Kim" });
@@ -590,7 +590,7 @@ describe("revisions.create", () => {
}); });
test("should not send a notification for group mentions when disableMentions is true", async () => { test("should not send a notification for group mentions when disableMentions is true", async () => {
const spy = jest.spyOn(Notification, "create"); const spy = vi.spyOn(Notification, "create");
const actor = await buildUser(); const actor = await buildUser();
const group = await buildGroup({ const group = await buildGroup({
teamId: actor.teamId, teamId: actor.teamId,
@@ -16,16 +16,14 @@ const props = {
}, },
}; };
vi.setConfig({ testTimeout: 30000 });
const daysAgo = (n: number) => subDays(new Date(), n); const daysAgo = (n: number) => subDays(new Date(), n);
const dayStr = (d: Date) => format(d, "yyyy-MM-dd"); const dayStr = (d: Date) => format(d, "yyyy-MM-dd");
describe("RollupDocumentInsightsTask", () => { describe("RollupDocumentInsightsTask", () => {
let task: RollupDocumentInsightsTask; let task: RollupDocumentInsightsTask;
beforeAll(() => {
jest.setTimeout(30000);
});
beforeEach(() => { beforeEach(() => {
task = new RollupDocumentInsightsTask(); task = new RollupDocumentInsightsTask();
}); });
@@ -8,12 +8,12 @@ import ShareSubscriptionNotificationsTask from "./ShareSubscriptionNotifications
const ip = "127.0.0.1"; const ip = "127.0.0.1";
beforeEach(() => { beforeEach(() => {
jest.restoreAllMocks(); vi.restoreAllMocks();
}); });
describe("ShareSubscriptionNotificationsTask", () => { describe("ShareSubscriptionNotificationsTask", () => {
it("should send email to confirmed subscriber", async () => { it("should send email to confirmed subscriber", async () => {
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule"); const spy = vi.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const document = await buildDocument(); const document = await buildDocument();
const share = await buildShare({ const share = await buildShare({
@@ -43,7 +43,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
}); });
it("should not send email to unconfirmed subscriber", async () => { it("should not send email to unconfirmed subscriber", async () => {
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule"); const spy = vi.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const document = await buildDocument(); const document = await buildDocument();
const share = await buildShare({ const share = await buildShare({
@@ -72,7 +72,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
}); });
it("should not send email to unsubscribed subscriber", async () => { it("should not send email to unsubscribed subscriber", async () => {
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule"); const spy = vi.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const document = await buildDocument(); const document = await buildDocument();
const share = await buildShare({ const share = await buildShare({
@@ -103,7 +103,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
}); });
it("should throttle notifications to once per 6 hours", async () => { it("should throttle notifications to once per 6 hours", async () => {
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule"); const spy = vi.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const document = await buildDocument(); const document = await buildDocument();
const share = await buildShare({ const share = await buildShare({
@@ -134,7 +134,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
}); });
it("should send if last notified more than 6 hours ago", async () => { it("should send if last notified more than 6 hours ago", async () => {
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule"); const spy = vi.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const document = await buildDocument(); const document = await buildDocument();
const share = await buildShare({ const share = await buildShare({
@@ -165,7 +165,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
}); });
it("should not send for unpublished shares", async () => { it("should not send for unpublished shares", async () => {
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule"); const spy = vi.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const document = await buildDocument(); const document = await buildDocument();
const share = await buildShare({ const share = await buildShare({
@@ -196,7 +196,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
}); });
it("should update lastNotifiedAt after sending", async () => { it("should update lastNotifiedAt after sending", async () => {
jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule"); vi.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const document = await buildDocument(); const document = await buildDocument();
const share = await buildShare({ const share = await buildShare({
@@ -229,7 +229,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
}); });
it("should send to multiple subscribers", async () => { it("should send to multiple subscribers", async () => {
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule"); const spy = vi.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const document = await buildDocument(); const document = await buildDocument();
const share = await buildShare({ const share = await buildShare({
@@ -268,7 +268,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
}); });
it("should not send if document has no shares", async () => { it("should not send if document has no shares", async () => {
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule"); const spy = vi.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const document = await buildDocument(); const document = await buildDocument();
const task = new ShareSubscriptionNotificationsTask(); const task = new ShareSubscriptionNotificationsTask();
@@ -285,7 +285,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
}); });
it("should send when child document is updated and subscription is scoped to parent", async () => { it("should send when child document is updated and subscription is scoped to parent", async () => {
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule"); const spy = vi.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const parent = await buildDocument(); const parent = await buildDocument();
const child = await buildDocument({ const child = await buildDocument({
@@ -321,7 +321,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
}); });
it("should not send when updated document is outside subscription scope", async () => { it("should not send when updated document is outside subscription scope", async () => {
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule"); const spy = vi.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const parent = await buildDocument(); const parent = await buildDocument();
const sibling = await buildDocument({ const sibling = await buildDocument({
@@ -17,32 +17,30 @@ const props = {
}, },
}; };
vi.setConfig({ testTimeout: 30000 });
describe("UpdateDocumentsPopularityScoreTask", () => { describe("UpdateDocumentsPopularityScoreTask", () => {
let task: UpdateDocumentsPopularityScoreTask; let task: UpdateDocumentsPopularityScoreTask;
beforeAll(() => {
jest.setTimeout(30000);
});
beforeEach(() => { beforeEach(() => {
task = new UpdateDocumentsPopularityScoreTask(); task = new UpdateDocumentsPopularityScoreTask();
jest.spyOn(Date.prototype, "getHours").mockReturnValue(0); vi.spyOn(Date.prototype, "getHours").mockReturnValue(0);
// Ensure calculation query sees data created in tests by redirecting to main sequelize instance. // Ensure calculation query sees data created in tests by redirecting to main sequelize instance.
// We only mock if the instances are different to avoid infinite recursion. // We only mock if the instances are different to avoid infinite recursion.
if (sequelizeReadOnly !== sequelize) { if (sequelizeReadOnly !== sequelize) {
jest vi.spyOn(sequelizeReadOnly, "query").mockImplementation(
.spyOn(sequelizeReadOnly, "query") sequelize.query.bind(sequelize)
.mockImplementation(sequelize.query.bind(sequelize)); );
} }
}); });
afterEach(() => { afterEach(() => {
jest.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("should skip execution if not at a 6-hour interval", async () => { it("should skip execution if not at a 6-hour interval", async () => {
jest.spyOn(Date.prototype, "getHours").mockReturnValue(1); vi.spyOn(Date.prototype, "getHours").mockReturnValue(1);
const team = await buildTeam(); const team = await buildTeam();
const document = await buildDocument({ const document = await buildDocument({
teamId: team.id, teamId: team.id,
+4 -2
View File
@@ -1,9 +1,10 @@
import { Hook, PluginManager } from "@server/utils/PluginManager"; import { Hook, PluginManager } from "@server/utils/PluginManager";
import { requireDirectory } from "@server/utils/fs"; import { requireDirectory } from "@server/utils/fs";
import { createLazyRegistry } from "@server/utils/lazyRegistry";
import type { BaseTask } from "./base/BaseTask"; import type { BaseTask } from "./base/BaseTask";
const tasks = createLazyRegistry(() => {
const tasks: Record<string, typeof BaseTask> = {}; const tasks: Record<string, typeof BaseTask> = {};
requireDirectory<{ default: typeof BaseTask }>(__dirname).forEach( requireDirectory<{ default: typeof BaseTask }>(__dirname).forEach(
([module, id]) => { ([module, id]) => {
if (id === "index") { if (id === "index") {
@@ -12,9 +13,10 @@ requireDirectory<{ default: typeof BaseTask }>(__dirname).forEach(
tasks[id] = module.default; tasks[id] = module.default;
} }
); );
PluginManager.getHooks(Hook.Task).forEach((hook) => { PluginManager.getHooks(Hook.Task).forEach((hook) => {
tasks[hook.value.name] = hook.value; tasks[hook.value.name] = hook.value;
}); });
return tasks;
});
export default tasks; export default tasks;
@@ -12,7 +12,7 @@ import {
} from "@server/test/factories"; } from "@server/test/factories";
import { getTestServer } from "@server/test/support"; import { getTestServer } from "@server/test/support";
jest.mock("@server/storage/files"); vi.mock("@server/storage/files");
const server = getTestServer(); const server = getTestServer();
+1 -1
View File
@@ -5,7 +5,7 @@ import { getTestServer, setSelfHosted } from "@server/test/support";
const mockTeamInSessionId = randomUUID(); const mockTeamInSessionId = randomUUID();
jest.mock("@server/utils/authentication", () => ({ vi.mock("@server/utils/authentication", () => ({
getSessionsInCookie() { getSessionsInCookie() {
return { [mockTeamInSessionId]: {} }; return { [mockTeamInSessionId]: {} };
}, },
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`#collections.add_group should require group in team 1`] = ` exports[`#collections.add_group > should require group in team 1`] = `
{ {
"error": "authorization_error", "error": "authorization_error",
"message": "Authorization error", "message": "Authorization error",
@@ -9,7 +9,7 @@ exports[`#collections.add_group should require group in team 1`] = `
} }
`; `;
exports[`#collections.add_user should not allow add self 1`] = ` exports[`#collections.add_user > should not allow add self 1`] = `
{ {
"error": "authorization_error", "error": "authorization_error",
"message": "Authorization error", "message": "Authorization error",
@@ -18,7 +18,7 @@ exports[`#collections.add_user should not allow add self 1`] = `
} }
`; `;
exports[`#collections.add_user should require user in team 1`] = ` exports[`#collections.add_user > should require user in team 1`] = `
{ {
"error": "authorization_error", "error": "authorization_error",
"message": "Authorization error", "message": "Authorization error",
@@ -27,7 +27,7 @@ exports[`#collections.add_user should require user in team 1`] = `
} }
`; `;
exports[`#collections.create should require authentication 1`] = ` exports[`#collections.create > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -36,7 +36,7 @@ exports[`#collections.create should require authentication 1`] = `
} }
`; `;
exports[`#collections.delete should require authentication 1`] = ` exports[`#collections.delete > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -45,7 +45,7 @@ exports[`#collections.delete should require authentication 1`] = `
} }
`; `;
exports[`#collections.export should require authentication 1`] = ` exports[`#collections.export > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -54,7 +54,7 @@ exports[`#collections.export should require authentication 1`] = `
} }
`; `;
exports[`#collections.export_all should require authentication 1`] = ` exports[`#collections.export_all > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -63,7 +63,7 @@ exports[`#collections.export_all should require authentication 1`] = `
} }
`; `;
exports[`#collections.group_memberships should require authentication 1`] = ` exports[`#collections.group_memberships > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -72,7 +72,7 @@ exports[`#collections.group_memberships should require authentication 1`] = `
} }
`; `;
exports[`#collections.import should require authentication 1`] = ` exports[`#collections.import > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -81,7 +81,7 @@ exports[`#collections.import should require authentication 1`] = `
} }
`; `;
exports[`#collections.info should require authentication 1`] = ` exports[`#collections.info > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -90,7 +90,7 @@ exports[`#collections.info should require authentication 1`] = `
} }
`; `;
exports[`#collections.list should require authentication 1`] = ` exports[`#collections.list > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -99,7 +99,7 @@ exports[`#collections.list should require authentication 1`] = `
} }
`; `;
exports[`#collections.memberships should require authentication 1`] = ` exports[`#collections.memberships > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -108,7 +108,7 @@ exports[`#collections.memberships should require authentication 1`] = `
} }
`; `;
exports[`#collections.move should require authentication 1`] = ` exports[`#collections.move > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -117,7 +117,7 @@ exports[`#collections.move should require authentication 1`] = `
} }
`; `;
exports[`#collections.remove_group should require group in team 1`] = ` exports[`#collections.remove_group > should require group in team 1`] = `
{ {
"error": "authorization_error", "error": "authorization_error",
"message": "Authorization error", "message": "Authorization error",
@@ -126,7 +126,7 @@ exports[`#collections.remove_group should require group in team 1`] = `
} }
`; `;
exports[`#collections.remove_user should require user in team 1`] = ` exports[`#collections.remove_user > should require user in team 1`] = `
{ {
"error": "authorization_error", "error": "authorization_error",
"message": "Authorization error", "message": "Authorization error",
@@ -135,7 +135,7 @@ exports[`#collections.remove_user should require user in team 1`] = `
} }
`; `;
exports[`#collections.update should require authentication 1`] = ` exports[`#collections.update > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`#comments.add_reaction should require authentication 1`] = ` exports[`#comments.add_reaction > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -9,7 +9,7 @@ exports[`#comments.add_reaction should require authentication 1`] = `
} }
`; `;
exports[`#comments.create should require authentication 1`] = ` exports[`#comments.create > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -18,7 +18,7 @@ exports[`#comments.create should require authentication 1`] = `
} }
`; `;
exports[`#comments.info should require authentication 1`] = ` exports[`#comments.info > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -27,7 +27,7 @@ exports[`#comments.info should require authentication 1`] = `
} }
`; `;
exports[`#comments.list should require authentication 1`] = ` exports[`#comments.list > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -36,7 +36,7 @@ exports[`#comments.list should require authentication 1`] = `
} }
`; `;
exports[`#comments.remove_reaction should require authentication 1`] = ` exports[`#comments.remove_reaction > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -45,7 +45,7 @@ exports[`#comments.remove_reaction should require authentication 1`] = `
} }
`; `;
exports[`#comments.resolve should require authentication 1`] = ` exports[`#comments.resolve > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -54,7 +54,7 @@ exports[`#comments.resolve should require authentication 1`] = `
} }
`; `;
exports[`#comments.unresolve should require authentication 1`] = ` exports[`#comments.unresolve > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -63,7 +63,7 @@ exports[`#comments.unresolve should require authentication 1`] = `
} }
`; `;
exports[`#comments.update should require authentication 1`] = ` exports[`#comments.update > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`#documents.create should error with invalid parentDocument 1`] = ` exports[`#documents.create > should error with invalid parentDocument 1`] = `
{ {
"error": "authorization_error", "error": "authorization_error",
"message": "Authorization error", "message": "Authorization error",
@@ -9,7 +9,7 @@ exports[`#documents.create should error with invalid parentDocument 1`] = `
} }
`; `;
exports[`#documents.delete should require authentication 1`] = ` exports[`#documents.delete > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -18,7 +18,7 @@ exports[`#documents.delete should require authentication 1`] = `
} }
`; `;
exports[`#documents.documents should require authentication 1`] = ` exports[`#documents.documents > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -27,7 +27,7 @@ exports[`#documents.documents should require authentication 1`] = `
} }
`; `;
exports[`#documents.documents should return 403 if user does not have access to the document 1`] = ` exports[`#documents.documents > should return 403 if user does not have access to the document 1`] = `
{ {
"error": "authorization_error", "error": "authorization_error",
"message": "Authorization error", "message": "Authorization error",
@@ -36,7 +36,7 @@ exports[`#documents.documents should return 403 if user does not have access to
} }
`; `;
exports[`#documents.empty_trash should not allow non-admin users 1`] = ` exports[`#documents.empty_trash > should not allow non-admin users 1`] = `
{ {
"error": "authorization_error", "error": "authorization_error",
"message": "Admin role required", "message": "Admin role required",
@@ -45,7 +45,7 @@ exports[`#documents.empty_trash should not allow non-admin users 1`] = `
} }
`; `;
exports[`#documents.empty_trash should require authentication 1`] = ` exports[`#documents.empty_trash > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -54,7 +54,7 @@ exports[`#documents.empty_trash should require authentication 1`] = `
} }
`; `;
exports[`#documents.list should require authentication 1`] = ` exports[`#documents.list > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -63,7 +63,7 @@ exports[`#documents.list should require authentication 1`] = `
} }
`; `;
exports[`#documents.restore should require authentication 1`] = ` exports[`#documents.restore > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -72,7 +72,7 @@ exports[`#documents.restore should require authentication 1`] = `
} }
`; `;
exports[`#documents.search should require authentication 1`] = ` exports[`#documents.search > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication error", "message": "Authentication error",
@@ -81,7 +81,7 @@ exports[`#documents.search should require authentication 1`] = `
} }
`; `;
exports[`#documents.update should require authentication 1`] = ` exports[`#documents.update > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -90,7 +90,7 @@ exports[`#documents.update should require authentication 1`] = `
} }
`; `;
exports[`#documents.update should require text while appending 1`] = ` exports[`#documents.update > should require text while appending 1`] = `
{ {
"error": "validation_error", "error": "validation_error",
"message": "text is required when using append, prepend, or editMode", "message": "text is required when using append, prepend, or editMode",
@@ -99,7 +99,7 @@ exports[`#documents.update should require text while appending 1`] = `
} }
`; `;
exports[`#documents.viewed should require authentication 1`] = ` exports[`#documents.viewed > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -3335,11 +3335,11 @@ describe("#documents.import", () => {
collectionId: collection.id, collectionId: collection.id,
}); });
jest vi.spyOn(FileStorage, "store").mockResolvedValue(
.spyOn(FileStorage, "store") undefined as unknown as string
.mockResolvedValue(undefined as unknown as string); );
jest.spyOn(DocumentImportTask.prototype, "schedule").mockResolvedValue({ vi.spyOn(DocumentImportTask.prototype, "schedule").mockResolvedValue({
finished: jest.fn().mockResolvedValue({ documentId: document.id }), finished: vi.fn().mockResolvedValue({ documentId: document.id }),
} as unknown as Awaited<ReturnType<DocumentImportTask["schedule"]>>); } as unknown as Awaited<ReturnType<DocumentImportTask["schedule"]>>);
const content = await readFile( const content = await readFile(
@@ -3366,7 +3366,7 @@ describe("#documents.import", () => {
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(body.data.id).toEqual(document.id); expect(body.data.id).toEqual(document.id);
jest.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("should require authentication", async () => { it("should require authentication", async () => {
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`#events.list should require authentication 1`] = ` exports[`#events.list > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -12,7 +12,7 @@ import { getTestServer } from "@server/test/support";
const server = getTestServer(); const server = getTestServer();
jest.mock("@server/storage/files"); vi.mock("@server/storage/files");
describe("#fileOperations.info", () => { describe("#fileOperations.info", () => {
it("should return fileOperation", async () => { it("should return fileOperation", async () => {
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`#groups.add_user should require admin 1`] = ` exports[`#groups.add_user > should require admin 1`] = `
{ {
"error": "authorization_error", "error": "authorization_error",
"message": "Authorization error", "message": "Authorization error",
@@ -9,7 +9,7 @@ exports[`#groups.add_user should require admin 1`] = `
} }
`; `;
exports[`#groups.add_user should require user in team 1`] = ` exports[`#groups.add_user > should require user in team 1`] = `
{ {
"error": "authorization_error", "error": "authorization_error",
"message": "Authorization error", "message": "Authorization error",
@@ -18,7 +18,7 @@ exports[`#groups.add_user should require user in team 1`] = `
} }
`; `;
exports[`#groups.delete should require authentication 1`] = ` exports[`#groups.delete > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -27,7 +27,7 @@ exports[`#groups.delete should require authentication 1`] = `
} }
`; `;
exports[`#groups.info should require authentication 1`] = ` exports[`#groups.info > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -36,7 +36,7 @@ exports[`#groups.info should require authentication 1`] = `
} }
`; `;
exports[`#groups.list should require authentication 1`] = ` exports[`#groups.list > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -45,7 +45,7 @@ exports[`#groups.list should require authentication 1`] = `
} }
`; `;
exports[`#groups.memberships should require authentication 1`] = ` exports[`#groups.memberships > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -54,7 +54,7 @@ exports[`#groups.memberships should require authentication 1`] = `
} }
`; `;
exports[`#groups.remove_user should require admin 1`] = ` exports[`#groups.remove_user > should require admin 1`] = `
{ {
"error": "authorization_error", "error": "authorization_error",
"message": "Authorization error", "message": "Authorization error",
@@ -63,7 +63,7 @@ exports[`#groups.remove_user should require admin 1`] = `
} }
`; `;
exports[`#groups.remove_user should require user in team 1`] = ` exports[`#groups.remove_user > should require user in team 1`] = `
{ {
"error": "authorization_error", "error": "authorization_error",
"message": "Authorization error", "message": "Authorization error",
@@ -72,7 +72,7 @@ exports[`#groups.remove_user should require user in team 1`] = `
} }
`; `;
exports[`#groups.update should require authentication 1`] = ` exports[`#groups.update > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -81,7 +81,7 @@ exports[`#groups.update should require authentication 1`] = `
} }
`; `;
exports[`#groups.update when checking for noop updates fails with validation error when name already taken 1`] = ` exports[`#groups.update > when checking for noop updates > fails with validation error when name already taken 1`] = `
{ {
"error": "validation_error", "error": "validation_error",
"message": "The name of this group is already in use (isUniqueNameInTeam)", "message": "The name of this group is already in use (isUniqueNameInTeam)",
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`oauthAuthentications.delete should require authentication 1`] = ` exports[`oauthAuthentications.delete > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -9,7 +9,7 @@ exports[`oauthAuthentications.delete should require authentication 1`] = `
} }
`; `;
exports[`oauthAuthentications.list should require authentication 1`] = ` exports[`oauthAuthentications.list > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`oauthClients.create should require authentication 1`] = ` exports[`oauthClients.create > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -9,7 +9,7 @@ exports[`oauthClients.create should require authentication 1`] = `
} }
`; `;
exports[`oauthClients.delete should require authentication 1`] = ` exports[`oauthClients.delete > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -18,7 +18,7 @@ exports[`oauthClients.delete should require authentication 1`] = `
} }
`; `;
exports[`oauthClients.info should require authentication 1`] = ` exports[`oauthClients.info > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -27,7 +27,7 @@ exports[`oauthClients.info should require authentication 1`] = `
} }
`; `;
exports[`oauthClients.list should require authentication 1`] = ` exports[`oauthClients.list > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -36,7 +36,7 @@ exports[`oauthClients.list should require authentication 1`] = `
} }
`; `;
exports[`oauthClients.rotate_secret should require authentication 1`] = ` exports[`oauthClients.rotate_secret > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -45,7 +45,7 @@ exports[`oauthClients.rotate_secret should require authentication 1`] = `
} }
`; `;
exports[`oauthclients.update should require authentication 1`] = ` exports[`oauthclients.update > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`#reactions.list should require authentication 1`] = ` exports[`#reactions.list > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`#shares.create should require authentication 1`] = ` exports[`#shares.create > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -9,7 +9,7 @@ exports[`#shares.create should require authentication 1`] = `
} }
`; `;
exports[`#shares.list should require authentication 1`] = ` exports[`#shares.list > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -18,7 +18,7 @@ exports[`#shares.list should require authentication 1`] = `
} }
`; `;
exports[`#shares.revoke should require authentication 1`] = ` exports[`#shares.revoke > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -27,7 +27,7 @@ exports[`#shares.revoke should require authentication 1`] = `
} }
`; `;
exports[`#shares.update should require authentication 1`] = ` exports[`#shares.update > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
+25 -8
View File
@@ -1,3 +1,4 @@
import type { Mock } from "vitest";
import { UnfurlResourceType } from "@shared/types"; import { UnfurlResourceType } from "@shared/types";
import env from "@server/env"; import env from "@server/env";
import type { User } from "@server/models"; import type { User } from "@server/models";
@@ -10,22 +11,38 @@ import {
import { getTestServer } from "@server/test/support"; import { getTestServer } from "@server/test/support";
import Iframely from "plugins/iframely/server/iframely"; import Iframely from "plugins/iframely/server/iframely";
jest.mock("dns", () => ({ const resolveCname = vi.hoisted(
resolveCname: ( () =>
(
input: string, input: string,
callback: (err: Error | null, addresses: string[]) => void callback: (err: Error | null, addresses: string[]) => void
) => { ) => {
if (input.includes("valid.custom.domain")) { if (input.includes("valid.custom.domain")) {
callback(null, ["secure.outline.dev"]); callback(null, ["secure.outline.dev"]);
} else { return;
}
callback(null, []); callback(null, []);
} }
);
vi.mock("node:dns", () => ({
default: {
resolveCname,
}, },
resolveCname,
})); }));
jest vi.mock("dns", () => ({
.spyOn(Iframely, "requestResource") default: {
.mockImplementation(() => Promise.resolve({})); resolveCname,
},
resolveCname,
}));
vi.spyOn(Iframely, "requestResource").mockImplementation(() =>
Promise.resolve({})
);
const server = getTestServer(); const server = getTestServer();
@@ -287,7 +304,7 @@ describe("#urls.unfurl", () => {
}); });
it("should succeed with status 200 ok for a valid external url", async () => { it("should succeed with status 200 ok for a valid external url", async () => {
(Iframely.requestResource as jest.Mock).mockResolvedValue( (Iframely.requestResource as Mock).mockResolvedValue(
Promise.resolve({ Promise.resolve({
url: "https://www.flickr.com", url: "https://www.flickr.com",
type: "rich", type: "rich",
@@ -343,7 +360,7 @@ describe("#urls.unfurl", () => {
}); });
it("should succeed with status 204 no content for a non-existing external url", async () => { it("should succeed with status 204 no content for a non-existing external url", async () => {
(Iframely.requestResource as jest.Mock).mockResolvedValue( (Iframely.requestResource as Mock).mockResolvedValue(
Promise.resolve({ Promise.resolve({
status: 404, status: 404,
error: error:
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`#users.activate should require admin 1`] = ` exports[`#users.activate > should require admin 1`] = `
{ {
"error": "authorization_error", "error": "authorization_error",
"message": "Authorization error", "message": "Authorization error",
@@ -9,7 +9,7 @@ exports[`#users.activate should require admin 1`] = `
} }
`; `;
exports[`#users.delete should require authentication 1`] = ` exports[`#users.delete > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -18,7 +18,7 @@ exports[`#users.delete should require authentication 1`] = `
} }
`; `;
exports[`#users.demote should not allow demoting self 1`] = ` exports[`#users.demote > should not allow demoting self 1`] = `
{ {
"error": "validation_error", "error": "validation_error",
"message": "You cannot change your own role", "message": "You cannot change your own role",
@@ -27,7 +27,7 @@ exports[`#users.demote should not allow demoting self 1`] = `
} }
`; `;
exports[`#users.demote should require admin 1`] = ` exports[`#users.demote > should require admin 1`] = `
{ {
"error": "authorization_error", "error": "authorization_error",
"message": "Admin role required", "message": "Admin role required",
@@ -36,7 +36,7 @@ exports[`#users.demote should require admin 1`] = `
} }
`; `;
exports[`#users.promote should require admin 1`] = ` exports[`#users.promote > should require admin 1`] = `
{ {
"error": "authorization_error", "error": "authorization_error",
"message": "Admin role required", "message": "Admin role required",
@@ -45,7 +45,7 @@ exports[`#users.promote should require admin 1`] = `
} }
`; `;
exports[`#users.suspend should not allow suspending self 1`] = ` exports[`#users.suspend > should not allow suspending self 1`] = `
{ {
"error": "authorization_error", "error": "authorization_error",
"message": "Authorization error", "message": "Authorization error",
@@ -54,7 +54,7 @@ exports[`#users.suspend should not allow suspending self 1`] = `
} }
`; `;
exports[`#users.suspend should require admin 1`] = ` exports[`#users.suspend > should require admin 1`] = `
{ {
"error": "authorization_error", "error": "authorization_error",
"message": "Authorization error", "message": "Authorization error",
@@ -63,7 +63,7 @@ exports[`#users.suspend should require admin 1`] = `
} }
`; `;
exports[`#users.update should require authentication 1`] = ` exports[`#users.update > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -72,7 +72,7 @@ exports[`#users.update should require authentication 1`] = `
} }
`; `;
exports[`#users.updateEmail post should fail if email not in allowed domains 1`] = ` exports[`#users.updateEmail > post > should fail if email not in allowed domains 1`] = `
{ {
"error": "validation_error", "error": "validation_error",
"message": "The domain is not allowed for this workspace", "message": "The domain is not allowed for this workspace",
@@ -81,7 +81,7 @@ exports[`#users.updateEmail post should fail if email not in allowed domains 1`]
} }
`; `;
exports[`#users.updateEmail post should fail if email not unique in workspace 1`] = ` exports[`#users.updateEmail > post > should fail if email not unique in workspace 1`] = `
{ {
"error": "validation_error", "error": "validation_error",
"message": "User with email already exists", "message": "User with email already exists",
@@ -90,7 +90,7 @@ exports[`#users.updateEmail post should fail if email not unique in workspace 1`
} }
`; `;
exports[`#users.updateEmail post should require authentication 1`] = ` exports[`#users.updateEmail > post > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
+3 -3
View File
@@ -14,10 +14,10 @@ import { getTestServer } from "@server/test/support";
const server = getTestServer(); const server = getTestServer();
beforeAll(() => { beforeAll(() => {
jest.useFakeTimers().setSystemTime(new Date("2018-01-02T00:00:00.000Z")); vi.useFakeTimers().setSystemTime(new Date("2018-01-02T00:00:00.000Z"));
}); });
afterAll(() => { afterAll(() => {
jest.useRealTimers(); vi.useRealTimers();
}); });
describe("#users.list", () => { describe("#users.list", () => {
@@ -766,7 +766,7 @@ describe("#users.update", () => {
describe("#users.updateEmail", () => { describe("#users.updateEmail", () => {
describe("post", () => { describe("post", () => {
it("should trigger verification email", async () => { it("should trigger verification email", async () => {
const spy = jest.spyOn(ConfirmUpdateEmail.prototype, "schedule"); const spy = vi.spyOn(ConfirmUpdateEmail.prototype, "schedule");
const user = await buildUser(); const user = await buildUser();
const res = await server.post("/api/users.updateEmail", { const res = await server.post("/api/users.updateEmail", {
body: { body: {
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`#views.create should require authentication 1`] = ` exports[`#views.create > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
@@ -9,7 +9,7 @@ exports[`#views.create should require authentication 1`] = `
} }
`; `;
exports[`#views.list should require authentication 1`] = ` exports[`#views.list > should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication required",
-4
View File
@@ -233,10 +233,6 @@ export function createMigrationRunner(
* See https://github.com/sequelize/sequelize/issues/14807#issuecomment-1854398131 * See https://github.com/sequelize/sequelize/issues/14807#issuecomment-1854398131
*/ */
export function monkeyPatchSequelizeErrorsForJest(instance: Sequelize) { export function monkeyPatchSequelizeErrorsForJest(instance: Sequelize) {
if (typeof jest === "undefined") {
return instance;
}
const sequelizeVersion = (Sequelize as unknown as { version: string }) const sequelizeVersion = (Sequelize as unknown as { version: string })
.version; .version;
const major = sequelizeVersion.split(".").map(Number)[0]; const major = sequelizeVersion.split(".").map(Number)[0];
+7 -5
View File
@@ -1,11 +1,13 @@
import { vi } from "vitest";
export default { export default {
upload: jest.fn().mockReturnValue("/endpoint/key"), upload: vi.fn().mockReturnValue("/endpoint/key"),
getUploadUrl: jest.fn().mockReturnValue("http://mock/create"), getUploadUrl: vi.fn().mockReturnValue("http://mock/create"),
getUrlForKey: jest.fn().mockReturnValue("http://mock/get"), getUrlForKey: vi.fn().mockReturnValue("http://mock/get"),
getSignedUrl: jest.fn().mockReturnValue("http://s3mock"), getSignedUrl: vi.fn().mockReturnValue("http://s3mock"),
getPresignedPost: jest.fn().mockReturnValue({}), getPresignedPost: vi.fn().mockReturnValue({}),
}; };
-7
View File
@@ -1,7 +0,0 @@
import { sequelize } from "@server/storage/database";
module.exports = async function (opts) {
if (!opts.watch && !opts.watchAll) {
await sequelize.close();
}
};
+6
View File
@@ -0,0 +1,6 @@
export default function setup() {
return async () => {
const { sequelize } = await import("@server/storage/database");
await sequelize.close();
};
}
+11
View File
@@ -0,0 +1,11 @@
import { http, passthrough } from "msw";
import { setupServer } from "msw/node";
// Pass-through handlers for in-process supertest requests. Registered as
// initial handlers so they survive server.resetHandlers() between tests.
const passthroughLocalhost = http.all(
/^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?(\/|$)/,
() => passthrough()
);
export const server = setupServer(passthroughLocalhost);
+37 -32
View File
@@ -1,55 +1,60 @@
import "reflect-metadata"; import "reflect-metadata";
import { EventEmitter } from "node:events";
import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest";
import sharedEnv from "@shared/env"; import sharedEnv from "@shared/env";
import env from "@server/env"; import env from "@server/env";
import { EventEmitter } from "node:events"; import { server } from "./msw";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Increase the default max listeners for EventEmitter to prevent warnings in tests // Increase the default max listeners for EventEmitter to prevent warnings in tests
// This needs to be done before any modules that use EventEmitter are loaded // This needs to be done before any modules that use EventEmitter are loaded
EventEmitter.defaultMaxListeners = 100; EventEmitter.defaultMaxListeners = 100;
// Save native Response/Request before jest-fetch-mock replaces them with
// cross-fetch polyfills that don't support Web Streams (e.g. ReadableStream
// bodies lose their getReader method). The MCP SDK's @hono/node-server adapter
// depends on proper Web Streams support.
const NativeResponse = globalThis.Response;
const NativeRequest = globalThis.Request;
const NativeHeaders = globalThis.Headers;
// Enable fetch mocks for testing
require("jest-fetch-mock").enableMocks();
fetchMock.dontMock();
// Restore native Web API classes
globalThis.Response = NativeResponse;
globalThis.Request = NativeRequest;
globalThis.Headers = NativeHeaders;
// Mock AWS SDK S3 client and related commands // Mock AWS SDK S3 client and related commands
jest.mock("@aws-sdk/client-s3", () => ({ vi.mock("@aws-sdk/client-s3", () => ({
S3Client: jest.fn(() => ({ S3Client: vi.fn(() => ({
send: jest.fn(), send: vi.fn(),
})), })),
DeleteObjectCommand: jest.fn(), DeleteObjectCommand: vi.fn(),
GetObjectCommand: jest.fn(), GetObjectCommand: vi.fn(),
ObjectCannedACL: {}, ObjectCannedACL: {},
})); }));
jest.mock("@aws-sdk/lib-storage", () => ({ vi.mock("@aws-sdk/lib-storage", () => ({
Upload: jest.fn(() => ({ Upload: vi.fn(() => ({
done: jest.fn(), done: vi.fn(),
})), })),
})); }));
jest.mock("@aws-sdk/s3-presigned-post", () => ({ vi.mock("@aws-sdk/s3-presigned-post", () => ({
createPresignedPost: jest.fn(), createPresignedPost: vi.fn(),
})); }));
jest.mock("@aws-sdk/s3-request-presigner", () => ({ vi.mock("@aws-sdk/s3-request-presigner", () => ({
getSignedUrl: jest.fn(), getSignedUrl: vi.fn(),
})); }));
// Initialize the database models // Initialize the database models. Loaded dynamically so the
require("@server/storage/database"); // EventEmitter.defaultMaxListeners assignment above runs first; static imports
// would be hoisted ahead of it.
await import("@server/storage/database");
// Eagerly load plugin server entry points so that PluginManager.getHooks()
// returns the registered plugins. Vitest does not support require() of TS
// files with bare imports (e.g. `@server/...`), so we use Vite's
// import.meta.glob to load them through the Vite resolver instead.
const { PluginManager } = await import("@server/utils/PluginManager");
const pluginModules = import.meta.glob(
"../../plugins/*/server/!(*.test|schema).{js,ts}",
{ eager: true }
);
void pluginModules;
// Mark as loaded so PluginManager.loadPlugins() (which uses require()) is a
// no-op during tests.
(PluginManager as unknown as { loaded: boolean }).loaded = true;
beforeEach(() => { beforeEach(() => {
env.URL = sharedEnv.URL = "https://app.outline.dev"; env.URL = sharedEnv.URL = "https://app.outline.dev";
-11
View File
@@ -1,11 +0,0 @@
// This file runs before the test environment is set up to ensure mocks are registered early
// It prevents real Redis clients from being initialized during module imports
// Mock ioredis with ioredis-mock before any imports
jest.mock("ioredis", () => require("ioredis-mock"));
// Mock other Redis-dependent modules
jest.mock("@server/utils/MutexLock");
// Mock AWS SDK signature module to prevent aws_logger open handle
jest.mock("@aws-sdk/signature-v4-crt", () => ({}));
+56
View File
@@ -0,0 +1,56 @@
// This file runs before the test environment is set up to ensure mocks are registered early.
// It prevents real Redis clients from being initialized during module imports.
import { vi } from "vitest";
import type * as IORedisMock from "ioredis-mock";
import { __setRequireDirectoryCache } from "@server/utils/fs";
// Pre-populate the requireDirectory cache used by @server/utils/fs so that
// tasks/processors/email-templates can be looked up via their pre-loaded
// modules instead of via Node's require(), which cannot resolve TypeScript
// files with aliased imports under Vitest. The eager globs intentionally
// exclude index.ts files (which call requireDirectory themselves and would
// recurse) and any files whose imports would themselves load the directory
// they live in.
__setRequireDirectoryCache(
"emails/templates",
import.meta.glob("../emails/templates/!(index|*.test).{js,ts}", {
eager: true,
})
);
__setRequireDirectoryCache(
"queues/processors",
import.meta.glob("../queues/processors/!(index|*.test).{js,ts}", {
eager: true,
})
);
__setRequireDirectoryCache(
"queues/tasks",
import.meta.glob("../queues/tasks/!(index|*.test).{js,ts}", {
eager: true,
})
);
vi.mock("ioredis", async () => {
const mod = await vi.importActual<typeof IORedisMock>("ioredis-mock");
return mod;
});
vi.mock("@server/utils/MutexLock");
vi.mock("@aws-sdk/signature-v4-crt", () => ({}));
// Auto-mock these modules using the corresponding files under server/__mocks__/.
// In Jest, __mocks__ next to a `roots` directory is auto-applied; vitest
// requires an explicit vi.mock() call to wire them up.
vi.mock("bull", () => import("../__mocks__/bull"));
vi.mock("dd-trace", async () => {
const mod = await import("../__mocks__/dd-trace");
return { default: mod.mockTracer, ...mod };
});
vi.mock("franc", () => import("../__mocks__/franc"));
vi.mock("iso-639-3", () => import("../__mocks__/iso-639-3"));
vi.mock(
"request-filtering-agent",
() => import("../__mocks__/request-filtering-agent")
);
+22
View File
@@ -1,10 +1,12 @@
import { faker } from "@faker-js/faker"; import { faker } from "@faker-js/faker";
import type { Transaction } from "sequelize"; import type { Transaction } from "sequelize";
import { afterEach, beforeEach, vi } from "vitest";
import sharedEnv from "@shared/env"; import sharedEnv from "@shared/env";
import { createContext } from "@server/context"; import { createContext } from "@server/context";
import env from "@server/env"; import env from "@server/env";
import type { User } from "@server/models"; import type { User } from "@server/models";
import onerror from "@server/onerror"; import onerror from "@server/onerror";
import { BaseTask } from "@server/queues/tasks/base/BaseTask";
import webService from "@server/services/web"; import webService from "@server/services/web";
import { sequelize } from "@server/storage/database"; import { sequelize } from "@server/storage/database";
import type { APIContext } from "@server/types"; import type { APIContext } from "@server/types";
@@ -33,6 +35,26 @@ export function setSelfHosted() {
env.URL = sharedEnv.URL = `https://${faker.internet.domainName()}`; env.URL = sharedEnv.URL = `https://${faker.internet.domainName()}`;
} }
/**
* Mock scheduling for all task subclasses in the current test file.
*
* @returns the schedule mock for assertions.
*/
export function mockTaskSchedule() {
const schedule = vi.fn<BaseTask<object>["schedule"]>();
beforeEach(() => {
schedule.mockReset();
vi.spyOn(BaseTask.prototype, "schedule").mockImplementation(schedule);
});
afterEach(() => {
vi.restoreAllMocks();
});
return schedule;
}
export function withAPIContext<T>( export function withAPIContext<T>(
user: User, user: User,
fn: (ctx: APIContext) => T fn: (ctx: APIContext) => T
+17 -2
View File
@@ -1,14 +1,29 @@
import { vi } from "vitest";
export class MutexLock { export class MutexLock {
// Default expiry time for acquiring lock in milliseconds // Default expiry time for acquiring lock in milliseconds
public static defaultLockTimeout = 4000; public static defaultLockTimeout = 4000;
/**
* Acquires a mock lock.
*/
public static acquire = vi.fn().mockResolvedValue({
release: vi.fn().mockResolvedValue(true),
expiration: Date.now() + 10000,
});
/**
* Releases a mock lock.
*/
public static release = vi.fn().mockResolvedValue(true);
/** /**
* Returns the mock redlock instance * Returns the mock redlock instance
*/ */
public static get lock() { public static get lock() {
return { return {
acquire: jest.fn().mockResolvedValue({ acquire: vi.fn().mockResolvedValue({
release: jest.fn().mockResolvedValue(true), release: vi.fn().mockResolvedValue(true),
expiration: Date.now() + 10000, expiration: Date.now() + 10000,
}), }),
}; };
+51 -54
View File
@@ -1,9 +1,24 @@
import fetchMock from "jest-fetch-mock"; import { http, HttpResponse } from "msw";
import { server } from "@server/test/msw";
import { checkEmbeddability, convertBareUrlsToEmbedMarkdown } from "./embeds"; import { checkEmbeddability, convertBareUrlsToEmbedMarkdown } from "./embeds";
beforeEach(() => { const embedUrl = "https://www.example.com/embed";
fetchMock.resetMocks();
}); const mockEmbedResponse = (
url: string,
init: { status?: number; headers?: Record<string, string> } = {}
) => {
server.use(
http.get(
url,
() =>
new HttpResponse(null, {
status: init.status ?? 200,
headers: init.headers ?? {},
})
)
);
};
describe("checkEmbeddability", () => { describe("checkEmbeddability", () => {
describe("when URL doesn't match any embed pattern", () => { describe("when URL doesn't match any embed pattern", () => {
@@ -21,55 +36,48 @@ describe("checkEmbeddability", () => {
describe("when URL matches an embed pattern", () => { describe("when URL matches an embed pattern", () => {
it("should return embeddable: true when no restrictive headers", async () => { it("should return embeddable: true when no restrictive headers", async () => {
fetchMock.mockResponseOnce("", { mockEmbedResponse(embedUrl);
status: 200,
headers: {},
});
const result = await checkEmbeddability("https://www.example.com/embed"); const result = await checkEmbeddability(embedUrl);
expect(result).toEqual({ embeddable: true }); expect(result).toEqual({ embeddable: true });
}); });
it("should return embeddable: false when X-Frame-Options: DENY", async () => { it("should return embeddable: false when X-Frame-Options: DENY", async () => {
fetchMock.mockResponseOnce("", { mockEmbedResponse(embedUrl, {
status: 200,
headers: { "X-Frame-Options": "DENY" }, headers: { "X-Frame-Options": "DENY" },
}); });
const result = await checkEmbeddability("https://www.example.com/embed"); const result = await checkEmbeddability(embedUrl);
expect(result).toEqual({ embeddable: false, reason: "x-frame-options" }); expect(result).toEqual({ embeddable: false, reason: "x-frame-options" });
}); });
it("should return embeddable: false when X-Frame-Options: SAMEORIGIN", async () => { it("should return embeddable: false when X-Frame-Options: SAMEORIGIN", async () => {
fetchMock.mockResponseOnce("", { mockEmbedResponse(embedUrl, {
status: 200,
headers: { "X-Frame-Options": "SAMEORIGIN" }, headers: { "X-Frame-Options": "SAMEORIGIN" },
}); });
const result = await checkEmbeddability("https://www.example.com/embed"); const result = await checkEmbeddability(embedUrl);
expect(result).toEqual({ embeddable: false, reason: "x-frame-options" }); expect(result).toEqual({ embeddable: false, reason: "x-frame-options" });
}); });
it("should return embeddable: false when X-Frame-Options: ALLOW-FROM", async () => { it("should return embeddable: false when X-Frame-Options: ALLOW-FROM", async () => {
fetchMock.mockResponseOnce("", { mockEmbedResponse(embedUrl, {
status: 200,
headers: { "X-Frame-Options": "ALLOW-FROM https://example.com" }, headers: { "X-Frame-Options": "ALLOW-FROM https://example.com" },
}); });
const result = await checkEmbeddability("https://www.example.com/embed"); const result = await checkEmbeddability(embedUrl);
expect(result).toEqual({ embeddable: false, reason: "x-frame-options" }); expect(result).toEqual({ embeddable: false, reason: "x-frame-options" });
}); });
it("should return embeddable: false when CSP frame-ancestors is 'none'", async () => { it("should return embeddable: false when CSP frame-ancestors is 'none'", async () => {
fetchMock.mockResponseOnce("", { mockEmbedResponse(embedUrl, {
status: 200,
headers: { headers: {
"Content-Security-Policy": "Content-Security-Policy":
"default-src 'self'; frame-ancestors 'none'", "default-src 'self'; frame-ancestors 'none'",
}, },
}); });
const result = await checkEmbeddability("https://www.example.com/embed"); const result = await checkEmbeddability(embedUrl);
expect(result).toEqual({ expect(result).toEqual({
embeddable: false, embeddable: false,
reason: "csp-frame-ancestors", reason: "csp-frame-ancestors",
@@ -77,14 +85,13 @@ describe("checkEmbeddability", () => {
}); });
it("should return embeddable: false when CSP frame-ancestors is 'self'", async () => { it("should return embeddable: false when CSP frame-ancestors is 'self'", async () => {
fetchMock.mockResponseOnce("", { mockEmbedResponse(embedUrl, {
status: 200,
headers: { headers: {
"Content-Security-Policy": "frame-ancestors 'self'", "Content-Security-Policy": "frame-ancestors 'self'",
}, },
}); });
const result = await checkEmbeddability("https://www.example.com/embed"); const result = await checkEmbeddability(embedUrl);
expect(result).toEqual({ expect(result).toEqual({
embeddable: false, embeddable: false,
reason: "csp-frame-ancestors", reason: "csp-frame-ancestors",
@@ -92,26 +99,24 @@ describe("checkEmbeddability", () => {
}); });
it("should return embeddable: true when CSP frame-ancestors is *", async () => { it("should return embeddable: true when CSP frame-ancestors is *", async () => {
fetchMock.mockResponseOnce("", { mockEmbedResponse(embedUrl, {
status: 200,
headers: { headers: {
"Content-Security-Policy": "frame-ancestors *", "Content-Security-Policy": "frame-ancestors *",
}, },
}); });
const result = await checkEmbeddability("https://www.example.com/embed"); const result = await checkEmbeddability(embedUrl);
expect(result).toEqual({ embeddable: true }); expect(result).toEqual({ embeddable: true });
}); });
it("should return embeddable: false when CSP frame-ancestors has specific origins", async () => { it("should return embeddable: false when CSP frame-ancestors has specific origins", async () => {
fetchMock.mockResponseOnce("", { mockEmbedResponse(embedUrl, {
status: 200,
headers: { headers: {
"Content-Security-Policy": "frame-ancestors https://allowed-site.com", "Content-Security-Policy": "frame-ancestors https://allowed-site.com",
}, },
}); });
const result = await checkEmbeddability("https://www.example.com/embed"); const result = await checkEmbeddability(embedUrl);
expect(result).toEqual({ expect(result).toEqual({
embeddable: false, embeddable: false,
reason: "csp-frame-ancestors", reason: "csp-frame-ancestors",
@@ -119,60 +124,52 @@ describe("checkEmbeddability", () => {
}); });
it("should return embeddable: false when COEP is require-corp", async () => { it("should return embeddable: false when COEP is require-corp", async () => {
fetchMock.mockResponseOnce("", { mockEmbedResponse(embedUrl, {
status: 200,
headers: { "Cross-Origin-Embedder-Policy": "require-corp" }, headers: { "Cross-Origin-Embedder-Policy": "require-corp" },
}); });
const result = await checkEmbeddability("https://www.example.com/embed"); const result = await checkEmbeddability(embedUrl);
expect(result).toEqual({ embeddable: false, reason: "coep" }); expect(result).toEqual({ embeddable: false, reason: "coep" });
}); });
it("should return embeddable: true when COEP is unsafe-none", async () => { it("should return embeddable: true when COEP is unsafe-none", async () => {
fetchMock.mockResponseOnce("", { mockEmbedResponse(embedUrl, {
status: 200,
headers: { "Cross-Origin-Embedder-Policy": "unsafe-none" }, headers: { "Cross-Origin-Embedder-Policy": "unsafe-none" },
}); });
const result = await checkEmbeddability("https://www.example.com/embed"); const result = await checkEmbeddability(embedUrl);
expect(result).toEqual({ embeddable: true }); expect(result).toEqual({ embeddable: true });
}); });
it("should return embeddable: false when server returns 403", async () => { it("should return embeddable: false when server returns 403", async () => {
fetchMock.mockResponseOnce("", { const url = "https://www.example.com/forbiddenpage";
status: 403, mockEmbedResponse(url, { status: 403 });
headers: {},
});
const result = await checkEmbeddability( const result = await checkEmbeddability(url);
"https://www.example.com/forbiddenpage"
);
expect(result).toEqual({ embeddable: false, reason: "http-error" }); expect(result).toEqual({ embeddable: false, reason: "http-error" });
}); });
it("should return embeddable: false when server returns 404", async () => { it("should return embeddable: false when server returns 404", async () => {
fetchMock.mockResponseOnce("", { const url = "https://www.example.com/nonexistentpage";
status: 404, mockEmbedResponse(url, { status: 404 });
headers: {},
});
const result = await checkEmbeddability( const result = await checkEmbeddability(url);
"https://www.example.com/nonexistentpage"
);
expect(result).toEqual({ embeddable: false, reason: "http-error" }); expect(result).toEqual({ embeddable: false, reason: "http-error" });
}); });
it("should return embeddable: true on timeout (optimistic)", async () => { it("should return embeddable: true on timeout (optimistic)", async () => {
fetchMock.mockAbortOnce(); // Network errors and aborts both land in the catch branch and return
// { embeddable: true, reason: "timeout" }.
server.use(http.get(embedUrl, () => HttpResponse.error()));
const result = await checkEmbeddability("https://www.example.com/embed"); const result = await checkEmbeddability(embedUrl);
expect(result).toEqual({ embeddable: true, reason: "timeout" }); expect(result).toEqual({ embeddable: true, reason: "timeout" });
}); });
it("should return embeddable: true on network error (optimistic)", async () => { it("should return embeddable: true on network error (optimistic)", async () => {
fetchMock.mockRejectOnce(new Error("Network error")); server.use(http.get(embedUrl, () => HttpResponse.error()));
const result = await checkEmbeddability("https://www.example.com/embed"); const result = await checkEmbeddability(embedUrl);
expect(result).toEqual({ embeddable: true, reason: "timeout" }); expect(result).toEqual({ embeddable: true, reason: "timeout" });
}); });
}); });
+35
View File
@@ -112,6 +112,25 @@ export function getFilenamesInDirectory(dirName: string): string[] {
); );
} }
// Optional cache used in tests, where Node's require() cannot resolve
// TypeScript files with aliased imports. Populated by the test setup with
// modules pre-loaded via Vite's import.meta.glob, keyed by directory suffix.
const requireDirectoryCache = new Map<string, Record<string, unknown>>();
/**
* Pre-populate requireDirectory's module cache. Intended for use only by the
* Vitest test setup; production code should not call this.
*
* @param suffix The directory path suffix to match against.
* @param modules The eagerly-loaded modules.
*/
export function __setRequireDirectoryCache(
suffix: string,
modules: Record<string, unknown>
): void {
requireDirectoryCache.set(suffix, modules);
}
/** /**
* Require all files in a directory and return them as an array of tuples. * Require all files in a directory and return them as an array of tuples.
* *
@@ -119,6 +138,22 @@ export function getFilenamesInDirectory(dirName: string): string[] {
* @returns An array of tuples containing the required files and their names. * @returns An array of tuples containing the required files and their names.
*/ */
export function requireDirectory<T>(dirName: string): [T, string][] { export function requireDirectory<T>(dirName: string): [T, string][] {
for (const [suffix, modules] of requireDirectoryCache) {
if (dirName.endsWith(suffix)) {
return Object.entries(modules)
.filter(
([filePath]) =>
!filePath.endsWith("/index.ts") &&
!filePath.endsWith("/index.js") &&
!filePath.includes(".test.")
)
.map(([filePath, mod]) => {
const base = filePath.split("/").pop() ?? filePath;
const id = base.replace(/\.[jt]s$/, "");
return [mod as T, id];
});
}
}
return getFilenamesInDirectory(dirName).map((fileName) => { return getFilenamesInDirectory(dirName).map((fileName) => {
const filePath = path.join(dirName, fileName); const filePath = path.join(dirName, fileName);
const name = path.basename(filePath.replace(/\.[jt]s$/, "")); const name = path.basename(filePath.replace(/\.[jt]s$/, ""));
+17 -10
View File
@@ -1,9 +1,9 @@
import fetchMock from "jest-fetch-mock"; import { http, HttpResponse } from "msw";
import { server } from "@server/test/msw";
import { getVersionInfo, getVersion } from "./getInstallationInfo"; import { getVersionInfo, getVersion } from "./getInstallationInfo";
beforeEach(() => { const dockerHubUrl =
fetchMock.resetMocks(); "https://hub.docker.com/v2/repositories/outlinewiki/outline/tags";
});
describe("getVersion", () => { describe("getVersion", () => {
it("should return the current version", () => { it("should return the current version", () => {
@@ -17,11 +17,13 @@ describe("getVersionInfo", () => {
const currentVersion = "0.80.0"; const currentVersion = "0.80.0";
it("should return version info when Docker Hub is accessible", async () => { it("should return version info when Docker Hub is accessible", async () => {
fetchMock.mockResponseOnce( server.use(
JSON.stringify({ http.get(dockerHubUrl, () =>
HttpResponse.json({
results: [{ name: "0.81.0" }, { name: "0.80.0" }, { name: "0.79.0" }], results: [{ name: "0.81.0" }, { name: "0.80.0" }, { name: "0.79.0" }],
next: null, next: null,
}) })
)
); );
const result = await getVersionInfo(currentVersion); const result = await getVersionInfo(currentVersion);
@@ -33,7 +35,7 @@ describe("getVersionInfo", () => {
}); });
it("should return fallback values when Docker Hub is unreachable", async () => { it("should return fallback values when Docker Hub is unreachable", async () => {
fetchMock.mockRejectOnce(new Error("Network request failed")); server.use(http.get(dockerHubUrl, () => HttpResponse.error()));
const result = await getVersionInfo(currentVersion); const result = await getVersionInfo(currentVersion);
@@ -44,7 +46,7 @@ describe("getVersionInfo", () => {
}); });
it("should return fallback values when fetch times out", async () => { it("should return fallback values when fetch times out", async () => {
fetchMock.mockRejectOnce(new Error("Request timeout after 10000ms")); server.use(http.get(dockerHubUrl, () => HttpResponse.error()));
const result = await getVersionInfo(currentVersion); const result = await getVersionInfo(currentVersion);
@@ -55,7 +57,7 @@ describe("getVersionInfo", () => {
}); });
it("should return fallback values when DNS lookup fails", async () => { it("should return fallback values when DNS lookup fails", async () => {
fetchMock.mockRejectOnce(new Error("DNS lookup failed")); server.use(http.get(dockerHubUrl, () => HttpResponse.error()));
const result = await getVersionInfo(currentVersion); const result = await getVersionInfo(currentVersion);
@@ -66,7 +68,12 @@ describe("getVersionInfo", () => {
}); });
it("should return fallback values when response is not JSON", async () => { it("should return fallback values when response is not JSON", async () => {
fetchMock.mockResponseOnce("Not JSON"); server.use(
http.get(
dockerHubUrl,
() => new HttpResponse("Not JSON", { status: 200 })
)
);
const result = await getVersionInfo(currentVersion); const result = await getVersionInfo(currentVersion);
+40
View File
@@ -0,0 +1,40 @@
/**
* Create a lazily-loaded object registry. The loader runs only when the
* registry is first accessed.
*
* @param load A function that returns the registry contents.
* @returns A proxy that exposes the lazily-loaded registry.
*/
export function createLazyRegistry<T>(
load: () => Record<string, T>
): Record<string, T> {
let cache: Record<string, T> | undefined;
const getRegistry = () => {
if (cache) {
return cache;
}
cache = load();
return cache;
};
return new Proxy({} as Record<string, T>, {
get(_target, prop: string | symbol) {
if (typeof prop === "symbol") {
return undefined;
}
return getRegistry()[prop];
},
has(_target, prop: string | symbol) {
return typeof prop === "string" && prop in getRegistry();
},
ownKeys() {
return Reflect.ownKeys(getRegistry());
},
getOwnPropertyDescriptor(_target, prop: string | symbol) {
return Reflect.getOwnPropertyDescriptor(getRegistry(), prop);
},
});
}
+9 -9
View File
@@ -1,4 +1,5 @@
import fetchMock from "jest-fetch-mock"; import { http, HttpResponse } from "msw";
import { server } from "@server/test/msw";
import OAuthClient from "./oauth"; import OAuthClient from "./oauth";
class MinimalOAuthClient extends OAuthClient { class MinimalOAuthClient extends OAuthClient {
@@ -9,16 +10,15 @@ class MinimalOAuthClient extends OAuthClient {
}; };
} }
beforeEach(() => {
fetchMock.resetMocks();
});
describe("userInfo", () => { describe("userInfo", () => {
it("should work with empty-body 401 Unauthorized responses", async () => { it("should work with empty-body 401 Unauthorized responses", async () => {
fetchMock.mockResponseOnce("", { server.use(
status: 401, http.get(
statusText: "unauthorized", "http://example.com/userinfo",
}); () =>
new HttpResponse(null, { status: 401, statusText: "unauthorized" })
)
);
const client = new MinimalOAuthClient("clientid", "clientsecret"); const client = new MinimalOAuthClient("clientid", "clientsecret");
try { try {
-1
View File
@@ -1,4 +1,3 @@
import { expect } from "@jest/globals";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import env from "@server/env"; import env from "@server/env";
import parseAttachmentIds from "./parseAttachmentIds"; import parseAttachmentIds from "./parseAttachmentIds";
+3 -2
View File
@@ -1,12 +1,13 @@
import dns from "node:dns"; import dns from "node:dns";
import type { MockInstance } from "vitest";
import env from "@server/env"; import env from "@server/env";
import { validateUrlNotPrivate } from "./url"; import { validateUrlNotPrivate } from "./url";
describe("validateUrlNotPrivate", () => { describe("validateUrlNotPrivate", () => {
let lookupSpy: jest.SpyInstance; let lookupSpy: MockInstance;
beforeEach(() => { beforeEach(() => {
lookupSpy = jest lookupSpy = vi
.spyOn(dns.promises, "lookup") .spyOn(dns.promises, "lookup")
.mockResolvedValue({ address: "93.184.216.34", family: 4 }); .mockResolvedValue({ address: "93.184.216.34", family: 4 });
}); });
-1
View File
@@ -1,4 +1,3 @@
import { describe, it, expect } from "@jest/globals";
import { isDatabaseUrl, isMailboxAddress } from "./validators"; import { isDatabaseUrl, isMailboxAddress } from "./validators";
describe("isDatabaseUrl", () => { describe("isDatabaseUrl", () => {
+3 -1
View File
@@ -1,3 +1,5 @@
jest.mock("i18next-http-backend"); import { vi } from "vitest";
vi.mock("i18next-http-backend");
export {}; export {};
+3 -3
View File
@@ -2,12 +2,12 @@ import { TextHelper } from "./TextHelper";
describe("TextHelper", () => { describe("TextHelper", () => {
beforeAll(() => { beforeAll(() => {
jest.useFakeTimers(); vi.useFakeTimers();
jest.setSystemTime(Date.parse("2021-01-01T00:00:00.000Z")); vi.setSystemTime(Date.parse("2021-01-01T00:00:00.000Z"));
}); });
afterAll(() => { afterAll(() => {
jest.useRealTimers(); vi.useRealTimers();
}); });
describe("replaceTemplateVariables", () => { describe("replaceTemplateVariables", () => {
+1 -1
View File
@@ -3,7 +3,7 @@ import { bytesToHumanReadable, getFileNameFromUrl } from "./files";
// Mock the browser detection with a mutable value // Mock the browser detection with a mutable value
let mockIsMacValue = false; let mockIsMacValue = false;
jest.mock("./browser", () => ({ vi.mock("./browser", () => ({
get isMac() { get isMac() {
return mockIsMacValue; return mockIsMacValue;
}, },
+112
View File
@@ -0,0 +1,112 @@
import path from "node:path";
import babel from "vite-plugin-babel";
import { defineConfig } from "vitest/config";
const aliases = {
"@server": path.resolve(__dirname, "./server"),
"@shared": path.resolve(__dirname, "./shared"),
"~": path.resolve(__dirname, "./app"),
plugins: path.resolve(__dirname, "./plugins"),
};
const fileMock = path.resolve(__dirname, "./__mocks__/fileMock.js");
const babelPlugin = () =>
babel({
filter: /\.(t|j)sx?$/,
babelConfig: {
babelrc: false,
configFile: false,
sourceMaps: "inline",
presets: [
["@babel/preset-react", { runtime: "automatic" }],
["@babel/preset-typescript", { allowDeclareFields: true }],
],
plugins: [
"babel-plugin-transform-typescript-metadata",
["@babel/plugin-proposal-decorators", { legacy: true }],
"@babel/plugin-transform-class-properties",
],
},
});
const sharedConfig = {
resolve: { alias: aliases },
plugins: [babelPlugin()],
esbuild: false as const,
oxc: false as const,
};
const aliasesAsArray = Object.entries(aliases).map(([find, replacement]) => ({
find,
replacement,
}));
const fileMockAlias = { find: /\.(gif|ttf|eot|svg)$/, replacement: fileMock };
export default defineConfig({
...sharedConfig,
test: {
globals: true,
pool: "threads",
// Match Jest's default behavior — unhandled promise rejections are
// logged but don't fail tests on their own.
dangerouslyIgnoreUnhandledErrors: true,
projects: [
{
...sharedConfig,
test: {
name: "server",
globals: true,
environment: "node",
include: ["server/**/*.test.{ts,tsx}", "plugins/**/*.test.{ts,tsx}"],
setupFiles: [
"./__mocks__/console.js",
"./server/test/setupMocks.ts",
"./server/test/setup.ts",
],
globalSetup: ["./server/test/globalTeardown.ts"],
fileParallelism: true,
},
},
{
...sharedConfig,
resolve: { alias: [fileMockAlias, ...aliasesAsArray] },
test: {
name: "app",
globals: true,
environment: "jsdom",
environmentOptions: {
jsdom: { url: "http://localhost" },
},
include: ["app/**/*.test.{ts,tsx}"],
setupFiles: ["./__mocks__/window.js", "./app/test/setup.ts"],
},
},
{
...sharedConfig,
test: {
name: "shared-node",
globals: true,
environment: "node",
include: ["shared/**/*.test.{ts,tsx}"],
setupFiles: ["./__mocks__/console.js", "./shared/test/setup.ts"],
},
},
{
...sharedConfig,
resolve: { alias: [fileMockAlias, ...aliasesAsArray] },
test: {
name: "shared-jsdom",
globals: true,
environment: "jsdom",
environmentOptions: {
jsdom: { url: "http://localhost" },
},
include: ["shared/**/*.test.{ts,tsx}"],
setupFiles: ["./__mocks__/window.js"],
},
},
],
},
});
Vendored
+1
View File
@@ -0,0 +1 @@
/// <reference types="vitest/globals" />
+1209 -2240
View File
File diff suppressed because it is too large Load Diff