Skip to content

Commit

Permalink
Update mocking code to be compatible with esm
Browse files Browse the repository at this point in the history
  • Loading branch information
noencke committed Jan 31, 2025
1 parent 0052d4c commit 603cbfe
Show file tree
Hide file tree
Showing 15 changed files with 346 additions and 250 deletions.
5 changes: 2 additions & 3 deletions packages/drivers/odsp-driver/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,7 @@
"build:docs": "api-extractor run --local",
"build:esnext": "tsc --project ./tsconfig.json",
"build:genver": "gen-version",
"build:test": "npm run build:test:esm && npm run build:test:cjs",
"build:test:cjs": "fluid-tsc commonjs --project ./src/test/tsconfig.cjs.json",
"build:test": "npm run build:test:esm",
"build:test:esm": "tsc --project ./src/test/tsconfig.json",
"check:are-the-types-wrong": "attw --pack .",
"check:biome": "biome check .",
Expand All @@ -86,7 +85,7 @@
"lint:fix": "fluid-build . --task eslint:fix --task format",
"test": "npm run test:mocha",
"test:coverage": "c8 npm test",
"test:mocha": "npm run test:mocha:cjs && echo \"ADO #7404 - ESM modules cannot be stubbed - npm run test:mocha:esm\"",
"test:mocha": "npm run test:mocha:esm",
"test:mocha:cjs": "mocha --recursive \"dist/test/**/*.spec.*js\" --exit",
"test:mocha:esm": "mocha --recursive \"lib/test/**/*.spec.*js\" --exit",
"test:mocha:verbose": "cross-env FLUID_TEST_VERBOSE=1 npm run test:mocha",
Expand Down
152 changes: 79 additions & 73 deletions packages/drivers/odsp-driver/src/fetchSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
import { EpochTracker } from "./epochTracker.js";
import { getQueryString } from "./getQueryString.js";
import { getHeadersWithAuth } from "./getUrlAndHeadersWithAuth.js";
import { mockify } from "./mockify.js";
import { convertOdspSnapshotToSnapshotTreeAndBlobs } from "./odspSnapshotParser.js";
import { checkForKnownServerFarmType } from "./odspUrlHelper.js";
import {
Expand Down Expand Up @@ -693,87 +694,92 @@ function getTreeStatsCore(snapshotTree: ISnapshotTree, stats: ITreeStats): void
* @param epochTracker - epoch tracker used to add/validate epoch in the network call.
* @returns fetched snapshot.
*/
export async function downloadSnapshot(
odspResolvedUrl: IOdspResolvedUrl,
getAuthHeader: InstrumentedStorageTokenFetcher,
tokenFetchOptions: TokenFetchOptionsEx,
loadingGroupIds: string[] | undefined,
snapshotOptions: ISnapshotOptions | undefined,
snapshotFormatFetchType?: SnapshotFormatSupportType,
controller?: AbortController,
epochTracker?: EpochTracker,
scenarioName?: string,
): Promise<ISnapshotRequestAndResponseOptions> {
// back-compat: This block to be removed with #8784 when we only consume/consider odsp resolvers that are >= 0.51
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
const sharingLinkToRedeem = (odspResolvedUrl as any).sharingLinkToRedeem;
if (sharingLinkToRedeem) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
odspResolvedUrl.shareLinkInfo = { ...odspResolvedUrl.shareLinkInfo, sharingLinkToRedeem };
}
export const downloadSnapshot = mockify(
async (
odspResolvedUrl: IOdspResolvedUrl,
getAuthHeader: InstrumentedStorageTokenFetcher,
tokenFetchOptions: TokenFetchOptionsEx,
loadingGroupIds: string[] | undefined,
snapshotOptions: ISnapshotOptions | undefined,
snapshotFormatFetchType?: SnapshotFormatSupportType,
controller?: AbortController,
epochTracker?: EpochTracker,
scenarioName?: string,
): Promise<ISnapshotRequestAndResponseOptions> => {
// back-compat: This block to be removed with #8784 when we only consume/consider odsp resolvers that are >= 0.51
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
const sharingLinkToRedeem = (odspResolvedUrl as any).sharingLinkToRedeem;
if (sharingLinkToRedeem) {
odspResolvedUrl.shareLinkInfo = {
...odspResolvedUrl.shareLinkInfo,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
sharingLinkToRedeem,
};
}

const snapshotUrl = odspResolvedUrl.endpoints.snapshotStorageUrl;
const snapshotUrl = odspResolvedUrl.endpoints.snapshotStorageUrl;

const queryParams: Record<string, unknown> = { ump: 1 };
if (snapshotOptions !== undefined) {
for (const [key, value] of Object.entries(snapshotOptions)) {
// Exclude "timeout" from query string
if (value !== undefined && key !== "timeout") {
queryParams[key] = value;
const queryParams: Record<string, unknown> = { ump: 1 };
if (snapshotOptions !== undefined) {
for (const [key, value] of Object.entries(snapshotOptions)) {
// Exclude "timeout" from query string
if (value !== undefined && key !== "timeout") {
queryParams[key] = value;
}
}
}
}

if (loadingGroupIds !== undefined) {
queryParams.groupId = loadingGroupIds.join(",");
}

const queryString = getQueryString(queryParams);
const url = `${snapshotUrl}/trees/latest${queryString}`;
const method = "POST";
// The location of file can move on Spo in which case server returns 308(Permanent Redirect) error.
// Adding below header will make VROOM API return 404 instead of 308 and browser can intercept it.
// This error thrown by server will contain the new redirect location. Look at the 404 error parsing
// for further reference here: \packages\utils\odsp-doclib-utils\src\odspErrorUtils.ts
const header = { prefer: "manualredirect" };
const authHeader = await getAuthHeader(
{ ...tokenFetchOptions, request: { url, method } },
"downloadSnapshot",
);
assert(authHeader !== null, 0x1e5 /* "Storage token should not be null" */);
const { body, headers } = getFormBodyAndHeaders(odspResolvedUrl, authHeader, header);
const fetchOptions = {
body,
headers,
signal: controller?.signal,
method,
};
// Decide what snapshot format to fetch as per the feature gate.
switch (snapshotFormatFetchType) {
case SnapshotFormatSupportType.Binary: {
headers.accept = `application/ms-fluid; v=${currentReadVersion}`;
break;
if (loadingGroupIds !== undefined) {
queryParams.groupId = loadingGroupIds.join(",");
}
default: {
// By default ask both versions and let the server decide the format.
headers.accept = `application/json, application/ms-fluid; v=${currentReadVersion}`;

const queryString = getQueryString(queryParams);
const url = `${snapshotUrl}/trees/latest${queryString}`;
const method = "POST";
// The location of file can move on Spo in which case server returns 308(Permanent Redirect) error.
// Adding below header will make VROOM API return 404 instead of 308 and browser can intercept it.
// This error thrown by server will contain the new redirect location. Look at the 404 error parsing
// for further reference here: \packages\utils\odsp-doclib-utils\src\odspErrorUtils.ts
const header = { prefer: "manualredirect" };
const authHeader = await getAuthHeader(
{ ...tokenFetchOptions, request: { url, method } },
"downloadSnapshot",
);
assert(authHeader !== null, 0x1e5 /* "Storage token should not be null" */);
const { body, headers } = getFormBodyAndHeaders(odspResolvedUrl, authHeader, header);
const fetchOptions = {
body,
headers,
signal: controller?.signal,
method,
};
// Decide what snapshot format to fetch as per the feature gate.
switch (snapshotFormatFetchType) {
case SnapshotFormatSupportType.Binary: {
headers.accept = `application/ms-fluid; v=${currentReadVersion}`;
break;
}
default: {
// By default ask both versions and let the server decide the format.
headers.accept = `application/json, application/ms-fluid; v=${currentReadVersion}`;
}
}
}

const odspResponse = await (epochTracker?.fetch(
url,
fetchOptions,
"treesLatest",
true,
scenarioName,
) ?? fetchHelper(url, fetchOptions));

return {
odspResponse,
requestHeaders: headers,
requestUrl: url,
};
}
const odspResponse = await (epochTracker?.fetch(
url,
fetchOptions,
"treesLatest",
true,
scenarioName,
) ?? fetchHelper(url, fetchOptions));

return {
odspResponse,
requestHeaders: headers,
requestUrl: url,
};
},
);

function isRedeemSharingLinkError(
odspResolvedUrl: IOdspResolvedUrl,
Expand Down
109 changes: 56 additions & 53 deletions packages/drivers/odsp-driver/src/getFileLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "@fluidframework/telemetry-utils/internal";

import { getHeadersWithAuth } from "./getUrlAndHeadersWithAuth.js";
import { mockify } from "./mockify.js";
import {
fetchHelper,
getWithRetryForTokenRefresh,
Expand All @@ -42,64 +43,66 @@ const fileLinkCache = new Map<string, Promise<string>>();
* @param logger - used to log results of operation, including any error
* @returns Promise which resolves to file link url when successful; otherwise, undefined.
*/
export async function getFileLink(
getToken: TokenFetcher<OdspResourceTokenFetchOptions>,
resolvedUrl: IOdspResolvedUrl,
logger: ITelemetryLoggerExt,
): Promise<string> {
const cacheKey = `${resolvedUrl.siteUrl}_${resolvedUrl.driveId}_${resolvedUrl.itemId}`;
const maybeFileLinkCacheEntry = fileLinkCache.get(cacheKey);
if (maybeFileLinkCacheEntry !== undefined) {
return maybeFileLinkCacheEntry;
}
export const getFileLink = mockify(
async (
getToken: TokenFetcher<OdspResourceTokenFetchOptions>,
resolvedUrl: IOdspResolvedUrl,
logger: ITelemetryLoggerExt,
): Promise<string> => {
const cacheKey = `${resolvedUrl.siteUrl}_${resolvedUrl.driveId}_${resolvedUrl.itemId}`;
const maybeFileLinkCacheEntry = fileLinkCache.get(cacheKey);
if (maybeFileLinkCacheEntry !== undefined) {
return maybeFileLinkCacheEntry;
}

const fileLinkGenerator = async function (): Promise<string> {
let fileLinkCore: string;
try {
let retryCount = 0;
fileLinkCore = await runWithRetry(
async () =>
runWithRetryForCoherencyAndServiceReadOnlyErrors(
async () =>
getFileLinkWithLocationRedirectionHandling(getToken, resolvedUrl, logger),
"getFileLinkCore",
logger,
),
"getShareLink",
logger,
{
// TODO: use a stronger type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onRetry(delayInMs: number, error: any) {
retryCount++;
if (retryCount === 5) {
if (error !== undefined && typeof error === "object") {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
error.canRetry = false;
const fileLinkGenerator = async function (): Promise<string> {
let fileLinkCore: string;
try {
let retryCount = 0;
fileLinkCore = await runWithRetry(
async () =>
runWithRetryForCoherencyAndServiceReadOnlyErrors(
async () =>
getFileLinkWithLocationRedirectionHandling(getToken, resolvedUrl, logger),
"getFileLinkCore",
logger,
),
"getShareLink",
logger,
{
// TODO: use a stronger type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onRetry(delayInMs: number, error: any) {
retryCount++;
if (retryCount === 5) {
if (error !== undefined && typeof error === "object") {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
error.canRetry = false;
throw error;
}
throw error;
}
throw error;
}
},
},
},
);
} catch (error) {
// Delete from the cache to permit retrying later.
fileLinkCache.delete(cacheKey);
throw error;
}
);
} catch (error) {
// Delete from the cache to permit retrying later.
fileLinkCache.delete(cacheKey);
throw error;
}

// We are guaranteed to run the getFileLinkCore at least once with successful result (which must be a string)
assert(
fileLinkCore !== undefined,
0x292 /* "Unexpected undefined result from getFileLinkCore" */,
);
return fileLinkCore;
};
const fileLink = fileLinkGenerator();
fileLinkCache.set(cacheKey, fileLink);
return fileLink;
}
// We are guaranteed to run the getFileLinkCore at least once with successful result (which must be a string)
assert(
fileLinkCore !== undefined,
0x292 /* "Unexpected undefined result from getFileLinkCore" */,
);
return fileLinkCore;
};
const fileLink = fileLinkGenerator();
fileLinkCache.set(cacheKey, fileLink);
return fileLink;
},
);

/**
* Handles location redirection while fulfilling the getFileLink call. We don't want browser to handle
Expand Down
67 changes: 67 additions & 0 deletions packages/drivers/odsp-driver/src/mockify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/

/**
* A special key used to store the original function in a {@link Mockable | mockable} function.
* @remarks Use {@link mockify | `mockify.key`} as a convenient way to access this key.
*/
export const mockifyMockKey = Symbol("`mockify` mock function key");

/**
* A function that can be mocked after being decorated by {@link mockify | mockify()}.
*/
export interface Mockable<T extends (...args: any[]) => unknown> {
(...args: Parameters<T>): ReturnType<T>;
[mockifyMockKey]: T;
}

/**
* Decorates a function to allow it to be mocked.
* @param fn - The function that will become mockable.
* @returns A function with a {@link mockifyMockKey | special property } that can be overwritten to mock the original function.
* By default, this property is set to the original function.
* If overwritten with a new function, the new function will be called instead of the original.
* @example
* ```typescript
* const original = () => console.log("original");
* const mockable = mockify(original);
* mockable(); // logs "original"
* mockable[mockify.key] = () => console.log("mocked");
* mockable(); // logs "mocked"
* mockable[mockify.key] = original;
* mockable(); // logs "original"
* ```
*
* This pattern is useful for mocking top-level exported functions in a module.
* For example,
* ```typescript
* export function fn() { /* ... * / }
* ```
* becomes
* ```typescript
* import { mockify } from "./mockify.js";
* export const fn = mockify(() => { /* ... * / });
* ```
* and can now be mocked by another module that imports it.
* ```typescript
* import * as sinon from "sinon";
* import { mockify } from "./mockify.js";
* import { fn } from "./module.js";
* sinon.stub(fn, mockify.key).callsFake(() => {
* // ... mock function implementation ...
* });
* // ...
* sinon.restore();
* ```
*/
export function mockify<T extends (...args: any[]) => unknown>(fn: T): Mockable<T> {
const mockable = (...args: Parameters<T>): ReturnType<T> => {
return mockable[mockifyMockKey](...args) as ReturnType<T>;
};
mockable[mockifyMockKey] = fn;
return mockable;
}

mockify.key = mockifyMockKey;
Loading

0 comments on commit 603cbfe

Please sign in to comment.