/*
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";
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");
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;
},
): { status: number; payload: ResponseType } | undefined {
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 undefined;
}
const responseCode = this.expectations[this.index].query.code ?? 200;
const mockedResponse = this.expectations[this.index].params
?.response as ResponseType;
if (this.debug) {
logger.info("tracking query made", {
queryMade,
expectedQuery,
});
}
this.index++;
return { status: responseCode, payload: mockedResponse };
}
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;
}