support.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642
  1. import type { AbstractDialect, DialectName, Options } from '@sequelize/core';
  2. import { Sequelize } from '@sequelize/core';
  3. import type { PostgresDialect } from '@sequelize/postgres';
  4. import { isNotString } from '@sequelize/utils';
  5. import { isNodeError } from '@sequelize/utils/node';
  6. import chai from 'chai';
  7. import chaiAsPromised from 'chai-as-promised';
  8. import chaiDatetime from 'chai-datetime';
  9. import defaults from 'lodash/defaults';
  10. import isObject from 'lodash/isObject';
  11. import type { ExclusiveTestFunction, PendingTestFunction, TestFunction } from 'mocha';
  12. import assert from 'node:assert';
  13. import fs from 'node:fs';
  14. import path from 'node:path';
  15. import { inspect, isDeepStrictEqual } from 'node:util';
  16. import sinonChai from 'sinon-chai';
  17. import type { Class } from 'type-fest';
  18. import { CONFIG, SQLITE_DATABASES_DIR } from './config/config';
  19. export { getSqliteDatabasePath } from './config/config';
  20. const expect = chai.expect;
  21. const packagesDir = path.resolve(__dirname, '..', '..');
  22. const NON_DIALECT_PACKAGES = Object.freeze(['utils', 'validator-js', 'core']);
  23. chai.use(chaiDatetime);
  24. chai.use(chaiAsPromised);
  25. chai.use(sinonChai);
  26. /**
  27. * `expect(fn).to.throwWithCause()` works like `expect(fn).to.throw()`, except
  28. * that is also checks whether the message is present in the error cause.
  29. */
  30. chai.Assertion.addMethod('throwWithCause', function throwWithCause(errorConstructor, errorMessage) {
  31. // eslint-disable-next-line @typescript-eslint/no-invalid-this -- this is how chai works
  32. expect(withInlineCause(this._obj)).to.throw(errorConstructor, errorMessage);
  33. });
  34. chai.Assertion.addMethod('beNullish', function nullish() {
  35. // eslint-disable-next-line @typescript-eslint/no-invalid-this -- this is how chai works
  36. expect(this._obj).to.not.exist;
  37. });
  38. chai.Assertion.addMethod('notBeNullish', function nullish() {
  39. // eslint-disable-next-line @typescript-eslint/no-invalid-this -- this is how chai works
  40. expect(this._obj).to.exist;
  41. });
  42. function withInlineCause(cb: () => any): () => void {
  43. return () => {
  44. try {
  45. return cb();
  46. } catch (error) {
  47. assert(error instanceof Error);
  48. error.message = inlineErrorCause(error);
  49. throw error;
  50. }
  51. };
  52. }
  53. export function inlineErrorCause(error: unknown): string {
  54. if (!(error instanceof Error)) {
  55. return String(error);
  56. }
  57. let message = error.message;
  58. const cause = error.cause;
  59. if (cause instanceof Error) {
  60. message += `\nCaused by: ${inlineErrorCause(cause)}`;
  61. }
  62. return message;
  63. }
  64. chai.config.includeStack = true;
  65. chai.should();
  66. // Make sure errors get thrown when testing
  67. process.on('uncaughtException', e => {
  68. console.error('An unhandled exception occurred:');
  69. throw e;
  70. });
  71. let onNextUnhandledRejection: ((error: unknown) => any) | null = null;
  72. let unhandledRejections: unknown[] | null = null;
  73. process.on('unhandledRejection', e => {
  74. if (unhandledRejections) {
  75. unhandledRejections.push(e);
  76. }
  77. const onNext = onNextUnhandledRejection;
  78. if (onNext) {
  79. onNextUnhandledRejection = null;
  80. onNext(e);
  81. }
  82. if (onNext || unhandledRejections) {
  83. return;
  84. }
  85. console.error('An unhandled rejection occurred:');
  86. throw e;
  87. });
  88. // 'support' is requested by dev/check-connection, which is not a mocha context
  89. if (typeof afterEach !== 'undefined') {
  90. afterEach(() => {
  91. onNextUnhandledRejection = null;
  92. unhandledRejections = null;
  93. });
  94. }
  95. /**
  96. * Returns a Promise that will reject with the next unhandled rejection that occurs
  97. * during this test (instead of failing the test)
  98. */
  99. export async function nextUnhandledRejection() {
  100. return new Promise((resolve, reject) => {
  101. onNextUnhandledRejection = reject;
  102. });
  103. }
  104. export function createSequelizeInstance<Dialect extends AbstractDialect = AbstractDialect>(
  105. options?: Omit<Options<Dialect>, 'dialect'>,
  106. ): Sequelize<Dialect> {
  107. const dialectName = getTestDialect();
  108. const config = CONFIG[dialectName];
  109. const sequelizeOptions = defaults(options, config, {
  110. // the test suite was written before CLS was turned on by default.
  111. disableClsTransactions: true,
  112. } as const);
  113. if (dialectName === 'postgres') {
  114. const sequelizePostgresOptions: Options<PostgresDialect> = {
  115. ...(sequelizeOptions as Options<PostgresDialect>),
  116. native: process.env.DIALECT === 'postgres-native',
  117. };
  118. return new Sequelize(sequelizePostgresOptions) as unknown as Sequelize<Dialect>;
  119. }
  120. return new Sequelize<Dialect>(sequelizeOptions as Options<Dialect>);
  121. }
  122. export function getSupportedDialects() {
  123. return fs.readdirSync(packagesDir).filter(file => !NON_DIALECT_PACKAGES.includes(file));
  124. }
  125. export function getTestDialectClass(): Class<AbstractDialect> {
  126. const dialectClass = CONFIG[getTestDialect()].dialect;
  127. isNotString.assert(dialectClass);
  128. return dialectClass;
  129. }
  130. export function getTestDialect(): DialectName {
  131. let envDialect = process.env.DIALECT || '';
  132. if (envDialect === 'postgres-native') {
  133. envDialect = 'postgres';
  134. }
  135. if (!getSupportedDialects().includes(envDialect)) {
  136. throw new Error(
  137. `The DIALECT environment variable was set to ${JSON.stringify(envDialect)}, which is not a supported dialect. Set it to one of ${getSupportedDialects()
  138. .map(d => JSON.stringify(d))
  139. .join(', ')} instead.`,
  140. );
  141. }
  142. return envDialect as DialectName;
  143. }
  144. export function getTestDialectTeaser(moduleName: string): string {
  145. let dialect: string = getTestDialect();
  146. if (process.env.DIALECT === 'postgres-native') {
  147. dialect = 'postgres-native';
  148. }
  149. return `[${dialect.toUpperCase()}] ${moduleName}`;
  150. }
  151. export function getPoolMax(): number {
  152. return CONFIG[getTestDialect()].pool?.max ?? 1;
  153. }
  154. type ExpectationKey = 'default' | Permutations<DialectName, 4>;
  155. export type ExpectationRecord<V> = PartialRecord<ExpectationKey, V | Expectation<V> | Error>;
  156. type DecrementedDepth = [never, 0, 1, 2, 3];
  157. type Permutations<T extends string, Depth extends number, U extends string = T> = Depth extends 0
  158. ? never
  159. : T extends any
  160. ? T | `${T} ${Permutations<Exclude<U, T>, DecrementedDepth[Depth]>}`
  161. : never;
  162. type PartialRecord<K extends keyof any, V> = Partial<Record<K, V>>;
  163. export function expectPerDialect<Out>(method: () => Out, assertions: ExpectationRecord<Out>) {
  164. const expectations: PartialRecord<'default' | DialectName, Out | Error | Expectation<Out>> =
  165. Object.create(null);
  166. for (const [key, value] of Object.entries(assertions)) {
  167. const acceptedDialects = key.split(' ') as Array<DialectName | 'default'>;
  168. for (const dialect of acceptedDialects) {
  169. if (dialect === 'default' && acceptedDialects.length > 1) {
  170. throw new Error(`The 'default' expectation cannot be combined with other dialects.`);
  171. }
  172. if (expectations[dialect] !== undefined) {
  173. throw new Error(`The expectation for ${dialect} was already defined.`);
  174. }
  175. expectations[dialect] = value;
  176. }
  177. }
  178. let result: Out | Error;
  179. try {
  180. result = method();
  181. } catch (error: unknown) {
  182. assert(error instanceof Error, 'method threw a non-error');
  183. result = error;
  184. }
  185. const expectation = expectations[sequelize.dialect.name] ?? expectations.default;
  186. if (expectation === undefined) {
  187. throw new Error(
  188. `No expectation was defined for ${sequelize.dialect.name} and the 'default' expectation has not been defined.`,
  189. );
  190. }
  191. if (expectation instanceof Error) {
  192. assert(
  193. result instanceof Error,
  194. `Expected method to error with "${expectation.message}", but it returned ${inspect(result)}.`,
  195. );
  196. expect(inlineErrorCause(result)).to.include(expectation.message);
  197. } else {
  198. assert(
  199. !(result instanceof Error),
  200. `Did not expect query to error, but it errored with ${inlineErrorCause(result)}`,
  201. );
  202. const isDefault = expectations[sequelize.dialect.name] === undefined;
  203. assertMatchesExpectation(result, expectation, isDefault);
  204. }
  205. }
  206. function assertMatchesExpectation<V>(
  207. result: V,
  208. expectation: V | Expectation<V>,
  209. isDefault: boolean,
  210. ): void {
  211. if (expectation instanceof Expectation) {
  212. expectation.assert(result, isDefault);
  213. } else {
  214. expect(result).to.deep.equal(expectation);
  215. }
  216. }
  217. abstract class Expectation<Value> {
  218. abstract assert(value: Value, isDefault: boolean): void;
  219. }
  220. interface SqlExpectationOptions {
  221. genericQuotes?: boolean;
  222. }
  223. class SqlExpectation extends Expectation<string | string[]> {
  224. readonly #sql: string | readonly string[];
  225. readonly #options: SqlExpectationOptions | undefined;
  226. constructor(sql: string | readonly string[], options?: SqlExpectationOptions) {
  227. super();
  228. this.#sql = sql;
  229. this.#options = options;
  230. }
  231. #prepareSql(sql: string | readonly string[], isDefault: boolean): string | string[] {
  232. if (Array.isArray(sql)) {
  233. return sql.map(part => this.#prepareSql(part, isDefault)) as string[];
  234. }
  235. if (isDefault) {
  236. sql = replaceGenericIdentifierQuotes(sql as string, sequelize.dialect);
  237. }
  238. return minifySql(sql as string);
  239. }
  240. assert(value: string | readonly string[], isDefault: boolean) {
  241. expect(this.#prepareSql(value, false)).to.deep.equal(
  242. this.#prepareSql(this.#sql, isDefault || this.#options?.genericQuotes === true),
  243. );
  244. }
  245. }
  246. export function toMatchSql(sql: string | string[], options?: SqlExpectationOptions) {
  247. return new SqlExpectation(sql, options);
  248. }
  249. class RegexExpectation extends Expectation<string> {
  250. constructor(private readonly regex: RegExp) {
  251. super();
  252. }
  253. assert(value: string) {
  254. expect(value).to.match(this.regex);
  255. }
  256. }
  257. export function toMatchRegex(regex: RegExp) {
  258. return new RegexExpectation(regex);
  259. }
  260. type HasPropertiesInput<Obj extends Record<string, unknown>> = {
  261. [K in keyof Obj]?: any | Expectation<Obj[K]> | Error;
  262. };
  263. class HasPropertiesExpectation<Obj extends Record<string, unknown>> extends Expectation<Obj> {
  264. constructor(private readonly properties: HasPropertiesInput<Obj>) {
  265. super();
  266. }
  267. assert(value: Obj, isDefault: boolean) {
  268. for (const key of Object.keys(this.properties) as Array<keyof Obj>) {
  269. assertMatchesExpectation(value[key], this.properties[key], isDefault);
  270. }
  271. }
  272. }
  273. export function toHaveProperties<Obj extends Record<string, unknown>>(
  274. properties: HasPropertiesInput<Obj>,
  275. ) {
  276. return new HasPropertiesExpectation<Obj>(properties);
  277. }
  278. type MaybeLazy<T> = T | (() => T);
  279. export function expectsql(
  280. query: MaybeLazy<{ query: string; bind?: unknown } | Error>,
  281. assertions: {
  282. query: PartialRecord<ExpectationKey, string | Error>;
  283. bind: PartialRecord<ExpectationKey, unknown>;
  284. },
  285. ): void;
  286. export function expectsql(
  287. query: MaybeLazy<string | Error>,
  288. assertions: PartialRecord<ExpectationKey, string | Error>,
  289. ): void;
  290. export function expectsql(
  291. query: MaybeLazy<string | Error | { query: string; bind?: unknown }>,
  292. assertions:
  293. | {
  294. query: PartialRecord<ExpectationKey, string | Error>;
  295. bind: PartialRecord<ExpectationKey, unknown>;
  296. }
  297. | PartialRecord<ExpectationKey, string | Error>,
  298. ): void {
  299. const rawExpectationMap: PartialRecord<ExpectationKey, string | Error> =
  300. 'query' in assertions ? assertions.query : assertions;
  301. const expectations: PartialRecord<'default' | DialectName, string | Error> = Object.create(null);
  302. /**
  303. * The list of expectations that are run against more than one dialect, which enables the transformation of
  304. * identifier quoting to match the dialect.
  305. */
  306. const combinedExpectations = new Set<DialectName | 'default'>();
  307. combinedExpectations.add('default');
  308. for (const [key, value] of Object.entries(rawExpectationMap)) {
  309. const acceptedDialects = key.split(' ') as Array<DialectName | 'default'>;
  310. if (acceptedDialects.length > 1) {
  311. for (const dialect of acceptedDialects) {
  312. combinedExpectations.add(dialect);
  313. }
  314. }
  315. for (const dialect of acceptedDialects) {
  316. if (dialect === 'default' && acceptedDialects.length > 1) {
  317. throw new Error(`The 'default' expectation cannot be combined with other dialects.`);
  318. }
  319. if (expectations[dialect] !== undefined) {
  320. throw new Error(`The expectation for ${dialect} was already defined.`);
  321. }
  322. expectations[dialect] = value;
  323. }
  324. }
  325. const dialect = sequelize.dialect;
  326. const usedExpectationName = dialect.name in expectations ? dialect.name : 'default';
  327. let expectation = expectations[usedExpectationName];
  328. if (expectation == null) {
  329. throw new Error(
  330. `Undefined expectation for "${sequelize.dialect.name}"! (expectations: ${JSON.stringify(expectations)})`,
  331. );
  332. }
  333. if (combinedExpectations.has(usedExpectationName) && typeof expectation === 'string') {
  334. // replace [...] with the proper quote character for the dialect
  335. // except for ARRAY[...]
  336. expectation = replaceGenericIdentifierQuotes(expectation, dialect);
  337. if (dialect.name === 'ibmi') {
  338. expectation = expectation.trim().replace(/;$/, '');
  339. }
  340. }
  341. if (typeof query === 'function') {
  342. try {
  343. query = query();
  344. } catch (error: unknown) {
  345. if (!(error instanceof Error)) {
  346. throw new TypeError(
  347. 'expectsql: function threw something that is not an instance of Error.',
  348. );
  349. }
  350. query = error;
  351. }
  352. }
  353. if (expectation instanceof Error) {
  354. assert(
  355. query instanceof Error,
  356. `Expected query to error with "${expectation.message}", but it is equal to ${JSON.stringify(query)}.`,
  357. );
  358. expect(inlineErrorCause(query)).to.include(expectation.message);
  359. } else {
  360. assert(
  361. !(query instanceof Error),
  362. `Expected query to equal:\n${minifySql(expectation)}\n\nBut it errored with:\n${inlineErrorCause(query)}`,
  363. );
  364. expect(minifySql(isObject(query) ? query.query : query)).to.equal(minifySql(expectation));
  365. }
  366. if ('bind' in assertions) {
  367. const bind =
  368. assertions.bind[sequelize.dialect.name] || assertions.bind.default || assertions.bind;
  369. // @ts-expect-error -- too difficult to type, but this is safe
  370. expect(query.bind).to.deep.equal(bind);
  371. }
  372. }
  373. function replaceGenericIdentifierQuotes(sql: string, dialect: AbstractDialect): string {
  374. return sql.replaceAll(
  375. /(?<!ARRAY)\[([^\]]+)]/g,
  376. `${dialect.TICK_CHAR_LEFT}$1${dialect.TICK_CHAR_RIGHT}`,
  377. );
  378. }
  379. export function rand() {
  380. return Math.floor(Math.random() * 10e5);
  381. }
  382. export function isDeepEqualToOneOf(actual: unknown, expectedOptions: unknown[]): boolean {
  383. return expectedOptions.some(expected => isDeepStrictEqual(actual, expected));
  384. }
  385. /**
  386. * Reduces insignificant whitespace from SQL string.
  387. *
  388. * @param sql the SQL string
  389. * @returns the SQL string with insignificant whitespace removed.
  390. */
  391. export function minifySql(sql: string): string {
  392. // replace all consecutive whitespaces with a single plain space character
  393. return (
  394. sql
  395. .replaceAll(/\s+/g, ' ')
  396. // remove space before comma
  397. .replaceAll(' ,', ',')
  398. // remove space before )
  399. .replaceAll(' )', ')')
  400. // replace space after (
  401. .replaceAll('( ', '(')
  402. // remove whitespace at start & end
  403. .trim()
  404. );
  405. }
  406. export const sequelize = createSequelizeInstance<AbstractDialect>();
  407. export function resetSequelizeInstance(sequelizeInstance: Sequelize = sequelize): void {
  408. sequelizeInstance.removeAllModels();
  409. }
  410. // 'support' is requested by dev/check-connection, which is not a mocha context
  411. if (typeof before !== 'undefined') {
  412. before(function onBefore() {
  413. // legacy, remove once all tests have been migrated to not use "this" anymore
  414. // eslint-disable-next-line @typescript-eslint/no-invalid-this
  415. Object.defineProperty(this, 'sequelize', {
  416. value: sequelize,
  417. writable: false,
  418. configurable: false,
  419. });
  420. });
  421. }
  422. type Tester<Params extends any[]> = {
  423. (...params: Params): void;
  424. skip(...params: Params): void;
  425. only(...params: Params): void;
  426. };
  427. type TestFunctions = ExclusiveTestFunction | TestFunction | PendingTestFunction;
  428. export function createTester<Params extends any[]>(
  429. cb: (testFunction: TestFunctions, ...args: Params) => void,
  430. ): Tester<Params> {
  431. function tester(...params: Params) {
  432. cb(it, ...params);
  433. }
  434. tester.skip = function skippedTester(...params: Params) {
  435. cb(it.skip, ...params);
  436. };
  437. tester.only = function onlyTester(...params: Params) {
  438. cb(it.only, ...params);
  439. };
  440. return tester;
  441. }
  442. /**
  443. * Works like {@link beforeEach}, but returns an object that contains the values returned by its latest execution.
  444. *
  445. * @param cb
  446. */
  447. export function beforeEach2<T extends Record<string, any>>(cb: () => Promise<T> | T): T {
  448. // it's not the right shape but we're cheating. We'll be updating the value of this object before each test!
  449. const out = {} as T;
  450. beforeEach(async () => {
  451. const out2 = await cb();
  452. Object.assign(out, out2);
  453. });
  454. return out;
  455. }
  456. /**
  457. * Works like {@link before}, but returns an object that contains the values returned by its latest execution.
  458. *
  459. * @param cb
  460. */
  461. export function beforeAll2<T extends Record<string, any>>(cb: () => Promise<T> | T): T {
  462. // it's not the right shape but we're cheating. We'll be updating the value of this object before each test!
  463. const out = {} as T;
  464. before(async () => {
  465. const out2 = await cb();
  466. Object.assign(out, out2);
  467. });
  468. return out;
  469. }
  470. export function typeTest(_name: string, _callback: () => void): void {
  471. // This function doesn't do anything. a type test is only checked by TSC and never runs.
  472. }
  473. export async function unlinkIfExists(filePath: string): Promise<void> {
  474. try {
  475. await fs.promises.unlink(filePath);
  476. } catch (error) {
  477. if (isNodeError(error) && error.code !== 'ENOENT') {
  478. throw error;
  479. }
  480. }
  481. }
  482. let isIntegrationTestSuite = false;
  483. export function setIsIntegrationTestSuite(value: boolean): void {
  484. isIntegrationTestSuite = value;
  485. }
  486. // 'support' is requested by dev/check-connection, which is not a mocha context
  487. if (typeof after !== 'undefined') {
  488. after('delete SQLite databases', async () => {
  489. if (isIntegrationTestSuite) {
  490. // all Sequelize instances must be closed to be able to delete the database files, including the default one.
  491. // Closing is not possible in non-integration test suites,
  492. // as _all_ connections must be mocked (even for sqlite, even though it's a file-based database).
  493. await sequelize.close();
  494. }
  495. return fs.promises.rm(SQLITE_DATABASES_DIR, { recursive: true, force: true });
  496. });
  497. }
  498. // TODO: ignoredDeprecations should be removed in favour of EMPTY_ARRAY
  499. const ignoredDeprecations: readonly string[] = [
  500. 'SEQUELIZE0013',
  501. 'SEQUELIZE0018',
  502. 'SEQUELIZE0019',
  503. 'SEQUELIZE0021',
  504. 'SEQUELIZE0022',
  505. ];
  506. let allowedDeprecations: readonly string[] = ignoredDeprecations;
  507. export function allowDeprecationsInSuite(codes: readonly string[]) {
  508. before(() => {
  509. allowedDeprecations = [...codes, ...ignoredDeprecations];
  510. });
  511. after(() => {
  512. allowedDeprecations = ignoredDeprecations;
  513. });
  514. }
  515. // 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.
  516. process.on('warning', (warning: NodeJS.ErrnoException) => {
  517. if (warning.name === 'DeprecationWarning' && !allowedDeprecations.includes(warning.code!)) {
  518. throw warning;
  519. }
  520. });