mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
refactor
This commit is contained in:
35
src/http.ts
35
src/http.ts
@@ -1,8 +1,16 @@
|
||||
import { AxiosPromise, AxiosRequestConfig, ResponseType } from "axios";
|
||||
import {
|
||||
AxiosPromise,
|
||||
AxiosRequestConfig,
|
||||
Method,
|
||||
ResponseType,
|
||||
} from "axios";
|
||||
|
||||
export interface Http {
|
||||
(config: AxiosRequestConfig): AxiosPromise<any>;
|
||||
}
|
||||
export interface Http2 extends Http {
|
||||
with: (defaults: Partial<RequestParams>) => Http2;
|
||||
}
|
||||
|
||||
export type RequestParams = {
|
||||
baseURL: string;
|
||||
@@ -10,11 +18,25 @@ export type RequestParams = {
|
||||
params: any;
|
||||
headers: any;
|
||||
responseType: ResponseType;
|
||||
method: Method;
|
||||
};
|
||||
|
||||
export const http =
|
||||
(base: Http, defaults: Partial<RequestParams>): Http =>
|
||||
(config: AxiosRequestConfig) => {
|
||||
const wrap = (http2: Http2, defaults: Partial<RequestParams>): Http2 => {
|
||||
const f = ((config: AxiosRequestConfig) => http2(merge(defaults, config))) as Http2;
|
||||
f.with = (defaults: Partial<RequestParams>) => wrap(f, defaults);
|
||||
return f;
|
||||
};
|
||||
|
||||
export const http2From = (http: Http): Http2 => {
|
||||
const f = ((config: AxiosRequestConfig) => http(config)) as Http2;
|
||||
f.with = (defaults: Partial<RequestParams>) => wrap(f, defaults);
|
||||
return f;
|
||||
}
|
||||
|
||||
const merge = (
|
||||
defaults: Partial<RequestParams>,
|
||||
config: AxiosRequestConfig
|
||||
) => {
|
||||
let toApply = {
|
||||
...defaults,
|
||||
...config,
|
||||
@@ -37,5 +59,8 @@ export const http =
|
||||
},
|
||||
};
|
||||
}
|
||||
return base(toApply);
|
||||
return toApply;
|
||||
};
|
||||
|
||||
export const http =
|
||||
(base: Http, defaults: Partial<RequestParams>): Http => (config: AxiosRequestConfig) => base(merge(defaults, config));
|
||||
|
||||
@@ -33,13 +33,10 @@ import makeI8N, { asLANGs, KEY, keys as i8nKeys, LANG } from "./i8n";
|
||||
import { Icon, ICONS, festivals, features } from "./icon";
|
||||
import _, { shuffle } from "underscore";
|
||||
import morgan from "morgan";
|
||||
import { takeWithRepeats } from "./utils";
|
||||
import { mask, takeWithRepeats } from "./utils";
|
||||
import { parse } from "./burn";
|
||||
import { axiosImageFetcher, ImageFetcher } from "./images";
|
||||
import {
|
||||
JWTSmapiLoginTokens,
|
||||
SmapiAuthTokens,
|
||||
} from "./smapi_auth";
|
||||
import { JWTSmapiLoginTokens, SmapiAuthTokens } from "./smapi_auth";
|
||||
|
||||
export const BONOB_ACCESS_TOKEN_HEADER = "bat";
|
||||
|
||||
@@ -377,23 +374,28 @@ function server(
|
||||
logger.info(
|
||||
`${trace} bnb<- ${req.method} ${req.path}?${JSON.stringify(
|
||||
req.query
|
||||
)}, headers=${JSON.stringify({ ...req.headers, "bnbt": "*****", "bnbk": "*****" })}`
|
||||
)}, headers=${JSON.stringify(mask(req.headers, ["bnbt", "bnbk"]))}`
|
||||
);
|
||||
|
||||
const serviceToken = pipe(
|
||||
E.fromNullable("Missing bnbt header")(req.headers["bnbt"] as string),
|
||||
E.chain(token => pipe(
|
||||
E.chain((token) =>
|
||||
pipe(
|
||||
E.fromNullable("Missing bnbk header")(req.headers["bnbk"] as string),
|
||||
E.map(key => ({ token, key }))
|
||||
)),
|
||||
E.map((key) => ({ token, key }))
|
||||
)
|
||||
),
|
||||
E.chain((auth) =>
|
||||
pipe(
|
||||
smapiAuthTokens.verify(auth),
|
||||
E.mapLeft((_) => "Auth token failed to verify")
|
||||
)
|
||||
),
|
||||
E.getOrElseW(() => undefined)
|
||||
)
|
||||
E.getOrElseW((e: string) => {
|
||||
logger.error(`Failed to get serviceToken for stream: ${e}`);
|
||||
return undefined;
|
||||
})
|
||||
);
|
||||
|
||||
if (!serviceToken) {
|
||||
return res.status(401).send();
|
||||
|
||||
@@ -5,7 +5,7 @@ import axios from "axios";
|
||||
import randomstring from "randomstring";
|
||||
import _ from "underscore";
|
||||
// todo: rename http2 to http
|
||||
import { Http, http as http2, RequestParams } from "../http";
|
||||
import { Http2, http2From } from "../http";
|
||||
|
||||
import {
|
||||
Credentials,
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { b64Encode, b64Decode } from "../b64";
|
||||
import { axiosImageFetcher, ImageFetcher } from "../images";
|
||||
import { navidromeMusicLibrary, SubsonicGenericMusicLibrary } from "./library";
|
||||
import { getJSON as getJSON } from "./subsonic_http";
|
||||
import { client } from "./subsonic_http";
|
||||
|
||||
export const t = (password: string, s: string) =>
|
||||
Md5.hashStr(`${password}${s}`);
|
||||
@@ -101,7 +101,7 @@ export class Subsonic implements MusicService {
|
||||
// todo: why is this in here?
|
||||
externalImageFetcher: ImageFetcher;
|
||||
|
||||
subsonicHttp: Http;
|
||||
subsonic: Http2;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
@@ -111,29 +111,22 @@ export class Subsonic implements MusicService {
|
||||
this.url = url;
|
||||
this.streamClientApplication = streamClientApplication;
|
||||
this.externalImageFetcher = externalImageFetcher;
|
||||
this.subsonicHttp = http2(axios, {
|
||||
this.subsonic = http2From(axios).with({
|
||||
baseURL: this.url,
|
||||
params: { v: "1.16.1", c: DEFAULT_CLIENT_APPLICATION },
|
||||
headers: { "User-Agent": "bonob" },
|
||||
});
|
||||
}
|
||||
|
||||
authenticatedSubsonicHttp = (credentials: Credentials) =>
|
||||
http2(this.subsonicHttp, {
|
||||
params: {
|
||||
asAuthParams = (credentials: Credentials) => ({
|
||||
u: credentials.username,
|
||||
...t_and_s(credentials.password),
|
||||
},
|
||||
});
|
||||
|
||||
GET = (query: Partial<RequestParams>) => ({
|
||||
asJSON: <T>() => getJSON<T>(http2(this.subsonicHttp, query)),
|
||||
});
|
||||
})
|
||||
|
||||
generateToken = (credentials: Credentials) =>
|
||||
pipe(
|
||||
TE.tryCatch(
|
||||
() => getJSON<PingResponse>(http2(this.authenticatedSubsonicHttp(credentials), { url: "/rest/ping.view" })),
|
||||
() => client(this.subsonic.with({ params: this.asAuthParams(credentials) } ))({ method: 'get', url: "/rest/ping.view" }).asJSON<PingResponse>(),
|
||||
(e) => new AuthFailure(e as string)
|
||||
),
|
||||
TE.chain(({ type }) =>
|
||||
@@ -168,7 +161,7 @@ export class Subsonic implements MusicService {
|
||||
): Promise<SubsonicMusicLibrary> => {
|
||||
const subsonicGenericLibrary = new SubsonicGenericMusicLibrary(
|
||||
this.streamClientApplication,
|
||||
this.authenticatedSubsonicHttp(credentials)
|
||||
this.subsonic.with({ params: this.asAuthParams(credentials) } )
|
||||
);
|
||||
if (credentials.type == "navidrome") {
|
||||
return Promise.resolve(
|
||||
|
||||
@@ -38,8 +38,8 @@ import {
|
||||
import axios from "axios";
|
||||
import { asURLSearchParams } from "../utils";
|
||||
import { artistSummaryFromNDArtist, NDArtist } from "./navidrome";
|
||||
import { Http, http as newHttp, RequestParams } from "../http";
|
||||
import { getRaw2, getJSON } from "./subsonic_http";
|
||||
import { Http2, RequestParams } from "../http";
|
||||
import { client } from "./subsonic_http";
|
||||
|
||||
type album = {
|
||||
id: string;
|
||||
@@ -276,20 +276,17 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
|
||||
|
||||
export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary {
|
||||
streamClientApplication: StreamClientApplication;
|
||||
subsonicHttp: Http;
|
||||
subsonicHttp: Http2;
|
||||
|
||||
constructor(
|
||||
streamClientApplication: StreamClientApplication,
|
||||
subsonicHttp: Http
|
||||
subsonicHttp: Http2
|
||||
) {
|
||||
this.streamClientApplication = streamClientApplication;
|
||||
this.subsonicHttp = subsonicHttp;
|
||||
}
|
||||
|
||||
GET = (query: Partial<RequestParams>) => ({
|
||||
asRAW: () => getRaw2(newHttp(this.subsonicHttp, query)),
|
||||
asJSON: <T>() => getJSON<T>(newHttp(this.subsonicHttp, query)),
|
||||
});
|
||||
GET = (query: Partial<RequestParams>) => client(this.subsonicHttp)({ method: 'get', ...query });
|
||||
|
||||
flavour = () => "subsonic";
|
||||
|
||||
@@ -390,7 +387,6 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary {
|
||||
trackId: string;
|
||||
range: string | undefined;
|
||||
}) =>
|
||||
// todo: all these headers and stuff can be rolled into httpeee
|
||||
this.getTrack(trackId).then((track) =>
|
||||
this.GET({
|
||||
url: "/rest/stream",
|
||||
@@ -398,20 +394,10 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary {
|
||||
id: trackId,
|
||||
c: this.streamClientApplication(track),
|
||||
},
|
||||
headers: pipe(
|
||||
range,
|
||||
O.fromNullable,
|
||||
O.map((range) => ({
|
||||
// "User-Agent": USER_AGENT,
|
||||
Range: range,
|
||||
})),
|
||||
O.getOrElse(() => ({
|
||||
// "User-Agent": USER_AGENT,
|
||||
}))
|
||||
),
|
||||
headers: range != undefined ? { Range: range } : {},
|
||||
responseType: "stream",
|
||||
})
|
||||
.asRAW()
|
||||
.asRaw()
|
||||
.then((res) => ({
|
||||
status: res.status,
|
||||
headers: {
|
||||
@@ -717,7 +703,7 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary {
|
||||
url: "/rest/getCoverArt",
|
||||
params: { id, size },
|
||||
responseType: "arraybuffer",
|
||||
}).asRAW();
|
||||
}).asRaw();
|
||||
|
||||
private getTrack = (id: string) =>
|
||||
this.GET({
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import {
|
||||
isError,
|
||||
SubsonicEnvelope,
|
||||
} from ".";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { isError, SubsonicEnvelope } from ".";
|
||||
// todo: rename http2 to http
|
||||
import { Http, http as newHttp } from "../http";
|
||||
import { Http2, RequestParams } from "../http";
|
||||
|
||||
export type HttpResponse = {
|
||||
data: any;
|
||||
@@ -11,21 +9,7 @@ export type HttpResponse = {
|
||||
headers: any;
|
||||
};
|
||||
|
||||
export const getRaw2 = (http: Http) =>
|
||||
http({ method: "get" })
|
||||
.catch((e) => {
|
||||
throw `Subsonic failed with: ${e}`;
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status != 200 && response.status != 206) {
|
||||
throw `Subsonic failed with a ${response.status || "no!"} status`;
|
||||
} else return response;
|
||||
});
|
||||
|
||||
export const getJSON = async <T>(http: Http): Promise<T> =>
|
||||
getRaw2(newHttp(http, { params: { f: "json" } })).then(asJSON) as Promise<T>;
|
||||
|
||||
export const asJSON = <T>(response: HttpResponse): T => {
|
||||
const asJSON = <T>(response: HttpResponse): T => {
|
||||
const subsonicResponse = (response.data as SubsonicEnvelope)[
|
||||
"subsonic-response"
|
||||
];
|
||||
@@ -33,6 +17,35 @@ export const asJSON = <T>(response: HttpResponse): T => {
|
||||
throw `Subsonic error:${subsonicResponse.error.message}`;
|
||||
else return subsonicResponse as unknown as T;
|
||||
};
|
||||
const throwUp = (error: any) => {
|
||||
throw `Subsonic failed with: ${error}`;
|
||||
};
|
||||
const verifyResponse = (response: AxiosResponse<any>) => {
|
||||
if (response.status != 200 && response.status != 206) {
|
||||
throw `Subsonic failed with a ${response.status || "no!"} status`;
|
||||
} else return response;
|
||||
};
|
||||
|
||||
export interface SubsonicHttpResponse {
|
||||
asRaw(): Promise<AxiosResponse<any>>;
|
||||
asJSON<T>(): Promise<T>;
|
||||
}
|
||||
|
||||
export interface SubsonicHttp {
|
||||
(query: Partial<RequestParams>): SubsonicHttpResponse;
|
||||
}
|
||||
|
||||
export const client = (http: Http2): SubsonicHttp => {
|
||||
return (query: Partial<RequestParams>): SubsonicHttpResponse => {
|
||||
return {
|
||||
asRaw: () => http(query).catch(throwUp).then(verifyResponse),
|
||||
|
||||
asJSON: <T>() =>
|
||||
http
|
||||
.with({ params: { f: "json" } })(query)
|
||||
.catch(throwUp)
|
||||
.then(verifyResponse)
|
||||
.then(asJSON) as Promise<T>,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
14
src/utils.ts
14
src/utils.ts
@@ -22,11 +22,21 @@ export const asURLSearchParams = (q: any) => {
|
||||
return urlSearchParams;
|
||||
};
|
||||
|
||||
|
||||
export function takeWithRepeats<T>(things: T[], count: number) {
|
||||
const result = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
result.push(things[i % things.length])
|
||||
result.push(things[i % things.length]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const mask = (thing: any, fields: string[]) =>
|
||||
fields.reduce(
|
||||
(res: any, key: string) => {
|
||||
if (Object.keys(res).includes(key)) {
|
||||
res[key] = "****";
|
||||
}
|
||||
return res;
|
||||
},
|
||||
{ ...thing }
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { http, } from "../src/http";
|
||||
import { http, http2From, } from "../src/http";
|
||||
|
||||
describe("http", () => {
|
||||
const mockAxios = jest.fn();
|
||||
@@ -11,6 +11,7 @@ describe("http", () => {
|
||||
describe.each([
|
||||
["baseURL"],
|
||||
["url"],
|
||||
["method"],
|
||||
])('%s', (field) => {
|
||||
const getValue = (value: string) => {
|
||||
const thing = {} as any;
|
||||
@@ -136,3 +137,141 @@ describe("http", () => {
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
describe("http2", () => {
|
||||
const mockAxios = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["baseURL"],
|
||||
["url"],
|
||||
["method"],
|
||||
])('%s', (field) => {
|
||||
const fieldWithValue = (value: string) => {
|
||||
const thing = {} as any;
|
||||
thing[field] = value;
|
||||
return thing;
|
||||
};
|
||||
|
||||
const base = http2From(mockAxios).with(fieldWithValue('default'));
|
||||
|
||||
describe("using default", () => {
|
||||
it("should use the default", () => {
|
||||
base({})
|
||||
expect(mockAxios).toHaveBeenCalledWith(fieldWithValue('default'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("overriding", () => {
|
||||
it("should use the override", () => {
|
||||
base(fieldWithValue('override'))
|
||||
expect(mockAxios).toHaveBeenCalledWith(fieldWithValue('override'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapping", () => {
|
||||
const firstLayer = http2From(base).with(fieldWithValue('level1'));
|
||||
const secondLayer = firstLayer.with(fieldWithValue('level2'));
|
||||
|
||||
describe("when the outter call provides a value", () => {
|
||||
it("should apply it", () => {
|
||||
secondLayer(fieldWithValue('outter'))
|
||||
expect(mockAxios).toHaveBeenCalledWith(fieldWithValue('outter'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the outter call does not provide a value", () => {
|
||||
it("should use the second layer", () => {
|
||||
secondLayer({ })
|
||||
expect(mockAxios).toHaveBeenCalledWith(fieldWithValue('level2'));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestType", () => {
|
||||
const base = http2From(mockAxios).with({ responseType: 'stream' });
|
||||
|
||||
describe("using default", () => {
|
||||
it("should use the default", () => {
|
||||
base({})
|
||||
expect(mockAxios).toHaveBeenCalledWith({ responseType: 'stream' });
|
||||
});
|
||||
});
|
||||
|
||||
describe("overriding", () => {
|
||||
it("should use the override", () => {
|
||||
base({ responseType: 'arraybuffer' })
|
||||
expect(mockAxios).toHaveBeenCalledWith({ responseType: 'arraybuffer' });
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapping", () => {
|
||||
const firstLayer = base.with({ responseType: 'arraybuffer' });
|
||||
const secondLayer = firstLayer.with({ responseType: 'blob' });
|
||||
|
||||
describe("when the outter call provides a value", () => {
|
||||
it("should apply it", () => {
|
||||
secondLayer({ responseType: 'text' })
|
||||
expect(mockAxios).toHaveBeenCalledWith({ responseType: 'text' });
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the outter call does not provide a value", () => {
|
||||
it("should use the second layer", () => {
|
||||
secondLayer({ })
|
||||
expect(mockAxios).toHaveBeenCalledWith({ responseType: 'blob' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["params"],
|
||||
["headers"],
|
||||
])('%s', (field) => {
|
||||
const fieldWithValues = (values: any) => {
|
||||
const thing = {} as any;
|
||||
thing[field] = values;
|
||||
return thing;
|
||||
}
|
||||
const base = http2From(mockAxios).with(fieldWithValues({ a: 1, b: 2, c: 3, d: 4 }));
|
||||
|
||||
describe("using default", () => {
|
||||
it("should use the default", () => {
|
||||
base({});
|
||||
expect(mockAxios).toHaveBeenCalledWith(fieldWithValues({ a: 1, b: 2, c: 3, d: 4 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("overriding", () => {
|
||||
it("should use the override", () => {
|
||||
base(fieldWithValues({ b: 22, e: 5 }));
|
||||
expect(mockAxios).toHaveBeenCalledWith(fieldWithValues({ a: 1, b: 22, c: 3, d: 4, e: 5 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapping", () => {
|
||||
const firstLayer = base.with(fieldWithValues({ b: 22 }));
|
||||
const secondLayer = firstLayer.with(fieldWithValues({ c: 33 }));
|
||||
|
||||
describe("when the outter call provides a value", () => {
|
||||
it("should apply it", () => {
|
||||
secondLayer(fieldWithValues({ a: 11, e: 5 }));
|
||||
expect(mockAxios).toHaveBeenCalledWith(fieldWithValues({ a: 11, b: 22, c: 33, d: 4, e: 5 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the outter call does not provide a value", () => {
|
||||
it("should use the second layer", () => {
|
||||
secondLayer({ });
|
||||
expect(mockAxios).toHaveBeenCalledWith(fieldWithValues({ a: 1, b: 22, c: 33, d: 4 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
import { EMPTY, error, FAILURE, subsonicOK, ok } from "../subsonic.test";
|
||||
import { DODGY_IMAGE_NAME, t } from "../../src/subsonic";
|
||||
import { b64Encode } from "../../src/b64";
|
||||
import { http as http2 } from "../../src/http";
|
||||
import { http2From } from "../../src/http";
|
||||
|
||||
const maybeIdFromCoverArtUrn = (coverArt: BUrn | undefined) =>
|
||||
pipe(
|
||||
@@ -511,7 +511,7 @@ describe("SubsonicGenericMusicLibrary", () => {
|
||||
const generic = new SubsonicGenericMusicLibrary(
|
||||
streamClientApplication,
|
||||
// todo: all this stuff doesnt need to be defaulted in here.
|
||||
http2(mockAxios, {
|
||||
http2From(mockAxios).with({
|
||||
baseURL,
|
||||
params: authParams,
|
||||
headers
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { asURLSearchParams, takeWithRepeats } from "../src/utils";
|
||||
import { asURLSearchParams, mask, takeWithRepeats } from "../src/utils";
|
||||
|
||||
describe("asURLSearchParams", () => {
|
||||
describe("empty q", () => {
|
||||
@@ -46,8 +46,6 @@ describe("asURLSearchParams", () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
describe("takeWithRepeat", () => {
|
||||
describe("when there is nothing in the input", () => {
|
||||
it("should return an array of undefineds", () => {
|
||||
@@ -77,7 +75,32 @@ describe("takeWithRepeat", () => {
|
||||
describe("when there more than the amount required", () => {
|
||||
it("should return the first n items", () => {
|
||||
expect(takeWithRepeats(["a", "b", "c"], 2)).toEqual(["a", "b"]);
|
||||
expect(takeWithRepeats(["a", undefined, "c"], 2)).toEqual(["a", undefined]);
|
||||
expect(takeWithRepeats(["a", undefined, "c"], 2)).toEqual([
|
||||
"a",
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mask", () => {
|
||||
it.each([
|
||||
[{}, ["a", "b"], {}],
|
||||
[{ foo: "bar" }, ["a", "b"], { foo: "bar" }],
|
||||
[{ a: 1 }, ["a", "b"], { a: "****" }],
|
||||
[{ a: 1, b: "dog" }, ["a", "b"], { a: "****", b: "****" }],
|
||||
[
|
||||
{ a: 1, b: "dog", foo: "bar" },
|
||||
["a", "b"],
|
||||
{ a: "****", b: "****", foo: "bar" },
|
||||
],
|
||||
])(
|
||||
"masking of %s, keys = %s, should result in %s",
|
||||
(original: any, keys: string[], expected: any) => {
|
||||
const copyOfOrig = JSON.parse(JSON.stringify(original));
|
||||
const masked = mask(original, keys);
|
||||
expect(masked).toEqual(expected);
|
||||
expect(original).toEqual(copyOfOrig);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user