chore: Reduce no-explicit-any warnings in server directory (#12202)

* chore: Reduce no-explicit-any warnings in server directory

Tightens types across test response bodies, decorator signatures, the
TestServer wrapper, base class generics, and presenter Record types.
Where any is genuinely load-bearing (Sequelize model generics,
PropertyDescriptor decorator returns, plugin-registered template
classes, Fix mixin), keeps any with a targeted eslint-disable plus
reason rather than masking the constraint. Cuts server-only
no-explicit-any warnings from 162 to 70.

* fix: groups test asserts on first response instead of second

Caught by Copilot review on the no-explicit-any cleanup. Also fixes
the pre-existing getChangsetSkipped → getChangesetSkipped typo
surfaced while reviewing nearby decorator code.
This commit is contained in:
Tom Moor
2026-04-28 19:50:45 -04:00
committed by GitHub
parent 9b7ccf8cb5
commit 5610df5a26
35 changed files with 192 additions and 112 deletions
+1
View File
@@ -2,6 +2,7 @@ import { Hook, PluginManager } from "@server/utils/PluginManager";
import { requireDirectory } from "@server/utils/fs";
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>> = {};
requireDirectory<{ default: typeof BaseEmail }>(__dirname).forEach(
+4 -4
View File
@@ -1,5 +1,5 @@
import { buildTeam } from "@server/test/factories";
import { AuthenticationProvider } from "@server/models";
import { AuthenticationProvider, type User } from "@server/models";
import { createContext } from "@server/context";
describe("AuthenticationProvider", () => {
@@ -15,7 +15,7 @@ describe("AuthenticationProvider", () => {
enabled: true,
});
const ctx = createContext({ user: { team } as any });
const ctx = createContext({ user: { team } as unknown as User });
await expect(provider.disable(ctx)).resolves.not.toThrow();
expect(provider.enabled).toBe(false);
});
@@ -37,7 +37,7 @@ describe("AuthenticationProvider", () => {
enabled: true,
});
const ctx = createContext({ user: { team } as any });
const ctx = createContext({ user: { team } as unknown as User });
await expect(provider1.disable(ctx)).resolves.not.toThrow();
expect(provider1.enabled).toBe(false);
});
@@ -59,7 +59,7 @@ describe("AuthenticationProvider", () => {
enabled: true,
});
const ctx = createContext({ user: { team } as any });
const ctx = createContext({ user: { team } as unknown as User });
await expect(provider.disable(ctx)).rejects.toThrow(
"At least one authentication provider is required"
);
+2 -1
View File
@@ -49,6 +49,7 @@ import {
import isUUID from "validator/lib/isUUID";
import type {
CollectionSort,
ImportableIntegrationService,
ProsemirrorData,
SourceMetadata,
NavigationNode,
@@ -510,7 +511,7 @@ class Collection extends ParanoidModel<
importId: string | null;
@BelongsTo(() => Import, "apiImportId")
apiImport: Import<any> | null;
apiImport: Import<ImportableIntegrationService> | null;
@ForeignKey(() => Import)
@Column(DataType.UUID)
+2 -1
View File
@@ -40,6 +40,7 @@ import {
import { MaxLength } from "class-validator";
import isUUID from "validator/lib/isUUID";
import type {
ImportableIntegrationService,
NavigationNode,
ProsemirrorData,
SourceMetadata,
@@ -609,7 +610,7 @@ class Document extends ArchivableModel<
importId: string | null;
@BelongsTo(() => Import, "apiImportId")
apiImport: Import<any> | null;
apiImport: Import<ImportableIntegrationService> | null;
@ForeignKey(() => Import)
@Column(DataType.UUID)
+1
View File
@@ -2,6 +2,7 @@ import { AllowNull, Column, IsDate } from "sequelize-typescript";
import ParanoidModel from "./ParanoidModel";
class ArchivableModel<
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mirrors Model base; tightening to object resolves Attributes<M> to never inside Sequelize helpers.
TModelAttributes extends object = any,
TCreationAttributes extends object = TModelAttributes,
> extends ParanoidModel<TModelAttributes, TCreationAttributes> {
+1
View File
@@ -10,6 +10,7 @@ import {
import Model from "./Model";
class IdModel<
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mirrors Model base; tightening to object resolves Attributes<M> to never inside Sequelize helpers.
TModelAttributes extends object = any,
TCreationAttributes extends object = TModelAttributes,
> extends Model<TModelAttributes, TCreationAttributes> {
+3 -2
View File
@@ -24,7 +24,7 @@ import {
} from "sequelize-typescript";
import Logger from "@server/logging/Logger";
import type { Replace, APIContext } from "@server/types";
import { getChangsetSkipped } from "../decorators/Changeset";
import { getChangesetSkipped } from "../decorators/Changeset";
import { InternalError } from "@server/errors";
type EventOverrideOptions = {
@@ -48,6 +48,7 @@ type EventOptions = EventOverrideOptions & {
export type HookContext = APIContext["context"] & { event?: EventOptions };
class Model<
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Sequelize's Attributes<M> resolves to never under object default; load-bearing for static helpers like saveWithCtx<M extends Model>.
TModelAttributes extends object = any,
TCreationAttributes extends object = TModelAttributes,
> extends SequelizeModel<TModelAttributes, TCreationAttributes> {
@@ -392,7 +393,7 @@ class Model<
const virtualFields = (this.constructor as typeof Model).virtualFields;
const blobFields = (this.constructor as typeof Model).blobFields;
const skippedFields = getChangsetSkipped(this);
const skippedFields = getChangesetSkipped(this);
for (const change of changes) {
const previous = this.previous(change);
+1
View File
@@ -2,6 +2,7 @@ import { DeletedAt } from "sequelize-typescript";
import IdModel from "./IdModel";
class ParanoidModel<
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mirrors Model base; tightening to object resolves Attributes<M> to never inside Sequelize helpers.
TModelAttributes extends object = any,
TCreationAttributes extends object = TModelAttributes,
> extends IdModel<TModelAttributes, TCreationAttributes> {
+2 -2
View File
@@ -5,7 +5,7 @@ const key = Symbol("skipChangeset");
/**
* This decorator is used to annotate a property as being skipped from being included in a changeset.
*/
export function SkipChangeset(target: any, propertyKey: string) {
export function SkipChangeset(target: object, propertyKey: string) {
const properties: string[] = Reflect.getMetadata(key, target);
if (!properties) {
@@ -18,6 +18,6 @@ export function SkipChangeset(target: any, propertyKey: string) {
/**
* This function is used to get the properties that should be skipped from a changeset.
*/
export function getChangsetSkipped(target: any): string[] {
export function getChangesetSkipped(target: object): string[] {
return Reflect.getMetadata(key, target) || [];
}
+1
View File
@@ -78,6 +78,7 @@ export function CounterCache<
});
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TS rejects PropertyDescriptor as legacy decorator return type; descriptor is consumed by Sequelize at runtime.
} as any;
};
}
+2 -1
View File
@@ -11,7 +11,7 @@ const key = "sequelize:vault";
* so that it can be used by getters and setters. Must be accompanied by a
* @Column(DataType.BLOB) annotation.
*/
export default function Encrypted(target: any, propertyKey: string) {
export default function Encrypted(target: object, propertyKey: string) {
// Ensure that the Encrypted decorator is the first decorator applied to the property, we can check
// this by looking at the attributes of the target and checking if the propertyKey is already defined.
if (getAttributes(target)[propertyKey]) {
@@ -71,5 +71,6 @@ export default function Encrypted(target: any, propertyKey: string) {
throw err;
}
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TS rejects PropertyDescriptor return for legacy decorator; descriptor is consumed by Sequelize at runtime.
} as any;
}
+3
View File
@@ -6,8 +6,10 @@
* @param target model class
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- decorator extends a Sequelize Model class with dynamic statics (rawAttributes, associations) that defy precise typing.
export default function Fix(target: any): void {
return class extends target {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mixin constructor must accept arbitrary Sequelize Model args.
constructor(...args: any[]) {
// suppresses warning from here which is not applicable in our typescript
// environment: https://github.com/sequelize/sequelize/blob/00ced18c2cb2a8b99ae0ebf5669c124abb4c673d/src/model.js#L99
@@ -63,5 +65,6 @@ export default function Fix(target: any): void {
});
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mixin class returned in place of original target; consumer expects original class shape.
} as any;
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { addAttributeOptions } from "sequelize-typescript";
/**
* A decorator that validates that a string is a fully qualified domain name.
*/
export default function IsFQDN(target: any, propertyName: string) {
export default function IsFQDN(target: object, propertyName: string) {
return addAttributeOptions(target, propertyName, {
validate: {
validDomain(value: string) {
+1 -1
View File
@@ -4,7 +4,7 @@ import { addAttributeOptions } from "sequelize-typescript";
/**
* A decorator that validates that a string is a valid hex color code.
*/
export default function IsHexColor(target: any, propertyName: string) {
export default function IsHexColor(target: object, propertyName: string) {
return addAttributeOptions(target, propertyName, {
validate: {
validDomain(value: string) {
@@ -4,7 +4,10 @@ import { addAttributeOptions } from "sequelize-typescript";
/**
* A decorator that validates that a string is a url or relative path.
*/
export default function IsUrlOrRelativePath(target: any, propertyName: string) {
export default function IsUrlOrRelativePath(
target: object,
propertyName: string
) {
return addAttributeOptions(target, propertyName, {
validate: {
validUrlOrPath(value: string) {
+2 -2
View File
@@ -13,8 +13,8 @@ export default function Length({
msg?: string;
min?: number;
max: number;
}): (target: any, propertyName: string) => void {
return (target: any, propertyName: string) =>
}): (target: object, propertyName: string) => void {
return (target: object, propertyName: string) =>
addAttributeOptions(target, propertyName, {
validate: {
validLength(value: string) {
+1 -1
View File
@@ -4,7 +4,7 @@ import { addAttributeOptions } from "sequelize-typescript";
* A decorator that validates that a string does not include something that
* looks like a URL.
*/
export default function NotContainsUrl(target: any, propertyName: string) {
export default function NotContainsUrl(target: object, propertyName: string) {
return addAttributeOptions(target, propertyName, {
validate: {
not: {
+2 -2
View File
@@ -17,8 +17,8 @@ export default function TextLength({
msg?: string;
min?: number;
max: number;
}): (target: any, propertyName: string) => void {
return (target: any, propertyName: string) =>
}): (target: object, propertyName: string) => void {
return (target: object, propertyName: string) =>
addAttributeOptions(target, propertyName, {
validate: {
validLength(value: ProsemirrorData) {
+23 -6
View File
@@ -8,15 +8,32 @@ jest.mock("@server/logging/sentry", () => ({
requestErrorHandler: jest.fn(),
}));
type MockCtx = {
headers: Record<string, string>;
headerSent: boolean;
writable: boolean;
accepts: jest.Mock;
set: jest.Mock;
res: { end: jest.Mock };
status: number | undefined;
type: string | undefined;
body: unknown;
};
type ReportableError = Error & {
status?: number;
isReportable?: boolean;
};
describe("onerror", () => {
let app: Koa;
let ctx: any;
let ctx: MockCtx;
beforeEach(() => {
// Create a mock Koa app
app = {
context: {},
} as any;
} as unknown as Koa;
// Apply the onerror middleware
onerror(app);
@@ -68,7 +85,7 @@ describe("onerror", () => {
});
it("should report unknown errors without isReportable property to Sentry", () => {
const error = new Error("Unknown error") as any;
const error = new Error("Unknown error") as ReportableError;
error.status = 500;
app.context.onerror.call(ctx, error);
@@ -77,7 +94,7 @@ describe("onerror", () => {
});
it("should report errors with invalid status codes to Sentry", () => {
const error = new Error("Invalid status error") as any;
const error = new Error("Invalid status error") as ReportableError;
error.status = 999;
app.context.onerror.call(ctx, error);
@@ -86,7 +103,7 @@ describe("onerror", () => {
});
it("should not report errors explicitly marked with isReportable: false", () => {
const error = new Error("Custom error") as any;
const error = new Error("Custom error") as ReportableError;
error.status = 500;
error.isReportable = false;
@@ -96,7 +113,7 @@ describe("onerror", () => {
});
it("should report errors explicitly marked with isReportable: true", () => {
const error = new Error("Custom error") as any;
const error = new Error("Custom error") as ReportableError;
error.status = 400;
error.isReportable = true;
+1 -1
View File
@@ -20,7 +20,7 @@ export default async function presentCollection(
) {
const asData = !ctx || Number(ctx?.headers["x-api-version"] ?? 0) >= 3;
const res: Record<string, any> = {
const res: Record<string, unknown> = {
id: collection.id,
url: collection.path,
urlId: collection.urlId,
+11 -10
View File
@@ -7,8 +7,11 @@ import type { Document, User, Group } from "@server/models";
import { View } from "@server/models";
import { opts } from "@server/utils/i18n";
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- heterogeneous payload from internal callers and third-party unfurl plugins.
type UnfurlData = Record<string, any>;
async function presentUnfurl(
data: Record<string, any>,
data: UnfurlData,
options?: { includeEmail: boolean }
) {
switch (data.type) {
@@ -30,7 +33,7 @@ async function presentUnfurl(
}
const presentURL = (
data: Record<string, any>
data: UnfurlData
): UnfurlResponse[UnfurlResourceType.URL] => {
// TODO: For backwards compatibility, remove once cache has expired in next release.
if (data.transformedUnfurl) {
@@ -49,7 +52,7 @@ const presentURL = (
};
const presentMention = async (
data: Record<string, any>,
data: UnfurlData,
options?: { includeEmail: boolean }
): Promise<UnfurlResponse[UnfurlResourceType.Mention]> => {
const user: User = data.user;
@@ -69,7 +72,7 @@ const presentMention = async (
};
const presentGroup = async (
data: Record<string, any>
data: UnfurlData
): Promise<UnfurlResponse[UnfurlResourceType.Group]> => {
const group: Group = data.group;
const memberCount = await group.memberCount;
@@ -89,7 +92,7 @@ const presentGroup = async (
};
const presentDocument = (
data: Record<string, any>
data: UnfurlData
): UnfurlResponse[UnfurlResourceType.Document] => {
const document: Document = data.document;
const viewer: User | undefined = data.viewer;
@@ -106,18 +109,16 @@ const presentDocument = (
};
};
const presentPR = (
data: Record<string, any>
): UnfurlResponse[UnfurlResourceType.PR] =>
const presentPR = (data: UnfurlData): UnfurlResponse[UnfurlResourceType.PR] =>
data as UnfurlResponse[UnfurlResourceType.PR]; // this would have been transformed by the unfurl plugin.
const presentIssue = (
data: Record<string, any>
data: UnfurlData
): UnfurlResponse[UnfurlResourceType.Issue] =>
data as UnfurlResponse[UnfurlResourceType.Issue]; // this would have been transformed by the unfurl plugin.
const presentProject = (
data: Record<string, any>
data: UnfurlData
): UnfurlResponse[UnfurlResourceType.Project] =>
data as UnfurlResponse[UnfurlResourceType.Project]; // this would have been transformed by the unfurl plugin.
@@ -7,6 +7,8 @@ import {
import SearchProviderManager from "@server/utils/SearchProviderManager";
import SearchIndexProcessor from "./SearchIndexProcessor";
type PerformArg = Parameters<SearchIndexProcessor["perform"]>[0];
const processor = new SearchIndexProcessor();
describe("SearchIndexProcessor", () => {
@@ -48,7 +50,7 @@ describe("SearchIndexProcessor", () => {
collectionId: collection.id,
teamId: user.teamId,
actorId: user.id,
} as any);
} as PerformArg);
expect(indexSpy).toHaveBeenCalledWith(
SearchableModel.Document,
@@ -69,7 +71,7 @@ describe("SearchIndexProcessor", () => {
collectionId: "some-collection-id",
teamId: user.teamId,
actorId: user.id,
} as any);
} as PerformArg);
expect(removeSpy).toHaveBeenCalledWith(
SearchableModel.Document,
+2 -2
View File
@@ -8,7 +8,7 @@ export enum TaskPriority {
High = 10,
}
export abstract class BaseTask<T extends Record<string, any>> {
export abstract class BaseTask<T extends object> {
/**
* Schedule this task type to be processed asynchronously by a worker.
*
@@ -32,7 +32,7 @@ export abstract class BaseTask<T extends Record<string, any>> {
* @param props Properties to be used by the task
* @returns A promise that resolves once the task has completed.
*/
public abstract perform(props: T): Promise<any>;
public abstract perform(props: T): Promise<unknown>;
/**
* Handle failure when all attempts are exhausted for the task.
+3 -1
View File
@@ -35,7 +35,9 @@ describe("#auth.info", () => {
const body = await res.json();
expect(res.status).toEqual(200);
const availableTeamIds = body.data.availableTeams.map((t: any) => t.id);
const availableTeamIds = body.data.availableTeams.map(
(t: { id: string }) => t.id
);
expect(availableTeamIds.length).toEqual(3);
expect(availableTeamIds).toContain(team.id);
+10 -6
View File
@@ -834,7 +834,7 @@ describe("#documents.list", () => {
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data).toHaveLength(2);
const docIds = body.data.map((doc: any) => doc.id);
const docIds = body.data.map((doc: { id: string }) => doc.id);
expect(docIds).toContain(docs[0].id);
expect(docIds).toContain(docs[1].id);
expect(docIds).not.toContain(docs[2].id);
@@ -2033,7 +2033,9 @@ describe("#documents.search", () => {
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data).toHaveLength(2);
const returnedIds = body.data.map((d: any) => d.document.id).sort();
const returnedIds = body.data
.map((d: { document: { id: string } }) => d.document.id)
.sort();
const expectedIds = docsInCollection1.map((d) => d.id).sort();
expect(returnedIds).toEqual(expectedIds);
});
@@ -3218,7 +3220,7 @@ describe("#documents.restore", () => {
revisionId,
},
headers: {
"x-api-version": 3,
"x-api-version": "3",
},
});
const body = await res.json();
@@ -3333,10 +3335,12 @@ describe("#documents.import", () => {
collectionId: collection.id,
});
jest.spyOn(FileStorage, "store").mockResolvedValue(undefined as any);
jest
.spyOn(FileStorage, "store")
.mockResolvedValue(undefined as unknown as string);
jest.spyOn(DocumentImportTask.prototype, "schedule").mockResolvedValue({
finished: jest.fn().mockResolvedValue({ documentId: document.id }),
} as any);
} as unknown as Awaited<ReturnType<DocumentImportTask["schedule"]>>);
const content = await readFile(
path.resolve(
@@ -5765,7 +5769,7 @@ describe("#documents.documents", () => {
expect(res.status).toBe(200);
expect(body.data.id).toBe(parent.id);
const childIds = body.data.children.map((node: any) => node.id);
const childIds = body.data.children.map((node: { id: string }) => node.id);
expect(childIds).toContain(child1.id);
expect(childIds).toContain(child2.id);
});
+1 -1
View File
@@ -472,7 +472,7 @@ describe("#events.list", () => {
expect(res.status).toEqual(200);
// admin SHOULD see events for documents without a collection
const eventIds = body.data.map((e: any) => e.id);
const eventIds = body.data.map((e: { id: string }) => e.id);
expect(eventIds).toContain(draftEvent.id);
});
+9 -5
View File
@@ -293,11 +293,13 @@ describe("#groups.list", () => {
expect(body.data.groupMemberships[0].groupId).toEqual(group.id);
expect(body.data.groupMemberships[1].groupId).toEqual(group.id);
expect(
body.data.groupMemberships.map((u: any) => u.user.id).includes(user.id)
body.data.groupMemberships
.map((u: { user: { id: string } }) => u.user.id)
.includes(user.id)
).toBe(true);
expect(
body.data.groupMemberships
.map((u: any) => u.user.id)
.map((u: { user: { id: string } }) => u.user.id)
.includes(anotherUser.id)
).toBe(true);
expect(body.policies.length).toEqual(2);
@@ -317,11 +319,13 @@ describe("#groups.list", () => {
expect(anotherBody.data.groupMemberships[0].groupId).toEqual(group.id);
expect(anotherBody.data.groupMemberships[1].groupId).toEqual(group.id);
expect(
body.data.groupMemberships.map((u: any) => u.user.id).includes(user.id)
anotherBody.data.groupMemberships
.map((u: { user: { id: string } }) => u.user.id)
.includes(user.id)
).toBe(true);
expect(
body.data.groupMemberships
.map((u: any) => u.user.id)
anotherBody.data.groupMemberships
.map((u: { user: { id: string } }) => u.user.id)
.includes(anotherUser.id)
).toBe(true);
});
@@ -12,6 +12,12 @@ import {
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
type NotificationItem = {
actor: { id: string };
userId: string;
event: NotificationEventType;
};
const server = getTestServer();
describe("#notifications.list", () => {
@@ -69,13 +75,15 @@ describe("#notifications.list", () => {
expect(body.data.notifications.length).toBe(3);
expect(body.pagination.total).toBe(3);
expect(body.data.unseen).toBe(2);
expect((randomElement(body.data.notifications) as any).actor.id).toBe(
actor.id
expect(
randomElement<NotificationItem>(body.data.notifications).actor.id
).toBe(actor.id);
expect(
randomElement<NotificationItem>(body.data.notifications).userId
).toBe(user.id);
const events = body.data.notifications.map(
(n: NotificationItem) => n.event
);
expect((randomElement(body.data.notifications) as any).userId).toBe(
user.id
);
const events = body.data.notifications.map((n: any) => n.event);
expect(events).toContain(NotificationEventType.UpdateDocument);
expect(events).toContain(NotificationEventType.CreateComment);
expect(events).toContain(NotificationEventType.MentionedInComment);
@@ -134,13 +142,15 @@ describe("#notifications.list", () => {
expect(body.data.notifications.length).toBe(1);
expect(body.pagination.total).toBe(1);
expect(body.data.unseen).toBe(1);
expect((randomElement(body.data.notifications) as any).actor.id).toBe(
actor.id
expect(
randomElement<NotificationItem>(body.data.notifications).actor.id
).toBe(actor.id);
expect(
randomElement<NotificationItem>(body.data.notifications).userId
).toBe(user.id);
const events = body.data.notifications.map(
(n: NotificationItem) => n.event
);
expect((randomElement(body.data.notifications) as any).userId).toBe(
user.id
);
const events = body.data.notifications.map((n: any) => n.event);
expect(events).toContain(NotificationEventType.MentionedInComment);
});
@@ -199,13 +209,15 @@ describe("#notifications.list", () => {
expect(body.data.notifications.length).toBe(2);
expect(body.pagination.total).toBe(2);
expect(body.data.unseen).toBe(2);
expect((randomElement(body.data.notifications) as any).actor.id).toBe(
actor.id
expect(
randomElement<NotificationItem>(body.data.notifications).actor.id
).toBe(actor.id);
expect(
randomElement<NotificationItem>(body.data.notifications).userId
).toBe(user.id);
const events = body.data.notifications.map(
(n: NotificationItem) => n.event
);
expect((randomElement(body.data.notifications) as any).userId).toBe(
user.id
);
const events = body.data.notifications.map((n: any) => n.event);
expect(events).toContain(NotificationEventType.CreateComment);
expect(events).toContain(NotificationEventType.UpdateDocument);
});
@@ -265,13 +277,15 @@ describe("#notifications.list", () => {
expect(body.data.notifications.length).toBe(1);
expect(body.pagination.total).toBe(1);
expect(body.data.unseen).toBe(1);
expect((randomElement(body.data.notifications) as any).actor.id).toBe(
actor.id
expect(
randomElement<NotificationItem>(body.data.notifications).actor.id
).toBe(actor.id);
expect(
randomElement<NotificationItem>(body.data.notifications).userId
).toBe(user.id);
const events = body.data.notifications.map(
(n: NotificationItem) => n.event
);
expect((randomElement(body.data.notifications) as any).userId).toBe(
user.id
);
const events = body.data.notifications.map((n: any) => n.event);
expect(events).toContain(NotificationEventType.MentionedInComment);
});
});
+2 -2
View File
@@ -302,10 +302,10 @@ describe("#pins.list", () => {
expect(body.data).toBeTruthy();
expect(body.data.pins).toBeTruthy();
expect(body.data.pins).toHaveLength(2);
const pinIds = body.data.pins.map((p: any) => p.id);
const pinIds = body.data.pins.map((p: { id: string }) => p.id);
expect(pinIds).toContain(pins[0].id);
expect(pinIds).toContain(pins[1].id);
const docIds = body.data.documents.map((d: any) => d.id);
const docIds = body.data.documents.map((d: { id: string }) => d.id);
expect(docIds).toContain(docs[0].id);
expect(docIds).toContain(docs[1].id);
});
@@ -155,7 +155,9 @@ describe("#relationships.info", () => {
expect(body.data.relationship).toBeTruthy();
expect(body.data.documents).toHaveLength(2);
// User can read their own document but admin document should also be included
const documentIds = body.data.documents.map((doc: any) => doc.id);
const documentIds = body.data.documents.map(
(doc: { id: string }) => doc.id
);
expect(documentIds).toContain(userDocument.id);
});
@@ -172,7 +174,9 @@ describe("#relationships.info", () => {
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.documents).toHaveLength(2);
const documentIds = body.data.documents.map((doc: any) => doc.id);
const documentIds = body.data.documents.map(
(doc: { id: string }) => doc.id
);
expect(documentIds).toContain(document.id);
expect(documentIds).toContain(reverseDocument.id);
});
@@ -267,7 +271,7 @@ describe("#relationships.list", () => {
expect(body.data.relationships).toBeTruthy();
// All returned relationships should be backlinks
body.data.relationships.forEach((rel: any) => {
body.data.relationships.forEach((rel: { type: RelationshipType }) => {
expect(rel.type).toEqual(RelationshipType.Backlink);
});
});
@@ -285,7 +289,7 @@ describe("#relationships.list", () => {
expect(body.data.relationships).toBeTruthy();
// All returned relationships should have the specified documentId
body.data.relationships.forEach((rel: any) => {
body.data.relationships.forEach((rel: { documentId: string }) => {
expect(rel.documentId).toEqual(documents[0].id);
});
});
@@ -303,7 +307,7 @@ describe("#relationships.list", () => {
expect(body.data.relationships).toBeTruthy();
// All returned relationships should have the specified reverseDocumentId
body.data.relationships.forEach((rel: any) => {
body.data.relationships.forEach((rel: { reverseDocumentId: string }) => {
expect(rel.reverseDocumentId).toEqual(documents[1].id);
});
});
@@ -322,10 +326,12 @@ describe("#relationships.list", () => {
expect(body.data.relationships).toBeTruthy();
// All returned relationships should match both filters
body.data.relationships.forEach((rel: any) => {
expect(rel.type).toEqual(RelationshipType.Backlink);
expect(rel.documentId).toEqual(documents[0].id);
});
body.data.relationships.forEach(
(rel: { type: RelationshipType; documentId: string }) => {
expect(rel.type).toEqual(RelationshipType.Backlink);
expect(rel.documentId).toEqual(documents[0].id);
}
);
});
it("should fail with status 400 bad request when documentId is invalid", async () => {
+1 -1
View File
@@ -39,7 +39,7 @@ describe("#searches.list", () => {
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data).toHaveLength(3);
const queries = body.data.map((d: any) => d.query);
const queries = body.data.map((d: { query: string }) => d.query);
expect(queries).toContain("query");
expect(queries).toContain("foo");
expect(queries).toContain("bar");
+27 -16
View File
@@ -3,13 +3,20 @@ import type { AddressInfo } from "node:net";
import type Koa from "koa";
// oxlint-disable-next-line no-restricted-imports
import nodeFetch from "node-fetch";
// oxlint-disable-next-line no-restricted-imports
import type { RequestInit } from "node-fetch";
type TestRequestOptions = Omit<RequestInit, "body" | "headers"> & {
body?: unknown;
headers?: Record<string, string>;
};
class TestServer {
private server: http.Server;
private listener?: Promise<void> | null;
constructor(app: Koa) {
this.server = http.createServer(app.callback() as any);
this.server = http.createServer(app.callback() as http.RequestListener);
}
get address(): string {
@@ -29,19 +36,23 @@ class TestServer {
return this.listener;
}
fetch(path: string, opts: any) {
fetch(path: string, opts: TestRequestOptions) {
return this.listen().then(() => {
const url = `${this.address}${path}`;
const options = Object.assign({ headers: {} }, opts);
const contentType =
options.headers["Content-Type"] ?? options.headers["content-type"];
const headers: Record<string, string> = { ...opts.headers };
let body = opts.body;
const contentType = headers["Content-Type"] ?? headers["content-type"];
// automatic JSON encoding
if (!contentType && typeof options.body === "object") {
options.headers["Content-Type"] = "application/json";
options.body = JSON.stringify(options.body);
if (!contentType && typeof body === "object" && body !== null) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(body);
}
return nodeFetch(url, options);
return nodeFetch(url, {
...opts,
headers,
body: body as string | undefined,
});
});
}
@@ -51,31 +62,31 @@ class TestServer {
this.server.close();
}
delete(path: string, options?: any) {
delete(path: string, options?: TestRequestOptions) {
return this.fetch(path, { ...options, method: "DELETE" });
}
get(path: string, options?: any) {
get(path: string, options?: TestRequestOptions) {
return this.fetch(path, { ...options, method: "GET" });
}
head(path: string, options?: any) {
head(path: string, options?: TestRequestOptions) {
return this.fetch(path, { ...options, method: "HEAD" });
}
options(path: string, options?: any) {
options(path: string, options?: TestRequestOptions) {
return this.fetch(path, { ...options, method: "OPTIONS" });
}
patch(path: string, options?: any) {
patch(path: string, options?: TestRequestOptions) {
return this.fetch(path, { ...options, method: "PATCH" });
}
post(path: string, options?: any) {
post(path: string, options?: TestRequestOptions) {
return this.fetch(path, { ...options, method: "POST" });
}
put(path: string, options?: any) {
put(path: string, options?: TestRequestOptions) {
return this.fetch(path, { ...options, method: "PUT" });
}
}
+2 -1
View File
@@ -44,11 +44,12 @@ export enum Hook {
type PluginValueMap = {
[Hook.API]: Router;
[Hook.AuthProvider]: { router: Router | Promise<Router>; id: string };
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- typeof BaseEmail<EmailProps> isn't assignable from BaseEmail<Subtype>; plugins register heterogeneous template Props.
[Hook.EmailTemplate]: typeof BaseEmail<any>;
[Hook.IssueProvider]: BaseIssueProvider;
[Hook.Processor]: typeof BaseProcessor;
[Hook.SearchProvider]: BaseSearchProvider;
[Hook.Task]: typeof BaseTask<any>;
[Hook.Task]: typeof BaseTask<object>;
[Hook.Uninstall]: UninstallSignature;
[Hook.UnfurlProvider]: { unfurl: UnfurlSignature; cacheExpiry: number };
[Hook.GroupSyncProvider]: { id: string; provider: GroupSyncProvider };
+2 -1
View File
@@ -7,7 +7,7 @@ const key = Symbol("env:public");
/**
* This decorator on an environment variable makes that variable available client-side
*/
export function Public(target: any, propertyKey: string) {
export function Public(target: object, propertyKey: string) {
const publicVars: string[] = Reflect.getMetadata(key, target);
if (!publicVars) {
@@ -18,6 +18,7 @@ export function Public(target: any, propertyKey: string) {
}
export class PublicEnvironmentRegister {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- consumed at runtime as a flat map of typed Environment values; tightening to unknown breaks call sites.
private static publicEnv: Record<string, any> = {};
static registerEnv(env: Environment) {
+7 -5
View File
@@ -135,11 +135,15 @@ describe("isDatabaseUrl", () => {
});
it("should reject null", () => {
expect(isDatabaseUrl(null as any, defaultOptions)).toBe(false);
expect(isDatabaseUrl(null as unknown as string, defaultOptions)).toBe(
false
);
});
it("should reject undefined", () => {
expect(isDatabaseUrl(undefined as any, defaultOptions)).toBe(false);
expect(
isDatabaseUrl(undefined as unknown as string, defaultOptions)
).toBe(false);
});
it("should reject URL with invalid protocol", () => {
@@ -279,9 +283,7 @@ describe("isMailboxAddress", () => {
});
it("should accept a mailbox format with quoted display name containing a comma", () => {
expect(
isMailboxAddress('"Company, Inc." <user@example.com>')
).toBe(true);
expect(isMailboxAddress('"Company, Inc." <user@example.com>')).toBe(true);
});
it("should accept a mailbox format with a quoted display name", () => {