import type { AbstractDialect, DialectName, Options } from '@sequelize/core'; import { Sequelize } from '@sequelize/core'; import type { PostgresDialect } from '@sequelize/postgres'; import { isNotString } from '@sequelize/utils'; import { isNodeError } from '@sequelize/utils/node'; import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; import chaiDatetime from 'chai-datetime'; import defaults from 'lodash/defaults'; import isObject from 'lodash/isObject'; import type { ExclusiveTestFunction, PendingTestFunction, TestFunction } from 'mocha'; import assert from 'node:assert'; import fs from 'node:fs'; import path from 'node:path'; import { inspect, isDeepStrictEqual } from 'node:util'; import sinonChai from 'sinon-chai'; import type { Class } from 'type-fest'; import { CONFIG, SQLITE_DATABASES_DIR } from './config/config'; export { getSqliteDatabasePath } from './config/config'; const expect = chai.expect; const packagesDir = path.resolve(__dirname, '..', '..'); const NON_DIALECT_PACKAGES = Object.freeze(['utils', 'validator-js', 'core']); chai.use(chaiDatetime); chai.use(chaiAsPromised); chai.use(sinonChai); /** * `expect(fn).to.throwWithCause()` works like `expect(fn).to.throw()`, except * that is also checks whether the message is present in the error cause. */ chai.Assertion.addMethod('throwWithCause', function throwWithCause(errorConstructor, errorMessage) { // eslint-disable-next-line @typescript-eslint/no-invalid-this -- this is how chai works expect(withInlineCause(this._obj)).to.throw(errorConstructor, errorMessage); }); chai.Assertion.addMethod('beNullish', function nullish() { // eslint-disable-next-line @typescript-eslint/no-invalid-this -- this is how chai works expect(this._obj).to.not.exist; }); chai.Assertion.addMethod('notBeNullish', function nullish() { // eslint-disable-next-line @typescript-eslint/no-invalid-this -- this is how chai works expect(this._obj).to.exist; }); function withInlineCause(cb: () => any): () => void { return () => { try { return cb(); } catch (error) { assert(error instanceof Error); error.message = inlineErrorCause(error); throw error; } }; } export function inlineErrorCause(error: unknown): string { if (!(error instanceof Error)) { return String(error); } let message = error.message; const cause = error.cause; if (cause instanceof Error) { message += `\nCaused by: ${inlineErrorCause(cause)}`; } return message; } chai.config.includeStack = true; chai.should(); // Make sure errors get thrown when testing process.on('uncaughtException', e => { console.error('An unhandled exception occurred:'); throw e; }); let onNextUnhandledRejection: ((error: unknown) => any) | null = null; let unhandledRejections: unknown[] | null = null; process.on('unhandledRejection', e => { if (unhandledRejections) { unhandledRejections.push(e); } const onNext = onNextUnhandledRejection; if (onNext) { onNextUnhandledRejection = null; onNext(e); } if (onNext || unhandledRejections) { return; } console.error('An unhandled rejection occurred:'); throw e; }); // 'support' is requested by dev/check-connection, which is not a mocha context if (typeof afterEach !== 'undefined') { afterEach(() => { onNextUnhandledRejection = null; unhandledRejections = null; }); } /** * Returns a Promise that will reject with the next unhandled rejection that occurs * during this test (instead of failing the test) */ export async function nextUnhandledRejection() { return new Promise((resolve, reject) => { onNextUnhandledRejection = reject; }); } export function createSequelizeInstance( options?: Omit, 'dialect'>, ): Sequelize { const dialectName = getTestDialect(); const config = CONFIG[dialectName]; const sequelizeOptions = defaults(options, config, { // the test suite was written before CLS was turned on by default. disableClsTransactions: true, } as const); if (dialectName === 'postgres') { const sequelizePostgresOptions: Options = { ...(sequelizeOptions as Options), native: process.env.DIALECT === 'postgres-native', }; return new Sequelize(sequelizePostgresOptions) as unknown as Sequelize; } return new Sequelize(sequelizeOptions as Options); } export function getSupportedDialects() { return fs.readdirSync(packagesDir).filter(file => !NON_DIALECT_PACKAGES.includes(file)); } export function getTestDialectClass(): Class { const dialectClass = CONFIG[getTestDialect()].dialect; isNotString.assert(dialectClass); return dialectClass; } export function getTestDialect(): DialectName { let envDialect = process.env.DIALECT || ''; if (envDialect === 'postgres-native') { envDialect = 'postgres'; } if (!getSupportedDialects().includes(envDialect)) { throw new Error( `The DIALECT environment variable was set to ${JSON.stringify(envDialect)}, which is not a supported dialect. Set it to one of ${getSupportedDialects() .map(d => JSON.stringify(d)) .join(', ')} instead.`, ); } return envDialect as DialectName; } export function getTestDialectTeaser(moduleName: string): string { let dialect: string = getTestDialect(); if (process.env.DIALECT === 'postgres-native') { dialect = 'postgres-native'; } return `[${dialect.toUpperCase()}] ${moduleName}`; } export function getPoolMax(): number { return CONFIG[getTestDialect()].pool?.max ?? 1; } type ExpectationKey = 'default' | Permutations; export type ExpectationRecord = PartialRecord | Error>; type DecrementedDepth = [never, 0, 1, 2, 3]; type Permutations = Depth extends 0 ? never : T extends any ? T | `${T} ${Permutations, DecrementedDepth[Depth]>}` : never; type PartialRecord = Partial>; export function expectPerDialect(method: () => Out, assertions: ExpectationRecord) { const expectations: PartialRecord<'default' | DialectName, Out | Error | Expectation> = Object.create(null); for (const [key, value] of Object.entries(assertions)) { const acceptedDialects = key.split(' ') as Array; for (const dialect of acceptedDialects) { if (dialect === 'default' && acceptedDialects.length > 1) { throw new Error(`The 'default' expectation cannot be combined with other dialects.`); } if (expectations[dialect] !== undefined) { throw new Error(`The expectation for ${dialect} was already defined.`); } expectations[dialect] = value; } } let result: Out | Error; try { result = method(); } catch (error: unknown) { assert(error instanceof Error, 'method threw a non-error'); result = error; } const expectation = expectations[sequelize.dialect.name] ?? expectations.default; if (expectation === undefined) { throw new Error( `No expectation was defined for ${sequelize.dialect.name} and the 'default' expectation has not been defined.`, ); } if (expectation instanceof Error) { assert( result instanceof Error, `Expected method to error with "${expectation.message}", but it returned ${inspect(result)}.`, ); expect(inlineErrorCause(result)).to.include(expectation.message); } else { assert( !(result instanceof Error), `Did not expect query to error, but it errored with ${inlineErrorCause(result)}`, ); const isDefault = expectations[sequelize.dialect.name] === undefined; assertMatchesExpectation(result, expectation, isDefault); } } function assertMatchesExpectation( result: V, expectation: V | Expectation, isDefault: boolean, ): void { if (expectation instanceof Expectation) { expectation.assert(result, isDefault); } else { expect(result).to.deep.equal(expectation); } } abstract class Expectation { abstract assert(value: Value, isDefault: boolean): void; } interface SqlExpectationOptions { genericQuotes?: boolean; } class SqlExpectation extends Expectation { readonly #sql: string | readonly string[]; readonly #options: SqlExpectationOptions | undefined; constructor(sql: string | readonly string[], options?: SqlExpectationOptions) { super(); this.#sql = sql; this.#options = options; } #prepareSql(sql: string | readonly string[], isDefault: boolean): string | string[] { if (Array.isArray(sql)) { return sql.map(part => this.#prepareSql(part, isDefault)) as string[]; } if (isDefault) { sql = replaceGenericIdentifierQuotes(sql as string, sequelize.dialect); } return minifySql(sql as string); } assert(value: string | readonly string[], isDefault: boolean) { expect(this.#prepareSql(value, false)).to.deep.equal( this.#prepareSql(this.#sql, isDefault || this.#options?.genericQuotes === true), ); } } export function toMatchSql(sql: string | string[], options?: SqlExpectationOptions) { return new SqlExpectation(sql, options); } class RegexExpectation extends Expectation { constructor(private readonly regex: RegExp) { super(); } assert(value: string) { expect(value).to.match(this.regex); } } export function toMatchRegex(regex: RegExp) { return new RegexExpectation(regex); } type HasPropertiesInput> = { [K in keyof Obj]?: any | Expectation | Error; }; class HasPropertiesExpectation> extends Expectation { constructor(private readonly properties: HasPropertiesInput) { super(); } assert(value: Obj, isDefault: boolean) { for (const key of Object.keys(this.properties) as Array) { assertMatchesExpectation(value[key], this.properties[key], isDefault); } } } export function toHaveProperties>( properties: HasPropertiesInput, ) { return new HasPropertiesExpectation(properties); } type MaybeLazy = T | (() => T); export function expectsql( query: MaybeLazy<{ query: string; bind?: unknown } | Error>, assertions: { query: PartialRecord; bind: PartialRecord; }, ): void; export function expectsql( query: MaybeLazy, assertions: PartialRecord, ): void; export function expectsql( query: MaybeLazy, assertions: | { query: PartialRecord; bind: PartialRecord; } | PartialRecord, ): void { const rawExpectationMap: PartialRecord = 'query' in assertions ? assertions.query : assertions; const expectations: PartialRecord<'default' | DialectName, string | Error> = Object.create(null); /** * The list of expectations that are run against more than one dialect, which enables the transformation of * identifier quoting to match the dialect. */ const combinedExpectations = new Set(); combinedExpectations.add('default'); for (const [key, value] of Object.entries(rawExpectationMap)) { const acceptedDialects = key.split(' ') as Array; if (acceptedDialects.length > 1) { for (const dialect of acceptedDialects) { combinedExpectations.add(dialect); } } for (const dialect of acceptedDialects) { if (dialect === 'default' && acceptedDialects.length > 1) { throw new Error(`The 'default' expectation cannot be combined with other dialects.`); } if (expectations[dialect] !== undefined) { throw new Error(`The expectation for ${dialect} was already defined.`); } expectations[dialect] = value; } } const dialect = sequelize.dialect; const usedExpectationName = dialect.name in expectations ? dialect.name : 'default'; let expectation = expectations[usedExpectationName]; if (expectation == null) { throw new Error( `Undefined expectation for "${sequelize.dialect.name}"! (expectations: ${JSON.stringify(expectations)})`, ); } if (combinedExpectations.has(usedExpectationName) && typeof expectation === 'string') { // replace [...] with the proper quote character for the dialect // except for ARRAY[...] expectation = replaceGenericIdentifierQuotes(expectation, dialect); if (dialect.name === 'ibmi') { expectation = expectation.trim().replace(/;$/, ''); } } if (typeof query === 'function') { try { query = query(); } catch (error: unknown) { if (!(error instanceof Error)) { throw new TypeError( 'expectsql: function threw something that is not an instance of Error.', ); } query = error; } } if (expectation instanceof Error) { assert( query instanceof Error, `Expected query to error with "${expectation.message}", but it is equal to ${JSON.stringify(query)}.`, ); expect(inlineErrorCause(query)).to.include(expectation.message); } else { assert( !(query instanceof Error), `Expected query to equal:\n${minifySql(expectation)}\n\nBut it errored with:\n${inlineErrorCause(query)}`, ); expect(minifySql(isObject(query) ? query.query : query)).to.equal(minifySql(expectation)); } if ('bind' in assertions) { const bind = assertions.bind[sequelize.dialect.name] || assertions.bind.default || assertions.bind; // @ts-expect-error -- too difficult to type, but this is safe expect(query.bind).to.deep.equal(bind); } } function replaceGenericIdentifierQuotes(sql: string, dialect: AbstractDialect): string { return sql.replaceAll( /(? isDeepStrictEqual(actual, expected)); } /** * Reduces insignificant whitespace from SQL string. * * @param sql the SQL string * @returns the SQL string with insignificant whitespace removed. */ export function minifySql(sql: string): string { // replace all consecutive whitespaces with a single plain space character return ( sql .replaceAll(/\s+/g, ' ') // remove space before comma .replaceAll(' ,', ',') // remove space before ) .replaceAll(' )', ')') // replace space after ( .replaceAll('( ', '(') // remove whitespace at start & end .trim() ); } export const sequelize = createSequelizeInstance(); export function resetSequelizeInstance(sequelizeInstance: Sequelize = sequelize): void { sequelizeInstance.removeAllModels(); } // 'support' is requested by dev/check-connection, which is not a mocha context if (typeof before !== 'undefined') { before(function onBefore() { // legacy, remove once all tests have been migrated to not use "this" anymore // eslint-disable-next-line @typescript-eslint/no-invalid-this Object.defineProperty(this, 'sequelize', { value: sequelize, writable: false, configurable: false, }); }); } type Tester = { (...params: Params): void; skip(...params: Params): void; only(...params: Params): void; }; type TestFunctions = ExclusiveTestFunction | TestFunction | PendingTestFunction; export function createTester( cb: (testFunction: TestFunctions, ...args: Params) => void, ): Tester { function tester(...params: Params) { cb(it, ...params); } tester.skip = function skippedTester(...params: Params) { cb(it.skip, ...params); }; tester.only = function onlyTester(...params: Params) { cb(it.only, ...params); }; return tester; } /** * Works like {@link beforeEach}, but returns an object that contains the values returned by its latest execution. * * @param cb */ export function beforeEach2>(cb: () => Promise | T): T { // it's not the right shape but we're cheating. We'll be updating the value of this object before each test! const out = {} as T; beforeEach(async () => { const out2 = await cb(); Object.assign(out, out2); }); return out; } /** * Works like {@link before}, but returns an object that contains the values returned by its latest execution. * * @param cb */ export function beforeAll2>(cb: () => Promise | T): T { // it's not the right shape but we're cheating. We'll be updating the value of this object before each test! const out = {} as T; before(async () => { const out2 = await cb(); Object.assign(out, out2); }); return out; } export function typeTest(_name: string, _callback: () => void): void { // This function doesn't do anything. a type test is only checked by TSC and never runs. } export async function unlinkIfExists(filePath: string): Promise { try { await fs.promises.unlink(filePath); } catch (error) { if (isNodeError(error) && error.code !== 'ENOENT') { throw error; } } } let isIntegrationTestSuite = false; export function setIsIntegrationTestSuite(value: boolean): void { isIntegrationTestSuite = value; } // 'support' is requested by dev/check-connection, which is not a mocha context if (typeof after !== 'undefined') { after('delete SQLite databases', async () => { if (isIntegrationTestSuite) { // all Sequelize instances must be closed to be able to delete the database files, including the default one. // Closing is not possible in non-integration test suites, // as _all_ connections must be mocked (even for sqlite, even though it's a file-based database). await sequelize.close(); } return fs.promises.rm(SQLITE_DATABASES_DIR, { recursive: true, force: true }); }); } // TODO: ignoredDeprecations should be removed in favour of EMPTY_ARRAY const ignoredDeprecations: readonly string[] = [ 'SEQUELIZE0013', 'SEQUELIZE0018', 'SEQUELIZE0019', 'SEQUELIZE0021', 'SEQUELIZE0022', ]; let allowedDeprecations: readonly string[] = ignoredDeprecations; export function allowDeprecationsInSuite(codes: readonly string[]) { before(() => { allowedDeprecations = [...codes, ...ignoredDeprecations]; }); after(() => { allowedDeprecations = ignoredDeprecations; }); } // TODO: the DeprecationWarning is only thrown once. We should figure out a way to reset that or move all tests that use deprecated tests to one suite per deprecation. process.on('warning', (warning: NodeJS.ErrnoException) => { if (warning.name === 'DeprecationWarning' && !allowedDeprecations.includes(warning.code!)) { throw warning; } });