/* This file is part of GNU Taler (C) 2022 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see */ import { Logger } from "@gnu-taler/taler-util"; type HttpMethod = | "get" | "GET" | "delete" | "DELETE" | "head" | "HEAD" | "options" | "OPTIONS" | "post" | "POST" | "put" | "PUT" | "patch" | "PATCH" | "purge" | "PURGE" | "link" | "LINK" | "unlink" | "UNLINK"; /** * @deprecated do not use it, it will be removed */ export type Query = { method: HttpMethod; url: string; code?: number; }; type ExpectationValues = { query: Query; auth?: string; params?: { // eslint-disable-next-line @typescript-eslint/ban-types request?: object; qparam?: Record; // eslint-disable-next-line @typescript-eslint/ban-types response?: object; }; }; type TestValues = { currentExpectedQuery: ExpectationValues | undefined; lastQuery: ExpectationValues | undefined; }; const logger = new Logger("testing/mock.ts"); type MockedResponse = { queryMade: ExpectationValues; expectedQuery?: ExpectationValues; }; /** * @deprecated do not use it, it will be removed */ export abstract class MockEnvironment { expectations: Array = []; queriesMade: Array = []; index = 0; debug: boolean; constructor(debug: boolean) { this.debug = debug; this.saveRequestAndGetMockedResponse.bind(this); } public addRequestExpectation< // eslint-disable-next-line @typescript-eslint/ban-types RequestType extends object, // eslint-disable-next-line @typescript-eslint/ban-types ResponseType extends object, >( query: Query, params: { auth?: string; request?: RequestType; qparam?: any; response?: ResponseType; }, ): void { const expected = { query, params, auth: params.auth }; this.expectations.push(expected); if (this.debug) { logger.info("saving query as expected", expected); } } public saveRequestAndGetMockedResponse< // eslint-disable-next-line @typescript-eslint/ban-types RequestType extends object, // eslint-disable-next-line @typescript-eslint/ban-types ResponseType extends object, >( query: Query, params: { auth?: string; request?: RequestType; qparam?: any; response?: ResponseType; }, ): MockedResponse { const queryMade = { query, params, auth: params.auth }; this.queriesMade.push(queryMade); const expectedQuery = this.expectations[this.index]; if (!expectedQuery) { if (this.debug) { logger.info("unexpected query made", queryMade); } return { queryMade }; } if (this.debug) { logger.info("tracking query made", { queryMade, expectedQuery, }); } this.index++; return { queryMade, expectedQuery }; } public assertJustExpectedRequestWereMade(): AssertStatus { let queryNumber = 0; while (queryNumber < this.expectations.length) { const r = this.assertNextRequest(queryNumber); if (r.result !== "ok") return r; queryNumber++; } return this.assertNoMoreRequestWereMade(queryNumber); } private getLastTestValues(idx: number): TestValues { const currentExpectedQuery = this.expectations[idx]; const lastQuery = this.queriesMade[idx]; return { currentExpectedQuery, lastQuery }; } private assertNoMoreRequestWereMade(idx: number): AssertStatus { const { currentExpectedQuery, lastQuery } = this.getLastTestValues(idx); if (lastQuery !== undefined) { return { result: "error-did-one-more", made: lastQuery, }; } if (currentExpectedQuery !== undefined) { return { result: "error-did-one-less", expected: currentExpectedQuery, }; } return { result: "ok", }; } private assertNextRequest(index: number): AssertStatus { const { currentExpectedQuery, lastQuery } = this.getLastTestValues(index); if (!currentExpectedQuery) { return { result: "error-query-missing", }; } if (!lastQuery) { return { result: "error-did-one-less", expected: currentExpectedQuery, }; } if (lastQuery.query.method) { if (currentExpectedQuery.query.method !== lastQuery.query.method) { return { result: "error-difference", diff: "method", last: lastQuery.query.method, expected: currentExpectedQuery.query.method, index, }; } if (currentExpectedQuery.query.url !== lastQuery.query.url) { return { result: "error-difference", diff: "url", last: lastQuery.query.url, expected: currentExpectedQuery.query.url, index, }; } } if ( !deepEquals( currentExpectedQuery.params?.request, lastQuery.params?.request, ) ) { return { result: "error-difference", diff: "query-body", expected: currentExpectedQuery.params?.request, last: lastQuery.params?.request, index, }; } if ( !deepEquals(currentExpectedQuery.params?.qparam, lastQuery.params?.qparam) ) { return { result: "error-difference", diff: "query-params", expected: currentExpectedQuery.params?.qparam, last: lastQuery.params?.qparam, index, }; } if (!deepEquals(currentExpectedQuery.auth, lastQuery.auth)) { return { result: "error-difference", diff: "query-auth", expected: currentExpectedQuery.auth, last: lastQuery.auth, index, }; } return { result: "ok", }; } } type AssertStatus = | AssertOk | AssertQueryNotMadeButExpected | AssertQueryMadeButNotExpected | AssertQueryMissing | AssertExpectedQueryMethodMismatch | AssertExpectedQueryUrlMismatch | AssertExpectedQueryAuthMismatch | AssertExpectedQueryBodyMismatch | AssertExpectedQueryParamsMismatch; interface AssertOk { result: "ok"; } //trying to assert for a expected query but there is //no expected query in the queue interface AssertQueryMissing { result: "error-query-missing"; } //tested component did one more query that expected interface AssertQueryNotMadeButExpected { result: "error-did-one-more"; made: ExpectationValues; } //tested component didn't make an expected query interface AssertQueryMadeButNotExpected { result: "error-did-one-less"; expected: ExpectationValues; } interface AssertExpectedQueryMethodMismatch { result: "error-difference"; diff: "method"; last: string; expected: string; index: number; } interface AssertExpectedQueryUrlMismatch { result: "error-difference"; diff: "url"; last: string; expected: string; index: number; } interface AssertExpectedQueryAuthMismatch { result: "error-difference"; diff: "query-auth"; last: string | undefined; expected: string | undefined; index: number; } interface AssertExpectedQueryBodyMismatch { result: "error-difference"; diff: "query-body"; last: any; expected: any; index: number; } interface AssertExpectedQueryParamsMismatch { result: "error-difference"; diff: "query-params"; last: any; expected: any; index: number; } /** * helpers * */ export type Tester = (a: any, b: any) => boolean | undefined; function deepEquals( a: unknown, b: unknown, aStack: Array = [], bStack: Array = [], ): boolean { //one if the element is null or undefined if (a === null || b === null || b === undefined || a === undefined) { return a === b; } //both are errors if (a instanceof Error && b instanceof Error) { return a.message == b.message; } //is the same object if (Object.is(a, b)) { return true; } //both the same class const name = Object.prototype.toString.call(a); if (name != Object.prototype.toString.call(b)) { return false; } // switch (name) { case "[object Boolean]": case "[object String]": case "[object Number]": if (typeof a !== typeof b) { // One is a primitive, one a `new Primitive()` return false; } else if (typeof a !== "object" && typeof b !== "object") { // both are proper primitives return Object.is(a, b); } else { // both are `new Primitive()`s return Object.is(a.valueOf(), b.valueOf()); } case "[object Date]": { const _a = a as Date; const _b = b as Date; return _a == _b; } case "[object RegExp]": { const _a = a as RegExp; const _b = b as RegExp; return _a.source === _b.source && _a.flags === _b.flags; } case "[object Array]": { const _a = a as Array; const _b = b as Array; if (_a.length !== _b.length) { return false; } } } if (typeof a !== "object" || typeof b !== "object") { return false; } if ( typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b) && hasIterator(a) && hasIterator(b) ) { return iterable(a, b); } // Used to detect circular references. let length = aStack.length; while (length--) { if (aStack[length] === a) { return bStack[length] === b; } else if (bStack[length] === b) { return false; } } aStack.push(a); bStack.push(b); const aKeys = allKeysFromObject(a); const bKeys = allKeysFromObject(b); let keySize = aKeys.length; //same number of keys if (bKeys.length !== keySize) { return false; } let keyIterator: string; while (keySize--) { // eslint-disable-next-line @typescript-eslint/ban-types const _a = a as Record; // eslint-disable-next-line @typescript-eslint/ban-types const _b = b as Record; keyIterator = aKeys[keySize]; const de = deepEquals(_a[keyIterator], _b[keyIterator], aStack, bStack); if (!de) { return false; } } aStack.pop(); bStack.pop(); return true; } // eslint-disable-next-line @typescript-eslint/ban-types function allKeysFromObject(obj: object): Array { const keys = []; for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { keys.push(key); } } return keys; } const IteratorSymbol = Symbol.iterator; function hasIterator(object: any): boolean { return !!(object != null && object[IteratorSymbol]); } function iterable( a: unknown, b: unknown, aStack: Array = [], bStack: Array = [], ): boolean { if (a === null || b === null || b === undefined || a === undefined) { return a === b; } if (a.constructor !== b.constructor) { return false; } let length = aStack.length; while (length--) { if (aStack[length] === a) { return bStack[length] === b; } } aStack.push(a); bStack.push(b); const aIterator = (a as any)[IteratorSymbol](); const bIterator = (b as any)[IteratorSymbol](); const nextA = aIterator.next(); while (nextA.done) { const nextB = bIterator.next(); if (nextB.done || !deepEquals(nextA.value, nextB.value)) { return false; } } if (!bIterator.next().done) { return false; } // Remove the first value from the stack of traversed values. aStack.pop(); bStack.pop(); return true; }