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:
- '.github/**'
- 'vite.config.ts'
- 'vitest.config.ts'
server:
- 'server/**'
- 'shared/**'
@@ -82,15 +83,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/install
- name: Restore Jest transform cache
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
- run: yarn test:${{ matrix.test-group }}
test-server:
needs: changes
@@ -118,17 +111,9 @@ jobs:
steps:
- uses: actions/checkout@v5
- 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
- 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:
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 */
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
import localStorage from "../../__mocks__/localStorage";
import { initI18n } from "../utils/i18n";
@@ -7,6 +9,4 @@ initI18n();
global.localStorage = localStorage;
require("jest-fetch-mock").enableMocks();
jest.mock("~/utils/ApiClient");
vi.mock("~/utils/ApiClient");
+3 -1
View File
@@ -1,6 +1,8 @@
/* oxlint-disable */
import { vi } from "vitest";
export const client = {
post: jest.fn(() =>
post: vi.fn(() =>
Promise.resolve({
data: {
user: {},
+14 -10
View File
@@ -28,10 +28,11 @@
"db:rollback": "sequelize db:migrate:undo",
"db:reset": "sequelize db:drop && sequelize db:create && sequelize db:migrate",
"upgrade": "git fetch && git pull && yarn install && yarn heroku-postbuild",
"test": "TZ=UTC jest --config=.jestconfig.json",
"test:app": "TZ=UTC jest --config=.jestconfig.json --selectProjects app",
"test:shared": "TZ=UTC jest --config=.jestconfig.json --selectProjects shared-node shared-jsdom",
"test:server": "TZ=UTC jest --config=.jestconfig.json --selectProjects server",
"test": "TZ=UTC vitest run",
"test:app": "TZ=UTC vitest run --project app",
"test:shared": "TZ=UTC vitest run --project shared-node --project shared-jsdom",
"test:server": "TZ=UTC vitest run --project server",
"test:watch": "TZ=UTC vitest",
"vite:dev": "VITE_CJS_IGNORE_WARNING=true vite",
"vite:build": "VITE_CJS_IGNORE_WARNING=true vite build",
"vite:preview": "VITE_CJS_IGNORE_WARNING=true vite preview"
@@ -283,6 +284,7 @@
"@babel/preset-typescript": "^7.28.5",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.1",
"@swc/core": "^1.15.32",
"@types/addressparser": "^1.0.3",
"@types/cookie": "0.6.0",
"@types/crypto-js": "^4.2.2",
@@ -298,8 +300,8 @@
"@types/google.analytics": "^0.0.46",
"@types/invariant": "^2.2.37",
"@types/ioredis-mock": "^8.2.7",
"@types/jest": "^29.5.14",
"@types/js-yaml": "^4.0.9",
"@types/jsdom": "^28.0.1",
"@types/jsonwebtoken": "^8.5.9",
"@types/katex": "^0.16.8",
"@types/koa": "^2.15.0",
@@ -346,7 +348,7 @@
"@types/utf8": "^3.0.3",
"@types/validator": "^13.15.10",
"@types/yauzl": "^2.10.3",
"babel-jest": "^30.3.0",
"@vitest/ui": "^4.1.5",
"babel-plugin-module-resolver": "^5.0.3",
"babel-plugin-styled-components": "^2.1.4",
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
@@ -357,10 +359,8 @@
"husky": "^8.0.3",
"i18next-parser": "^8.13.0",
"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",
"msw": "^2.14.2",
"nodemon": "^3.1.14",
"oxlint": "1.50.0",
"oxlint-tsgolint": "0.14.2",
@@ -370,7 +370,11 @@
"rimraf": "^6.1.3",
"rollup-plugin-webpack-stats": "2.1.11",
"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": {
"@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 () => {
const spy = jest.spyOn(WelcomeEmail.prototype, "schedule");
const spy = vi.spyOn(WelcomeEmail.prototype, "schedule");
const subdomain = faker.internet.domainWord();
const team = await buildTeam({ subdomain });
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 () => {
const spy = jest.spyOn(SigninEmail.prototype, "schedule");
const spy = vi.spyOn(SigninEmail.prototype, "schedule");
const subdomain = faker.internet.domainWord();
const team = await buildTeam({ subdomain });
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 () => {
const user = await buildUser();
const spy = jest.spyOn(WelcomeEmail.prototype, "schedule");
const spy = vi.spyOn(WelcomeEmail.prototype, "schedule");
const subdomain = faker.internet.domainWord();
await buildTeam({ subdomain });
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 () => {
const spy = jest.spyOn(SigninEmail.prototype, "schedule");
const spy = vi.spyOn(SigninEmail.prototype, "schedule");
const subdomain = faker.internet.domainWord();
const team = await buildTeam({ subdomain });
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 () => {
const spy = jest.spyOn(WelcomeEmail.prototype, "schedule");
const spy = vi.spyOn(WelcomeEmail.prototype, "schedule");
const subdomain = faker.internet.domainWord();
await buildTeam({ subdomain });
const res = await server.post("/auth/email", {
@@ -145,7 +145,7 @@ describe("email", () => {
describe("with multiple users matching email", () => {
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 subdomain = faker.internet.domainWord();
const team = await buildTeam({
@@ -174,7 +174,7 @@ describe("email", () => {
});
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 subdomain = faker.internet.domainWord();
const team = await buildTeam({
@@ -203,7 +203,7 @@ describe("email", () => {
});
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 domain = faker.internet.domainName();
const team = await buildTeam({
@@ -232,7 +232,7 @@ describe("email", () => {
});
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 domain = faker.internet.domainName();
const team = await buildTeam({
@@ -2,12 +2,19 @@ import { Node } from "prosemirror-model";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import nodesWithEmptyTextNode from "@server/test/fixtures/notion-page-with-empty-text-nodes.json";
import allNodes from "@server/test/fixtures/notion-page.json";
import type { ProsemirrorData, ProsemirrorDoc } from "@shared/types";
import type { NotionPage } from "./NotionConverter";
import { NotionConverter } from "./NotionConverter";
jest.mock("node:crypto", () => ({
randomUUID: jest.fn(() => "550e8400-e29b-41d4-a716-446655440000"),
}));
const generatedId = "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", () => {
it("converts a page", () => {
@@ -15,6 +22,7 @@ describe("NotionConverter", () => {
children: allNodes,
} as NotionPage);
normalizeGeneratedIds(response);
expect(response).toMatchSnapshot();
expect(ProsemirrorHelper.toProsemirror(response)).toBeInstanceOf(Node);
});
@@ -24,6 +32,7 @@ describe("NotionConverter", () => {
children: nodesWithEmptyTextNode,
} as NotionPage);
normalizeGeneratedIds(response);
expect(response).toMatchSnapshot();
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": [
{
@@ -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": [
{
+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";
beforeEach(() => {
fetchMock.resetMocks();
});
const captureRequest = (url: string, response: Response | (() => Response)) => {
const captured: { request?: StrictRequest<DefaultBodyType> } = {};
server.use(
http.get(url, ({ request }) => {
captured.request = request;
return typeof response === "function" ? response() : response;
})
);
return captured;
};
describe("fetchOIDCConfiguration", () => {
it("should fetch and parse OIDC configuration successfully", async () => {
@@ -19,20 +32,18 @@ describe("fetchOIDCConfiguration", () => {
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");
expect(fetchMock).toHaveBeenCalledWith(
"https://example.com/.well-known/openid-configuration",
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({
Accept: "application/json",
}),
})
expect(captured.request?.url).toBe(
"https://example.com/.well-known/openid-configuration"
);
expect(captured.request?.method).toBe("GET");
expect(captured.request?.headers.get("Accept")).toBe("application/json");
expect(result).toEqual(mockConfig);
});
@@ -44,26 +55,37 @@ describe("fetchOIDCConfiguration", () => {
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/");
expect(fetchMock).toHaveBeenCalledWith(
"https://example.com/.well-known/openid-configuration",
expect.any(Object)
expect(captured.request?.url).toBe(
"https://example.com/.well-known/openid-configuration"
);
});
it("should throw error when HTTP request fails", async () => {
fetchMock.mockRejectOnce(new Error("Network error"));
await expect(fetchOIDCConfiguration("https://example.com")).rejects.toThrow(
"Network error"
server.use(
http.get("https://example.com/.well-known/openid-configuration", () =>
HttpResponse.error()
)
);
await expect(
fetchOIDCConfiguration("https://example.com")
).rejects.toThrow();
});
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(
"Failed to fetch OIDC configuration: 404 Not Found"
@@ -77,7 +99,11 @@ describe("fetchOIDCConfiguration", () => {
// 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(
"Missing token_endpoint in OIDC configuration"
@@ -91,7 +117,11 @@ describe("fetchOIDCConfiguration", () => {
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(
"Missing authorization_endpoint in OIDC configuration"
@@ -108,22 +138,20 @@ describe("fetchOIDCConfiguration", () => {
"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(
"https://auth.example.com/application/o/outline/"
);
expect(fetchMock).toHaveBeenCalledWith(
"https://auth.example.com/application/o/outline/.well-known/openid-configuration",
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({
Accept: "application/json",
}),
})
expect(captured.request?.url).toBe(
"https://auth.example.com/application/o/outline/.well-known/openid-configuration"
);
expect(captured.request?.method).toBe("GET");
expect(captured.request?.headers.get("Accept")).toBe("application/json");
expect(result).toEqual(mockConfig);
});
@@ -137,22 +165,20 @@ describe("fetchOIDCConfiguration", () => {
"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(
"https://auth.example.com/application/o/outline"
);
expect(fetchMock).toHaveBeenCalledWith(
"https://auth.example.com/application/o/outline/.well-known/openid-configuration",
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({
Accept: "application/json",
}),
})
expect(captured.request?.url).toBe(
"https://auth.example.com/application/o/outline/.well-known/openid-configuration"
);
expect(captured.request?.method).toBe("GET");
expect(captured.request?.headers.get("Accept")).toBe("application/json");
expect(result).toEqual(mockConfig);
});
@@ -164,17 +190,18 @@ describe("fetchOIDCConfiguration", () => {
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(
"https://example.com/.well-known/openid-configuration"
);
expect(fetchMock).toHaveBeenCalledWith(
"https://example.com/.well-known/openid-configuration",
expect.any(Object)
expect(captured.request?.url).toBe(
"https://example.com/.well-known/openid-configuration"
);
expect(result).toEqual(mockConfig);
});
});
@@ -21,7 +21,7 @@ import PostgresSearchProvider from "./PostgresSearchProvider";
const provider = SearchProviderManager.getProvider();
beforeEach(async () => {
jest.resetAllMocks();
vi.resetAllMocks();
await buildDocument();
});
+8 -4
View File
@@ -6,12 +6,16 @@ import { getTestServer } from "@server/test/support";
import env from "../env";
import * as Slack from "../slack";
jest.mock("../slack", () => ({
post: jest.fn(),
}));
const server = getTestServer();
beforeEach(() => {
vi.spyOn(Slack, "post").mockResolvedValue({ ok: true });
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("#hooks.unfurl", () => {
it("should return documents with matching SSO user", async () => {
const user = await buildUser();
+1 -1
View File
@@ -20,7 +20,7 @@ import { randomUUID } from "node:crypto";
const server = getTestServer();
// Increase timeout for all tests in this file
jest.setTimeout(10000);
vi.setConfig({ testTimeout: 10000 });
describe("#files.create", () => {
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 { mockTaskSchedule } from "@server/test/support";
import type { UserEvent } from "@server/types";
import DeliverWebhookTask from "../tasks/DeliverWebhookTask";
import WebhookProcessor from "./WebhookProcessor";
jest.mock("../tasks/DeliverWebhookTask");
const ip = "127.0.0.1";
const schedule = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
DeliverWebhookTask.prototype.schedule = schedule;
});
const schedule = mockTaskSchedule();
describe("WebhookProcessor", () => {
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 {
buildUser,
@@ -8,14 +14,28 @@ import {
import type { UserEvent } from "@server/types";
import DeliverWebhookTask from "./DeliverWebhookTask";
beforeEach(async () => {
jest.resetAllMocks();
fetchMock.resetMocks();
fetchMock.doMock();
});
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", () => {
test("should hit the subscription url and record a delivery", async () => {
const subscription = await buildWebhookSubscription({
@@ -25,7 +45,10 @@ describe("DeliverWebhookTask", () => {
const signedInUser = await buildUser({ teamId: subscription.teamId });
const processor = new DeliverWebhookTask();
fetchMock.mockResponse("SUCCESS", { status: 200 });
const captured = captureWebhook(
"http://example.com",
() => new HttpResponse("SUCCESS", { status: 200 })
);
const event: UserEvent = {
name: "users.signin",
@@ -39,12 +62,9 @@ describe("DeliverWebhookTask", () => {
event,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"http://example.com",
expect.anything()
);
const parsedBody = JSON.parse(fetchMock.mock.calls[0]![1]!.body as string);
expect(captured.length).toBe(1);
expect(captured[0].request.url).toBe("http://example.com/");
const parsedBody = JSON.parse(captured[0].body);
expect(parsedBody.webhookSubscriptionId).toBe(subscription.id);
expect(parsedBody.event).toBe("users.signin");
expect(parsedBody.payload.id).toBe(signedInUser.id);
@@ -70,6 +90,8 @@ describe("DeliverWebhookTask", () => {
const signedInUser = await buildUser({ teamId: subscription.teamId });
const processor = new DeliverWebhookTask();
const captured = captureWebhook("http://example.com");
const event: UserEvent = {
name: "users.signin",
userId: signedInUser.id,
@@ -82,13 +104,10 @@ describe("DeliverWebhookTask", () => {
event,
});
const headers = fetchMock.mock.calls[0]![1]!.headers! as Record<
string,
string
>;
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(headers["Outline-Signature"]).toMatch(/^t=[0-9]+,s=[a-z0-9]+$/);
expect(captured.length).toBe(1);
expect(captured[0].request.headers.get("Outline-Signature")).toMatch(
/^t=[0-9]+,s=[a-z0-9]+$/
);
});
test("should hit the subscription url when the eventing model doesn't exist", async () => {
@@ -108,17 +127,16 @@ describe("DeliverWebhookTask", () => {
ip,
};
const captured = captureWebhook("http://example.com");
await task.perform({
event,
subscriptionId: subscription.id,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"http://example.com",
expect.anything()
);
const parsedBody = JSON.parse(fetchMock.mock.calls[0]![1]!.body as string);
expect(captured.length).toBe(1);
expect(captured[0].request.url).toBe("http://example.com/");
const parsedBody = JSON.parse(captured[0].body);
expect(parsedBody.webhookSubscriptionId).toBe(subscription.id);
expect(parsedBody.event).toBe("users.delete");
expect(parsedBody.payload.id).toBe(deletedUserId);
@@ -140,7 +158,10 @@ describe("DeliverWebhookTask", () => {
events: ["*"],
});
fetchMock.mockResponse("FAILED", { status: 500 });
captureWebhook(
"http://example.com",
() => new HttpResponse("FAILED", { status: 500 })
);
const signedInUser = await buildUser({ teamId: subscription.teamId });
const task = new DeliverWebhookTask();
@@ -186,9 +207,9 @@ describe("DeliverWebhookTask", () => {
});
}
fetchMock.mockResponse(JSON.stringify({ message: "Failure" }), {
status: 500,
});
captureWebhook("http://example.com", () =>
HttpResponse.json({ message: "Failure" }, { status: 500 })
);
const signedInUser = await buildUser({ teamId: subscription.teamId });
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
const iso6393To1 = {
export const iso6393To1: Record<string, string | undefined> = {
eng: "en",
fra: "fr",
deu: "de",
@@ -12,7 +11,5 @@ const iso6393To1 = {
ara: "ar",
hin: "hi",
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 { EditorVersionExtension } from "./EditorVersionExtension";
jest.mock("@server/env", () => ({
vi.mock("@server/env", () => ({
default: {
COLLABORATION_MAX_CLIENTS_PER_DOCUMENT: 2,
},
COLLABORATION_MAX_CLIENTS_PER_DOCUMENT: 2,
}));
@@ -13,7 +16,7 @@ describe("ConnectionLimitExtension", () => {
let server: typeof Server;
let extension: ConnectionLimitExtension;
const port = 12345;
const url = `ws://localhost:${port}`;
const url = `ws://127.0.0.1:${port}`;
const documentName = "test";
beforeEach(async () => {
+1 -1
View File
@@ -6,7 +6,7 @@ import { sequelize } from "@server/storage/database";
import { buildUser } from "@server/test/factories";
import documentImporter from "./documentImporter";
jest.mock("@server/storage/files");
vi.mock("@server/storage/files");
describe("documentImporter", () => {
it("should convert Word Document to markdown", async () => {
@@ -1,17 +1,10 @@
import { subDays } from "date-fns";
import { Attachment, Document } from "@server/models";
import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
import { buildAttachment, buildDocument } from "@server/test/factories";
import { mockTaskSchedule } from "@server/test/support";
import documentPermanentDeleter from "./documentPermanentDeleter";
jest.mock("@server/queues/tasks/DeleteAttachmentTask");
const schedule = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
DeleteAttachmentTask.prototype.schedule = schedule;
});
const schedule = mockTaskSchedule();
describe("documentPermanentDeleter", () => {
it("should destroy documents", async () => {
+2 -2
View File
@@ -68,7 +68,7 @@ describe("documentUpdater", () => {
});
it("should notify collaboration server when text changes", async () => {
const notifyUpdateSpy = jest
const notifyUpdateSpy = vi
.spyOn(APIUpdateExtension, "notifyUpdate")
.mockResolvedValue(undefined);
@@ -95,7 +95,7 @@ describe("documentUpdater", () => {
});
it("should not notify collaboration server when only title changes", async () => {
const notifyUpdateSpy = jest
const notifyUpdateSpy = vi
.spyOn(APIUpdateExtension, "notifyUpdate")
.mockResolvedValue(undefined);
+8 -6
View File
@@ -1,22 +1,24 @@
import { Hook, PluginManager } from "@server/utils/PluginManager";
import { requireDirectory } from "@server/utils/fs";
import { createLazyRegistry } from "@server/utils/lazyRegistry";
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>.
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(
([module, id]) => {
if (id === "index") {
return;
}
emails[id] = module.default;
registry[id] = module.default;
}
);
PluginManager.getHooks(Hook.EmailTemplate).forEach((hook) => {
emails[hook.value.name] = hook.value;
registry[hook.value.name] = hook.value;
});
return registry;
});
export default emails;
+30 -30
View File
@@ -21,12 +21,12 @@ describe("Authentication middleware", () => {
{
// @ts-expect-error mock request
request: {
get: jest.fn(() => `Bearer ${user.getJwtToken()}`),
get: vi.fn(() => `Bearer ${user.getJwtToken()}`),
},
state,
cache: {},
},
jest.fn()
vi.fn()
);
expect(state.auth.user.id).toEqual(user.id);
});
@@ -41,12 +41,12 @@ describe("Authentication middleware", () => {
{
// @ts-expect-error mock request
request: {
get: jest.fn(() => `Bearer ${user.getJwtToken()}error`),
get: vi.fn(() => `Bearer ${user.getJwtToken()}error`),
},
state,
cache: {},
},
jest.fn()
vi.fn()
);
} catch (e) {
expect(e.message).toBe("Invalid token");
@@ -65,12 +65,12 @@ describe("Authentication middleware", () => {
{
// @ts-expect-error mock request
request: {
get: jest.fn(() => `Bearer ${user.getJwtToken()}`),
get: vi.fn(() => `Bearer ${user.getJwtToken()}`),
},
state,
cache: {},
},
jest.fn()
vi.fn()
);
} catch (e) {
expect(e.message).toBe("Invalid authentication type");
@@ -88,12 +88,12 @@ describe("Authentication middleware", () => {
{
// @ts-expect-error mock request
request: {
get: jest.fn(() => `Bearer ${key.value}`),
get: vi.fn(() => `Bearer ${key.value}`),
},
state,
cache: {},
},
jest.fn()
vi.fn()
);
expect(state.auth.user.id).toEqual(user.id);
});
@@ -112,12 +112,12 @@ describe("Authentication middleware", () => {
// @ts-expect-error mock request
request: {
url: "/auth.info",
get: jest.fn(() => `Bearer ${key.value}`),
get: vi.fn(() => `Bearer ${key.value}`),
},
state,
cache: {},
},
jest.fn()
vi.fn()
);
expect(state.auth.user.id).toEqual(user.id);
});
@@ -138,12 +138,12 @@ describe("Authentication middleware", () => {
// @ts-expect-error mock request
request: {
url: "/documents.create",
get: jest.fn(() => `Bearer ${key.value}`),
get: vi.fn(() => `Bearer ${key.value}`),
},
state,
cache: {},
},
jest.fn()
vi.fn()
);
throw new Error("Expected error to be thrown");
} catch (e) {
@@ -162,12 +162,12 @@ describe("Authentication middleware", () => {
{
// @ts-expect-error mock request
request: {
get: jest.fn(() => `Bearer ${randomString(38)}`),
get: vi.fn(() => `Bearer ${randomString(38)}`),
},
state,
cache: {},
},
jest.fn()
vi.fn()
);
} catch (e) {
expect(e.message).toBe("Invalid API key");
@@ -191,12 +191,12 @@ describe("Authentication middleware", () => {
// @ts-expect-error mock request
request: {
url: "/users.info",
get: jest.fn(() => `Bearer ${authentication.accessToken}`),
get: vi.fn(() => `Bearer ${authentication.accessToken}`),
},
state,
cache: {},
},
jest.fn()
vi.fn()
);
expect(state.auth.user.id).toEqual(user.id);
});
@@ -217,12 +217,12 @@ describe("Authentication middleware", () => {
// @ts-expect-error mock request
request: {
url: "/documents.create",
get: jest.fn(() => `Bearer ${authentication.accessToken}`),
get: vi.fn(() => `Bearer ${authentication.accessToken}`),
},
state,
cache: {},
},
jest.fn()
vi.fn()
);
} catch (e) {
expect(e.message).toContain("does not have access to this resource");
@@ -244,7 +244,7 @@ describe("Authentication middleware", () => {
request: {
url: "/users.info",
// @ts-expect-error mock request
get: jest.fn(() => null),
get: vi.fn(() => null),
body: {
token: authentication.accessToken,
},
@@ -252,7 +252,7 @@ describe("Authentication middleware", () => {
state,
cache: {},
},
jest.fn()
vi.fn()
);
} catch (e) {
expect(e.message).toContain(
@@ -271,12 +271,12 @@ describe("Authentication middleware", () => {
{
// @ts-expect-error mock request
request: {
get: jest.fn(() => "error"),
get: vi.fn(() => "error"),
},
state,
cache: {},
},
jest.fn()
vi.fn()
);
} catch (e) {
expect(e.message).toBe(
@@ -293,7 +293,7 @@ describe("Authentication middleware", () => {
{
request: {
// @ts-expect-error mock request
get: jest.fn(() => null),
get: vi.fn(() => null),
query: {
token: user.getJwtToken(),
},
@@ -301,7 +301,7 @@ describe("Authentication middleware", () => {
state,
cache: {},
},
jest.fn()
vi.fn()
);
expect(state.auth.user.id).toEqual(user.id);
});
@@ -314,7 +314,7 @@ describe("Authentication middleware", () => {
{
request: {
// @ts-expect-error mock request
get: jest.fn(() => null),
get: vi.fn(() => null),
body: {
token: user.getJwtToken(),
},
@@ -322,7 +322,7 @@ describe("Authentication middleware", () => {
state,
cache: {},
},
jest.fn()
vi.fn()
);
expect(state.auth.user.id).toEqual(user.id);
});
@@ -342,12 +342,12 @@ describe("Authentication middleware", () => {
{
// @ts-expect-error mock request
request: {
get: jest.fn(() => `Bearer ${user.getJwtToken()}`),
get: vi.fn(() => `Bearer ${user.getJwtToken()}`),
},
state,
cache: {},
},
jest.fn()
vi.fn()
);
} catch (err) {
error = err;
@@ -372,12 +372,12 @@ describe("Authentication middleware", () => {
{
// @ts-expect-error mock request
request: {
get: jest.fn(() => `Bearer ${user.getJwtToken()}`),
get: vi.fn(() => `Bearer ${user.getJwtToken()}`),
},
state,
cache: {},
},
jest.fn()
vi.fn()
);
} catch (err) {
error = err;
+53 -57
View File
@@ -18,7 +18,7 @@ describe("rateLimiter middleware", () => {
afterEach(() => {
env.RATE_LIMITER_ENABLED = originalRateLimiterEnabled;
env.RATE_LIMITER_MULTIPLIER = originalApiMultiplier;
jest.restoreAllMocks();
vi.restoreAllMocks();
});
it("should register and enforce custom rate limiter with matching paths (no mountPath)", async () => {
@@ -29,11 +29,11 @@ describe("rateLimiter middleware", () => {
path: "/documents.export",
mountPath: undefined,
ip: "127.0.0.1",
set: jest.fn(),
set: vi.fn(),
request: {},
} as unknown as Context;
await registerMiddleware(mockCtx, jest.fn());
await registerMiddleware(mockCtx, vi.fn());
const registeredPath = "/documents.export";
expect(RateLimiter.hasRateLimiter(registeredPath)).toBe(true);
@@ -51,11 +51,11 @@ describe("rateLimiter middleware", () => {
path: "/documents.export",
mountPath: "/api",
ip: "127.0.0.1",
set: jest.fn(),
set: vi.fn(),
request: {},
} as unknown as Context;
await registerMiddleware(mockCtxRegister, jest.fn());
await registerMiddleware(mockCtxRegister, vi.fn());
const registrationPath = "/api/documents.export";
expect(RateLimiter.hasRateLimiter(registrationPath)).toBe(true);
@@ -73,11 +73,11 @@ describe("rateLimiter middleware", () => {
path: "/documents.export",
mountPath: undefined,
ip: "127.0.0.1",
set: jest.fn(),
set: vi.fn(),
request: {},
} as unknown as Context;
await registerMiddleware(mockCtx, jest.fn());
await registerMiddleware(mockCtx, vi.fn());
const limiter = RateLimiter.getRateLimiter("/documents.export");
expect(limiter.points).toBe(10);
@@ -91,11 +91,11 @@ describe("rateLimiter middleware", () => {
path: "/shares.subscribe",
mountPath: undefined,
ip: "127.0.0.1",
set: jest.fn(),
set: vi.fn(),
request: {},
} as unknown as Context;
await registerMiddleware(mockCtx, jest.fn());
await registerMiddleware(mockCtx, vi.fn());
const limiter = RateLimiter.getRateLimiter("/shares.subscribe");
expect(limiter.points).toBe(1);
@@ -112,16 +112,16 @@ describe("rateLimiter middleware", () => {
describe("cache-keyed rate limiting", () => {
it("falls back to IP when no token is present", async () => {
const middleware = defaultRateLimiter();
const consumeSpy = jest
const consumeSpy = vi
.spyOn(RateLimiter.defaultRateLimiter, "consume")
.mockResolvedValue({} as never);
const cacheSpy = jest.spyOn(RateLimiter, "getCachedUserIdForToken");
const cacheSpy = vi.spyOn(RateLimiter, "getCachedUserIdForToken");
const mockCtx = {
path: "/some/path",
mountPath: undefined,
ip: "192.168.1.1",
set: jest.fn(),
set: vi.fn(),
request: {
get: () => undefined,
body: {},
@@ -130,7 +130,7 @@ describe("rateLimiter middleware", () => {
cookies: { get: () => undefined },
} as unknown as Context;
await middleware(mockCtx, jest.fn());
await middleware(mockCtx, vi.fn());
expect(cacheSpy).not.toHaveBeenCalled();
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 () => {
const apiKeyToken = `${ApiKey.prefix}${"a".repeat(38)}`;
const middleware = defaultRateLimiter();
const consumeSpy = jest
const consumeSpy = vi
.spyOn(RateLimiter.defaultRateLimiter, "consume")
.mockResolvedValue({} as never);
const cacheReadSpy = jest.spyOn(RateLimiter, "getCachedUserIdForToken");
const verifySpy = jest.spyOn(jwtUtils, "getUserForJWT");
const cacheReadSpy = vi.spyOn(RateLimiter, "getCachedUserIdForToken");
const verifySpy = vi.spyOn(jwtUtils, "getUserForJWT");
const mockCtx = {
path: "/some/path",
mountPath: undefined,
ip: "192.168.1.1",
set: jest.fn(),
set: vi.fn(),
request: { get: () => `Bearer ${apiKeyToken}` },
cookies: { get: () => undefined },
} as unknown as Context;
await middleware(mockCtx, jest.fn());
await middleware(mockCtx, vi.fn());
expect(cacheReadSpy).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 () => {
const middleware = defaultRateLimiter();
const consumeSpy = jest
const consumeSpy = vi
.spyOn(RateLimiter.defaultRateLimiter, "consume")
.mockResolvedValue({} as never);
jest
.spyOn(RateLimiter, "getCachedUserIdForToken")
.mockResolvedValue(null);
const cacheWriteSpy = jest
vi.spyOn(RateLimiter, "getCachedUserIdForToken").mockResolvedValue(null);
const cacheWriteSpy = vi
.spyOn(RateLimiter, "cacheUserForToken")
.mockResolvedValue();
jest
.spyOn(jwtUtils, "getUserForJWT")
.mockRejectedValue(new Error("invalid token"));
vi.spyOn(jwtUtils, "getUserForJWT").mockRejectedValue(
new Error("invalid token")
);
const mockCtx = {
path: "/some/path",
mountPath: undefined,
ip: "192.168.1.1",
set: jest.fn(),
set: vi.fn(),
request: { get: () => "Bearer forged-or-unknown-token" },
cookies: { get: () => undefined },
} as unknown as Context;
await middleware(mockCtx, jest.fn());
await middleware(mockCtx, vi.fn());
expect(consumeSpy).toHaveBeenCalledWith("192.168.1.1");
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 () => {
const middleware = defaultRateLimiter();
const consumeSpy = jest
const consumeSpy = vi
.spyOn(RateLimiter.defaultRateLimiter, "consume")
.mockResolvedValue({} as never);
jest
.spyOn(RateLimiter, "getCachedUserIdForToken")
.mockResolvedValue(null);
const cacheWriteSpy = jest
vi.spyOn(RateLimiter, "getCachedUserIdForToken").mockResolvedValue(null);
const cacheWriteSpy = vi
.spyOn(RateLimiter, "cacheUserForToken")
.mockResolvedValue();
jest.spyOn(jwtUtils, "getUserForJWT").mockResolvedValue({
vi.spyOn(jwtUtils, "getUserForJWT").mockResolvedValue({
user: { id: "user-abc" },
} as never);
@@ -210,12 +206,12 @@ describe("rateLimiter middleware", () => {
path: "/some/path",
mountPath: undefined,
ip: "192.168.1.1",
set: jest.fn(),
set: vi.fn(),
request: { get: () => "Bearer valid-token" },
cookies: { get: () => undefined },
} as unknown as Context;
await middleware(mockCtx, jest.fn());
await middleware(mockCtx, vi.fn());
expect(cacheWriteSpy).toHaveBeenCalledWith("valid-token", "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 () => {
const middleware = defaultRateLimiter();
const consumeSpy = jest
const consumeSpy = vi
.spyOn(RateLimiter.defaultRateLimiter, "consume")
.mockResolvedValue({} as never);
jest
.spyOn(RateLimiter, "getCachedUserIdForToken")
.mockResolvedValue("user-abc");
const verifySpy = jest.spyOn(jwtUtils, "getUserForJWT");
vi.spyOn(RateLimiter, "getCachedUserIdForToken").mockResolvedValue(
"user-abc"
);
const verifySpy = vi.spyOn(jwtUtils, "getUserForJWT");
const mockCtx = {
path: "/some/path",
mountPath: undefined,
ip: "192.168.1.1",
set: jest.fn(),
set: vi.fn(),
request: { get: () => "Bearer verified-token" },
cookies: { get: () => undefined },
} as unknown as Context;
await middleware(mockCtx, jest.fn());
await middleware(mockCtx, vi.fn());
expect(verifySpy).not.toHaveBeenCalled();
expect(consumeSpy).toHaveBeenCalledWith("user-abc");
@@ -248,23 +244,23 @@ describe("rateLimiter middleware", () => {
it("falls back to IP when the cache lookup throws", async () => {
const middleware = defaultRateLimiter();
const consumeSpy = jest
const consumeSpy = vi
.spyOn(RateLimiter.defaultRateLimiter, "consume")
.mockResolvedValue({} as never);
jest
.spyOn(RateLimiter, "getCachedUserIdForToken")
.mockRejectedValue(new Error("redis down"));
vi.spyOn(RateLimiter, "getCachedUserIdForToken").mockRejectedValue(
new Error("redis down")
);
const mockCtx = {
path: "/some/path",
mountPath: undefined,
ip: "192.168.1.1",
set: jest.fn(),
set: vi.fn(),
request: { get: () => "Bearer some-token" },
cookies: { get: () => undefined },
} as unknown as Context;
await middleware(mockCtx, jest.fn());
await middleware(mockCtx, vi.fn());
expect(consumeSpy).toHaveBeenCalledWith("192.168.1.1");
});
@@ -275,30 +271,30 @@ describe("rateLimiter middleware", () => {
path: "/documents.export",
mountPath: "/api",
ip: "127.0.0.1",
set: jest.fn(),
set: vi.fn(),
request: {},
} as unknown as Context;
await registerMiddleware(registerCtx, jest.fn());
await registerMiddleware(registerCtx, vi.fn());
const customLimiter = RateLimiter.getRateLimiter("/api/documents.export");
const consumeSpy = jest
const consumeSpy = vi
.spyOn(customLimiter, "consume")
.mockResolvedValue({} as never);
jest
.spyOn(RateLimiter, "getCachedUserIdForToken")
.mockResolvedValue("user-abc");
vi.spyOn(RateLimiter, "getCachedUserIdForToken").mockResolvedValue(
"user-abc"
);
const middleware = defaultRateLimiter();
const mockCtx = {
path: "/documents.export",
mountPath: "/api",
ip: "127.0.0.1",
set: jest.fn(),
set: vi.fn(),
request: { get: () => "Bearer verified-token" },
cookies: { get: () => undefined },
} as unknown as Context;
await middleware(mockCtx, jest.fn());
await middleware(mockCtx, vi.fn());
expect(consumeSpy).toHaveBeenCalledWith("/api/documents.export:user-abc");
});
+6 -6
View File
@@ -6,7 +6,7 @@ describe("Timeout middleware", () => {
const originalTimeout = 10000;
const newTimeout = 1800000; // 30 minutes
const setTimeout = jest.fn();
const setTimeout = vi.fn();
const mockSocket = {
timeout: originalTimeout,
setTimeout,
@@ -18,7 +18,7 @@ describe("Timeout middleware", () => {
},
};
const next = jest.fn();
const next = vi.fn();
const middleware = timeout(newTimeout);
await middleware(
@@ -39,7 +39,7 @@ describe("Timeout middleware", () => {
const originalTimeout = 10000;
const newTimeout = 1800000; // 30 minutes
const setTimeout = jest.fn();
const setTimeout = vi.fn();
const mockSocket = {
timeout: originalTimeout,
setTimeout,
@@ -52,7 +52,7 @@ describe("Timeout middleware", () => {
};
const error = new Error("Test error");
const next = jest.fn().mockRejectedValue(error);
const next = vi.fn().mockRejectedValue(error);
const middleware = timeout(newTimeout);
await expect(
@@ -74,7 +74,7 @@ describe("Timeout middleware", () => {
it("should handle undefined original timeout", async () => {
const newTimeout = 1800000; // 30 minutes
const setTimeout = jest.fn();
const setTimeout = vi.fn();
const mockSocket = {
timeout: undefined,
setTimeout,
@@ -86,7 +86,7 @@ describe("Timeout middleware", () => {
},
};
const next = jest.fn();
const next = vi.fn();
const middleware = timeout(newTimeout);
await middleware(
+2 -2
View File
@@ -13,7 +13,7 @@ import Collection from "./Collection";
import Document from "./Document";
beforeEach(() => {
jest.resetAllMocks();
vi.resetAllMocks();
});
describe("#url", () => {
@@ -311,7 +311,7 @@ describe("#removeDocument", () => {
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
const saveSpy = jest.spyOn(collection, "save");
const saveSpy = vi.spyOn(collection, "save");
await collection.deleteDocument(document);
expect(saveSpy).toHaveBeenCalled();
});
+1 -1
View File
@@ -15,7 +15,7 @@ import { withAPIContext } from "@server/test/support";
import UserMembership from "./UserMembership";
beforeEach(() => {
jest.resetAllMocks();
vi.resetAllMocks();
});
describe("#getSummary", () => {
+13 -1
View File
@@ -13,7 +13,19 @@ import IdModel from "./base/IdModel";
import { SkipChangeset } from "./decorators/Changeset";
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
class DocumentInsight extends IdModel<
InferAttributes<DocumentInsight>,
+11 -8
View File
@@ -16,7 +16,7 @@ describe("Team", () => {
});
it("should normalize domain to lowercase", async () => {
const id = randomUUID();
const id = randomUUID().split("-")[0];
const team = await buildTeam({ domain: `${id}.example.com` });
const result = await Team.findByDomain(`${id}.Example.COM`);
expect(result?.id).toEqual(team.id);
@@ -70,21 +70,23 @@ describe("Team", () => {
describe("previousSubdomains", () => {
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({
subdomain: "example",
subdomain: originalSubdomain,
});
const subdomain = "updated";
await team.update({ subdomain });
expect(team.subdomain).toEqual(subdomain);
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 });
expect(team.subdomain).toEqual(subdomain2);
expect(team.previousSubdomains?.length).toEqual(2);
expect(team.previousSubdomains?.[0]).toEqual("example");
expect(team.previousSubdomains?.[0]).toEqual(originalSubdomain);
expect(team.previousSubdomains?.[1]).toEqual(subdomain);
});
});
@@ -104,7 +106,8 @@ describe("Team", () => {
});
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 {
const team = await buildTeam();
const attachment = await buildAttachment({
@@ -119,7 +122,7 @@ describe("Team", () => {
const result = await team.publicAvatarUrl();
expect(result).toEqual(await attachment.signedUrl);
} finally {
jest.useRealTimers();
vi.useRealTimers();
}
});
+3 -2
View File
@@ -15,11 +15,12 @@ import User from "./User";
import UserMembership from "./UserMembership";
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(() => {
jest.useRealTimers();
vi.useRealTimers();
});
describe("user model", () => {
+3 -3
View File
@@ -6,12 +6,12 @@ import { DocumentHelper } from "./DocumentHelper";
describe("DocumentHelper", () => {
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(Date.parse("2021-01-01T00:00:00.000Z"));
vi.useFakeTimers();
vi.setSystemTime(Date.parse("2021-01-01T00:00:00.000Z"));
});
afterAll(() => {
jest.useRealTimers();
vi.useRealTimers();
});
describe("replaceInternalUrls", () => {
@@ -7,7 +7,7 @@ import { buildProseMirrorDoc, buildUser } from "@server/test/factories";
import type { MentionAttrs } from "./ProsemirrorHelper";
import { ProsemirrorHelper } from "./ProsemirrorHelper";
jest.mock("@server/storage/files");
vi.mock("@server/storage/files");
describe("ProsemirrorHelper", () => {
describe("processMentions", () => {
+60 -3
View File
@@ -1,8 +1,12 @@
import emojiRegex from "emoji-regex";
import { JSDOM } from "jsdom";
import { chunk, isMatch } from "es-toolkit/compat";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { EditorState, type Plugin } from "prosemirror-state";
import {
DecorationSet,
EditorView,
type DecorationSource,
} from "prosemirror-view";
import { Node, Fragment } from "prosemirror-model";
import { renderToString } from "react-dom/server";
import styled, { ServerStyleSheet, ThemeProvider } from "styled-components";
@@ -63,6 +67,30 @@ export type MentionAttrs = {
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()
export class ProsemirrorHelper extends SharedProsemirrorHelper {
/**
@@ -513,10 +541,39 @@ export class ProsemirrorHelper extends SharedProsemirrorHelper {
const diffPlugins = options?.changes
? 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({
doc: node,
plugins: [...plugins, ...diffPlugins],
plugins: editorPlugins,
schema,
});
+1 -1
View File
@@ -2,7 +2,7 @@ import { createContext } from "@server/context";
import { buildProseMirrorDoc, buildUser } from "@server/test/factories";
import { ProsemirrorHelper } from "./ProsemirrorHelper";
jest.mock("@server/storage/files");
vi.mock("@server/storage/files");
describe("ProsemirrorHelper", () => {
describe("replaceImagesWithAttachments", () => {
+10 -9
View File
@@ -1,20 +1,21 @@
import type Koa from "koa";
import type { Mock } from "vitest";
import { requestErrorHandler } from "@server/logging/sentry";
import { InternalError, ValidationError, NotFoundError } from "./errors";
import onerror from "./onerror";
// Mock the requestErrorHandler from Sentry
jest.mock("@server/logging/sentry", () => ({
requestErrorHandler: jest.fn(),
vi.mock("@server/logging/sentry", () => ({
requestErrorHandler: vi.fn(),
}));
type MockCtx = {
headers: Record<string, string>;
headerSent: boolean;
writable: boolean;
accepts: jest.Mock;
set: jest.Mock;
res: { end: jest.Mock };
accepts: Mock;
set: Mock;
res: { end: Mock };
status: number | undefined;
type: string | undefined;
body: unknown;
@@ -43,10 +44,10 @@ describe("onerror", () => {
headers: {},
headerSent: false,
writable: true,
accepts: jest.fn(() => "json"),
set: jest.fn(),
accepts: vi.fn(() => "json"),
set: vi.fn(),
res: {
end: jest.fn(),
end: vi.fn(),
},
status: undefined,
type: undefined,
@@ -54,7 +55,7 @@ describe("onerror", () => {
};
// Clear mock calls
(requestErrorHandler as jest.Mock).mockClear();
(requestErrorHandler as Mock).mockClear();
});
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`] = `
{
@@ -42,7 +42,7 @@ describe("SearchIndexProcessor", () => {
});
const provider = SearchProviderManager.getProvider();
const indexSpy = jest.spyOn(provider, "index");
const indexSpy = vi.spyOn(provider, "index");
await processor.perform({
name: "documents.publish",
@@ -63,7 +63,7 @@ describe("SearchIndexProcessor", () => {
it("should call provider.remove for documents.permanent_delete", async () => {
const user = await buildUser();
const provider = SearchProviderManager.getProvider();
const removeSpy = jest.spyOn(provider, "remove");
const removeSpy = vi.spyOn(provider, "remove");
await processor.perform({
name: "documents.permanent_delete",
+5 -3
View File
@@ -1,11 +1,12 @@
import { Hook, PluginManager } from "@server/utils/PluginManager";
import { requireDirectory } from "@server/utils/fs";
import { createLazyRegistry } from "@server/utils/lazyRegistry";
import type BaseProcessor from "./BaseProcessor";
const processors: Record<string, typeof BaseProcessor> = {};
const AbstractProcessors = ["ImportsProcessor"];
const processors = createLazyRegistry(() => {
const processors: Record<string, typeof BaseProcessor> = {};
requireDirectory<{ default: typeof BaseProcessor }>(__dirname).forEach(
([module, id]) => {
if (id === "index" || AbstractProcessors.includes(id)) {
@@ -14,9 +15,10 @@ requireDirectory<{ default: typeof BaseProcessor }>(__dirname).forEach(
processors[id] = module.default;
}
);
PluginManager.getHooks(Hook.Processor).forEach((hook) => {
processors[hook.value.name] = hook.value;
});
return processors;
});
export default processors;
@@ -13,12 +13,12 @@ import DocumentPublishedNotificationsTask from "./DocumentPublishedNotifications
const ip = "127.0.0.1";
beforeEach(async () => {
jest.resetAllMocks();
vi.resetAllMocks();
});
describe("documents.publish", () => {
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 document = await buildDocument({
teamId: user.teamId,
@@ -40,7 +40,7 @@ describe("documents.publish", () => {
});
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 document = await buildDocument({
teamId: user.teamId,
@@ -61,7 +61,7 @@ describe("documents.publish", () => {
});
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 document = await buildDocument({
teamId: user.teamId,
@@ -98,7 +98,7 @@ describe("documents.publish", () => {
});
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 collection = await buildCollection({
teamId: user.teamId,
@@ -124,7 +124,7 @@ describe("documents.publish", () => {
});
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 group = await buildGroup({
teamId: actor.teamId,
@@ -12,12 +12,12 @@ import GroupMentionedInCommentNotificationsTask from "./GroupMentionedInCommentN
const ip = "127.0.0.1";
beforeEach(async () => {
jest.resetAllMocks();
vi.resetAllMocks();
});
describe("GroupMentionedInCommentNotificationsTask", () => {
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 document = await buildDocument({
teamId: actor.teamId,
@@ -91,7 +91,7 @@ describe("GroupMentionedInCommentNotificationsTask", () => {
});
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 document = await buildDocument({
teamId: actor.teamId,
@@ -133,7 +133,7 @@ describe("GroupMentionedInCommentNotificationsTask", () => {
});
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 document = await buildDocument({
teamId: actor.teamId,
@@ -177,7 +177,7 @@ describe("GroupMentionedInCommentNotificationsTask", () => {
});
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 document = await buildDocument({
teamId: actor.teamId,
@@ -222,7 +222,7 @@ describe("GroupMentionedInCommentNotificationsTask", () => {
});
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 document = await buildDocument({
teamId: actor.teamId,
@@ -269,7 +269,7 @@ describe("GroupMentionedInCommentNotificationsTask", () => {
});
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 document = await buildDocument({
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", () => {
@@ -22,7 +22,7 @@ describe("ImportMarkdownZipTask", () => {
};
},
});
jest.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
vi.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
const props = {
fileOperationId: fileOperation.id,
@@ -53,7 +53,7 @@ describe("ImportMarkdownZipTask", () => {
};
},
});
jest.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
vi.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
const props = {
fileOperationId: fileOperation.id,
@@ -84,7 +84,7 @@ describe("ImportMarkdownZipTask", () => {
};
},
});
jest.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
vi.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
const props = {
fileOperationId: fileOperation.id,
@@ -118,7 +118,7 @@ describe("ImportMarkdownZipTask", () => {
};
},
});
jest.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
vi.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
const props = {
fileOperationId: fileOperation.id,
@@ -5,7 +5,7 @@ import InviteReminderTask from "./InviteReminderTask";
describe("InviteReminderTask", () => {
it("should send reminder emails", async () => {
const spy = jest.spyOn(InviteReminderEmail.prototype, "schedule");
const spy = vi.spyOn(InviteReminderEmail.prototype, "schedule");
// too old
await buildInvite({
@@ -23,7 +23,7 @@ import RevisionCreatedNotificationsTask from "./RevisionCreatedNotificationsTask
const ip = "127.0.0.1";
beforeEach(async () => {
jest.resetAllMocks();
vi.resetAllMocks();
});
function updateDocumentText(document: Document, text: string) {
@@ -34,7 +34,7 @@ function updateDocumentText(document: Document, text: string) {
describe("revisions.create", () => {
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();
let document = await buildDocument({
teamId: user.teamId,
@@ -64,7 +64,7 @@ describe("revisions.create", () => {
});
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();
let document = await buildDocument({
teamId: user.teamId,
@@ -99,7 +99,7 @@ describe("revisions.create", () => {
});
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();
let document = await buildDocument({
teamId: user.teamId,
@@ -125,7 +125,7 @@ describe("revisions.create", () => {
});
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();
let document = await buildDocument({
teamId: user.teamId,
@@ -225,7 +225,7 @@ describe("revisions.create", () => {
});
test("should not send multiple emails", async () => {
const spy = jest.spyOn(Notification, "create");
const spy = vi.spyOn(Notification, "create");
const collaborator0 = await buildUser();
const collaborator1 = 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 () => {
const spy = jest.spyOn(Notification, "create");
const spy = vi.spyOn(Notification, "create");
const collaborator0 = await buildUser();
const collaborator1 = 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 () => {
const spy = jest.spyOn(Notification, "create");
const spy = vi.spyOn(Notification, "create");
let document = await buildDocument();
const collaborator = 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 () => {
const spy = jest.spyOn(Notification, "create");
const spy = vi.spyOn(Notification, "create");
let document = await buildDocument();
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 () => {
const spy = jest.spyOn(Notification, "create");
const spy = vi.spyOn(Notification, "create");
let document = await buildDocument();
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 () => {
const spy = jest.spyOn(Notification, "create");
const spy = vi.spyOn(Notification, "create");
const document = await buildDocument();
const collaborator = await buildUser({ teamId: document.teamId });
@@ -500,7 +500,7 @@ describe("revisions.create", () => {
});
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 document = await buildDocument({
@@ -525,7 +525,7 @@ describe("revisions.create", () => {
});
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 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 () => {
const spy = jest.spyOn(Notification, "create");
const spy = vi.spyOn(Notification, "create");
const actor = await buildUser();
const group = await buildGroup({
teamId: actor.teamId,
@@ -16,16 +16,14 @@ const props = {
},
};
vi.setConfig({ testTimeout: 30000 });
const daysAgo = (n: number) => subDays(new Date(), n);
const dayStr = (d: Date) => format(d, "yyyy-MM-dd");
describe("RollupDocumentInsightsTask", () => {
let task: RollupDocumentInsightsTask;
beforeAll(() => {
jest.setTimeout(30000);
});
beforeEach(() => {
task = new RollupDocumentInsightsTask();
});
@@ -8,12 +8,12 @@ import ShareSubscriptionNotificationsTask from "./ShareSubscriptionNotifications
const ip = "127.0.0.1";
beforeEach(() => {
jest.restoreAllMocks();
vi.restoreAllMocks();
});
describe("ShareSubscriptionNotificationsTask", () => {
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 share = await buildShare({
@@ -43,7 +43,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
});
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 share = await buildShare({
@@ -72,7 +72,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
});
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 share = await buildShare({
@@ -103,7 +103,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
});
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 share = await buildShare({
@@ -134,7 +134,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
});
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 share = await buildShare({
@@ -165,7 +165,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
});
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 share = await buildShare({
@@ -196,7 +196,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
});
it("should update lastNotifiedAt after sending", async () => {
jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
vi.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const document = await buildDocument();
const share = await buildShare({
@@ -229,7 +229,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
});
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 share = await buildShare({
@@ -268,7 +268,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
});
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 task = new ShareSubscriptionNotificationsTask();
@@ -285,7 +285,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
});
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 child = await buildDocument({
@@ -321,7 +321,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
});
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 sibling = await buildDocument({
@@ -17,32 +17,30 @@ const props = {
},
};
vi.setConfig({ testTimeout: 30000 });
describe("UpdateDocumentsPopularityScoreTask", () => {
let task: UpdateDocumentsPopularityScoreTask;
beforeAll(() => {
jest.setTimeout(30000);
});
beforeEach(() => {
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.
// We only mock if the instances are different to avoid infinite recursion.
if (sequelizeReadOnly !== sequelize) {
jest
.spyOn(sequelizeReadOnly, "query")
.mockImplementation(sequelize.query.bind(sequelize));
vi.spyOn(sequelizeReadOnly, "query").mockImplementation(
sequelize.query.bind(sequelize)
);
}
});
afterEach(() => {
jest.restoreAllMocks();
vi.restoreAllMocks();
});
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 document = await buildDocument({
teamId: team.id,
+4 -2
View File
@@ -1,9 +1,10 @@
import { Hook, PluginManager } from "@server/utils/PluginManager";
import { requireDirectory } from "@server/utils/fs";
import { createLazyRegistry } from "@server/utils/lazyRegistry";
import type { BaseTask } from "./base/BaseTask";
const tasks = createLazyRegistry(() => {
const tasks: Record<string, typeof BaseTask> = {};
requireDirectory<{ default: typeof BaseTask }>(__dirname).forEach(
([module, id]) => {
if (id === "index") {
@@ -12,9 +13,10 @@ requireDirectory<{ default: typeof BaseTask }>(__dirname).forEach(
tasks[id] = module.default;
}
);
PluginManager.getHooks(Hook.Task).forEach((hook) => {
tasks[hook.value.name] = hook.value;
});
return tasks;
});
export default tasks;
@@ -12,7 +12,7 @@ import {
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
jest.mock("@server/storage/files");
vi.mock("@server/storage/files");
const server = getTestServer();
+1 -1
View File
@@ -5,7 +5,7 @@ import { getTestServer, setSelfHosted } from "@server/test/support";
const mockTeamInSessionId = randomUUID();
jest.mock("@server/utils/authentication", () => ({
vi.mock("@server/utils/authentication", () => ({
getSessionsInCookie() {
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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"message": "Authentication required",
@@ -3335,11 +3335,11 @@ describe("#documents.import", () => {
collectionId: collection.id,
});
jest
.spyOn(FileStorage, "store")
.mockResolvedValue(undefined as unknown as string);
jest.spyOn(DocumentImportTask.prototype, "schedule").mockResolvedValue({
finished: jest.fn().mockResolvedValue({ documentId: document.id }),
vi.spyOn(FileStorage, "store").mockResolvedValue(
undefined as unknown as string
);
vi.spyOn(DocumentImportTask.prototype, "schedule").mockResolvedValue({
finished: vi.fn().mockResolvedValue({ documentId: document.id }),
} as unknown as Awaited<ReturnType<DocumentImportTask["schedule"]>>);
const content = await readFile(
@@ -3366,7 +3366,7 @@ describe("#documents.import", () => {
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(document.id);
jest.restoreAllMocks();
vi.restoreAllMocks();
});
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",
"message": "Authentication required",
@@ -12,7 +12,7 @@ import { getTestServer } from "@server/test/support";
const server = getTestServer();
jest.mock("@server/storage/files");
vi.mock("@server/storage/files");
describe("#fileOperations.info", () => {
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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"message": "Authentication required",
+25 -8
View File
@@ -1,3 +1,4 @@
import type { Mock } from "vitest";
import { UnfurlResourceType } from "@shared/types";
import env from "@server/env";
import type { User } from "@server/models";
@@ -10,22 +11,38 @@ import {
import { getTestServer } from "@server/test/support";
import Iframely from "plugins/iframely/server/iframely";
jest.mock("dns", () => ({
resolveCname: (
const resolveCname = vi.hoisted(
() =>
(
input: string,
callback: (err: Error | null, addresses: string[]) => void
) => {
if (input.includes("valid.custom.domain")) {
callback(null, ["secure.outline.dev"]);
} else {
return;
}
callback(null, []);
}
);
vi.mock("node:dns", () => ({
default: {
resolveCname,
},
resolveCname,
}));
jest
.spyOn(Iframely, "requestResource")
.mockImplementation(() => Promise.resolve({}));
vi.mock("dns", () => ({
default: {
resolveCname,
},
resolveCname,
}));
vi.spyOn(Iframely, "requestResource").mockImplementation(() =>
Promise.resolve({})
);
const server = getTestServer();
@@ -287,7 +304,7 @@ describe("#urls.unfurl", () => {
});
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({
url: "https://www.flickr.com",
type: "rich",
@@ -343,7 +360,7 @@ describe("#urls.unfurl", () => {
});
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({
status: 404,
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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"message": "Authentication required",
+3 -3
View File
@@ -14,10 +14,10 @@ import { getTestServer } from "@server/test/support";
const server = getTestServer();
beforeAll(() => {
jest.useFakeTimers().setSystemTime(new Date("2018-01-02T00:00:00.000Z"));
vi.useFakeTimers().setSystemTime(new Date("2018-01-02T00:00:00.000Z"));
});
afterAll(() => {
jest.useRealTimers();
vi.useRealTimers();
});
describe("#users.list", () => {
@@ -766,7 +766,7 @@ describe("#users.update", () => {
describe("#users.updateEmail", () => {
describe("post", () => {
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 res = await server.post("/api/users.updateEmail", {
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",
"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",
"message": "Authentication required",
-4
View File
@@ -233,10 +233,6 @@ export function createMigrationRunner(
* See https://github.com/sequelize/sequelize/issues/14807#issuecomment-1854398131
*/
export function monkeyPatchSequelizeErrorsForJest(instance: Sequelize) {
if (typeof jest === "undefined") {
return instance;
}
const sequelizeVersion = (Sequelize as unknown as { version: string })
.version;
const major = sequelizeVersion.split(".").map(Number)[0];
+7 -5
View File
@@ -1,11 +1,13 @@
import { vi } from "vitest";
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 { EventEmitter } from "node:events";
import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest";
import sharedEnv from "@shared/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
// This needs to be done before any modules that use EventEmitter are loaded
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
jest.mock("@aws-sdk/client-s3", () => ({
S3Client: jest.fn(() => ({
send: jest.fn(),
vi.mock("@aws-sdk/client-s3", () => ({
S3Client: vi.fn(() => ({
send: vi.fn(),
})),
DeleteObjectCommand: jest.fn(),
GetObjectCommand: jest.fn(),
DeleteObjectCommand: vi.fn(),
GetObjectCommand: vi.fn(),
ObjectCannedACL: {},
}));
jest.mock("@aws-sdk/lib-storage", () => ({
Upload: jest.fn(() => ({
done: jest.fn(),
vi.mock("@aws-sdk/lib-storage", () => ({
Upload: vi.fn(() => ({
done: vi.fn(),
})),
}));
jest.mock("@aws-sdk/s3-presigned-post", () => ({
createPresignedPost: jest.fn(),
vi.mock("@aws-sdk/s3-presigned-post", () => ({
createPresignedPost: vi.fn(),
}));
jest.mock("@aws-sdk/s3-request-presigner", () => ({
getSignedUrl: jest.fn(),
vi.mock("@aws-sdk/s3-request-presigner", () => ({
getSignedUrl: vi.fn(),
}));
// Initialize the database models
require("@server/storage/database");
// Initialize the database models. Loaded dynamically so the
// 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(() => {
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 type { Transaction } from "sequelize";
import { afterEach, beforeEach, vi } from "vitest";
import sharedEnv from "@shared/env";
import { createContext } from "@server/context";
import env from "@server/env";
import type { User } from "@server/models";
import onerror from "@server/onerror";
import { BaseTask } from "@server/queues/tasks/base/BaseTask";
import webService from "@server/services/web";
import { sequelize } from "@server/storage/database";
import type { APIContext } from "@server/types";
@@ -33,6 +35,26 @@ export function setSelfHosted() {
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>(
user: User,
fn: (ctx: APIContext) => T
+17 -2
View File
@@ -1,14 +1,29 @@
import { vi } from "vitest";
export class MutexLock {
// Default expiry time for acquiring lock in milliseconds
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
*/
public static get lock() {
return {
acquire: jest.fn().mockResolvedValue({
release: jest.fn().mockResolvedValue(true),
acquire: vi.fn().mockResolvedValue({
release: vi.fn().mockResolvedValue(true),
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";
beforeEach(() => {
fetchMock.resetMocks();
});
const embedUrl = "https://www.example.com/embed";
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("when URL doesn't match any embed pattern", () => {
@@ -21,55 +36,48 @@ describe("checkEmbeddability", () => {
describe("when URL matches an embed pattern", () => {
it("should return embeddable: true when no restrictive headers", async () => {
fetchMock.mockResponseOnce("", {
status: 200,
headers: {},
});
mockEmbedResponse(embedUrl);
const result = await checkEmbeddability("https://www.example.com/embed");
const result = await checkEmbeddability(embedUrl);
expect(result).toEqual({ embeddable: true });
});
it("should return embeddable: false when X-Frame-Options: DENY", async () => {
fetchMock.mockResponseOnce("", {
status: 200,
mockEmbedResponse(embedUrl, {
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" });
});
it("should return embeddable: false when X-Frame-Options: SAMEORIGIN", async () => {
fetchMock.mockResponseOnce("", {
status: 200,
mockEmbedResponse(embedUrl, {
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" });
});
it("should return embeddable: false when X-Frame-Options: ALLOW-FROM", async () => {
fetchMock.mockResponseOnce("", {
status: 200,
mockEmbedResponse(embedUrl, {
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" });
});
it("should return embeddable: false when CSP frame-ancestors is 'none'", async () => {
fetchMock.mockResponseOnce("", {
status: 200,
mockEmbedResponse(embedUrl, {
headers: {
"Content-Security-Policy":
"default-src 'self'; frame-ancestors 'none'",
},
});
const result = await checkEmbeddability("https://www.example.com/embed");
const result = await checkEmbeddability(embedUrl);
expect(result).toEqual({
embeddable: false,
reason: "csp-frame-ancestors",
@@ -77,14 +85,13 @@ describe("checkEmbeddability", () => {
});
it("should return embeddable: false when CSP frame-ancestors is 'self'", async () => {
fetchMock.mockResponseOnce("", {
status: 200,
mockEmbedResponse(embedUrl, {
headers: {
"Content-Security-Policy": "frame-ancestors 'self'",
},
});
const result = await checkEmbeddability("https://www.example.com/embed");
const result = await checkEmbeddability(embedUrl);
expect(result).toEqual({
embeddable: false,
reason: "csp-frame-ancestors",
@@ -92,26 +99,24 @@ describe("checkEmbeddability", () => {
});
it("should return embeddable: true when CSP frame-ancestors is *", async () => {
fetchMock.mockResponseOnce("", {
status: 200,
mockEmbedResponse(embedUrl, {
headers: {
"Content-Security-Policy": "frame-ancestors *",
},
});
const result = await checkEmbeddability("https://www.example.com/embed");
const result = await checkEmbeddability(embedUrl);
expect(result).toEqual({ embeddable: true });
});
it("should return embeddable: false when CSP frame-ancestors has specific origins", async () => {
fetchMock.mockResponseOnce("", {
status: 200,
mockEmbedResponse(embedUrl, {
headers: {
"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({
embeddable: false,
reason: "csp-frame-ancestors",
@@ -119,60 +124,52 @@ describe("checkEmbeddability", () => {
});
it("should return embeddable: false when COEP is require-corp", async () => {
fetchMock.mockResponseOnce("", {
status: 200,
mockEmbedResponse(embedUrl, {
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" });
});
it("should return embeddable: true when COEP is unsafe-none", async () => {
fetchMock.mockResponseOnce("", {
status: 200,
mockEmbedResponse(embedUrl, {
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 });
});
it("should return embeddable: false when server returns 403", async () => {
fetchMock.mockResponseOnce("", {
status: 403,
headers: {},
});
const url = "https://www.example.com/forbiddenpage";
mockEmbedResponse(url, { status: 403 });
const result = await checkEmbeddability(
"https://www.example.com/forbiddenpage"
);
const result = await checkEmbeddability(url);
expect(result).toEqual({ embeddable: false, reason: "http-error" });
});
it("should return embeddable: false when server returns 404", async () => {
fetchMock.mockResponseOnce("", {
status: 404,
headers: {},
});
const url = "https://www.example.com/nonexistentpage";
mockEmbedResponse(url, { status: 404 });
const result = await checkEmbeddability(
"https://www.example.com/nonexistentpage"
);
const result = await checkEmbeddability(url);
expect(result).toEqual({ embeddable: false, reason: "http-error" });
});
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" });
});
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" });
});
});
+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.
*
@@ -119,6 +138,22 @@ export function getFilenamesInDirectory(dirName: string): string[] {
* @returns An array of tuples containing the required files and their names.
*/
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) => {
const filePath = path.join(dirName, fileName);
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";
beforeEach(() => {
fetchMock.resetMocks();
});
const dockerHubUrl =
"https://hub.docker.com/v2/repositories/outlinewiki/outline/tags";
describe("getVersion", () => {
it("should return the current version", () => {
@@ -17,11 +17,13 @@ describe("getVersionInfo", () => {
const currentVersion = "0.80.0";
it("should return version info when Docker Hub is accessible", async () => {
fetchMock.mockResponseOnce(
JSON.stringify({
server.use(
http.get(dockerHubUrl, () =>
HttpResponse.json({
results: [{ name: "0.81.0" }, { name: "0.80.0" }, { name: "0.79.0" }],
next: null,
})
)
);
const result = await getVersionInfo(currentVersion);
@@ -33,7 +35,7 @@ describe("getVersionInfo", () => {
});
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);
@@ -44,7 +46,7 @@ describe("getVersionInfo", () => {
});
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);
@@ -55,7 +57,7 @@ describe("getVersionInfo", () => {
});
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);
@@ -66,7 +68,12 @@ describe("getVersionInfo", () => {
});
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);
+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";
class MinimalOAuthClient extends OAuthClient {
@@ -9,16 +10,15 @@ class MinimalOAuthClient extends OAuthClient {
};
}
beforeEach(() => {
fetchMock.resetMocks();
});
describe("userInfo", () => {
it("should work with empty-body 401 Unauthorized responses", async () => {
fetchMock.mockResponseOnce("", {
status: 401,
statusText: "unauthorized",
});
server.use(
http.get(
"http://example.com/userinfo",
() =>
new HttpResponse(null, { status: 401, statusText: "unauthorized" })
)
);
const client = new MinimalOAuthClient("clientid", "clientsecret");
try {
-1
View File
@@ -1,4 +1,3 @@
import { expect } from "@jest/globals";
import { randomUUID } from "node:crypto";
import env from "@server/env";
import parseAttachmentIds from "./parseAttachmentIds";
+3 -2
View File
@@ -1,12 +1,13 @@
import dns from "node:dns";
import type { MockInstance } from "vitest";
import env from "@server/env";
import { validateUrlNotPrivate } from "./url";
describe("validateUrlNotPrivate", () => {
let lookupSpy: jest.SpyInstance;
let lookupSpy: MockInstance;
beforeEach(() => {
lookupSpy = jest
lookupSpy = vi
.spyOn(dns.promises, "lookup")
.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";
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 {};
+3 -3
View File
@@ -2,12 +2,12 @@ import { TextHelper } from "./TextHelper";
describe("TextHelper", () => {
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(Date.parse("2021-01-01T00:00:00.000Z"));
vi.useFakeTimers();
vi.setSystemTime(Date.parse("2021-01-01T00:00:00.000Z"));
});
afterAll(() => {
jest.useRealTimers();
vi.useRealTimers();
});
describe("replaceTemplateVariables", () => {
+1 -1
View File
@@ -3,7 +3,7 @@ import { bytesToHumanReadable, getFileNameFromUrl } from "./files";
// Mock the browser detection with a mutable value
let mockIsMacValue = false;
jest.mock("./browser", () => ({
vi.mock("./browser", () => ({
get isMac() {
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