Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -225,13 +225,17 @@ export class ExportHistoryJobClient {
request,
);

// Then terminate the linked export orchestration if it exists
// Then terminate the linked export orchestration if it exists.
// Only swallow "not found" errors (orchestration doesn't exist or already purged).
// Re-throw all other errors (network failures, auth errors, timeouts, etc.).
try {
await this.client.terminateOrchestration(orchestrationInstanceId, "Export job deleted");
await this.client.waitForOrchestrationCompletion(orchestrationInstanceId, false, 30);
await this.client.purgeOrchestration(orchestrationInstanceId);
} catch {
// Orchestration instance doesn't exist or already purged - this is expected
} catch (e: unknown) {
if (!isNotFoundError(e)) {
throw e;
}
}
}
}
Expand Down Expand Up @@ -268,3 +272,22 @@ function matchesFilter(state: ExportJobState, filter: ExportJobQuery): boolean {
(state.createdAt !== undefined && new Date(state.createdAt) <= filter.createdTo);
return statusMatches && createdFromMatches && createdToMatches;
}

/** gRPC status code for NOT_FOUND (well-known constant from the gRPC spec). */
const GRPC_STATUS_NOT_FOUND = 5;

/**
* Checks whether an error is a gRPC NOT_FOUND error.
*
* Uses duck typing to inspect the error's `code` property without requiring
* a direct dependency on `@grpc/grpc-js`. gRPC `ServiceError` objects always
* carry a numeric `code` field matching the standard gRPC status codes.
*/
function isNotFoundError(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as { code: unknown }).code === GRPC_STATUS_NOT_FOUND
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import { ExportHistoryJobClient } from "../src/client/export-history-client";
import { ExportHistoryStorageOptions } from "../src/models";
import { ORCHESTRATOR_INSTANCE_ID_PREFIX } from "../src/constants";

/** gRPC status code constants for test readability. */
const GRPC_STATUS = {
NOT_FOUND: 5,
UNAVAILABLE: 14,
UNAUTHENTICATED: 16,
INTERNAL: 13,
};

/**
* Creates a mock gRPC error with the specified status code.
*/
function createGrpcError(code: number, message: string): Error & { code: number } {
const error = new Error(message) as Error & { code: number };
error.code = code;
return error;
}

/**
* Creates a minimal mock of TaskHubGrpcClient for testing the delete() method.
*/
function createMockClient(overrides?: {
scheduleNewOrchestration?: jest.Mock;
terminateOrchestration?: jest.Mock;
waitForOrchestrationCompletion?: jest.Mock;
purgeOrchestration?: jest.Mock;
}) {
return {
scheduleNewOrchestration: overrides?.scheduleNewOrchestration ?? jest.fn().mockResolvedValue("op-instance-123"),
terminateOrchestration: overrides?.terminateOrchestration ?? jest.fn().mockResolvedValue(undefined),
waitForOrchestrationCompletion: overrides?.waitForOrchestrationCompletion ?? jest.fn().mockResolvedValue(undefined),
purgeOrchestration: overrides?.purgeOrchestration ?? jest.fn().mockResolvedValue(undefined),
} as unknown as ConstructorParameters<typeof ExportHistoryJobClient>[0];
}

const TEST_STORAGE_OPTIONS: ExportHistoryStorageOptions = {
connectionString: "DefaultEndpointsProtocol=https;AccountName=test;AccountKey=key;EndpointSuffix=core.windows.net",
containerName: "test-container",
};
Comment on lines +42 to +45

const TEST_JOB_ID = "test-job-1";

describe("ExportHistoryJobClient.delete()", () => {
it("should complete successfully when orchestration exists and all cleanup succeeds", async () => {
const terminateMock = jest.fn().mockResolvedValue(undefined);
const waitMock = jest.fn().mockResolvedValue(undefined);
const purgeMock = jest.fn().mockResolvedValue(undefined);

const client = createMockClient({
terminateOrchestration: terminateMock,
waitForOrchestrationCompletion: waitMock,
purgeOrchestration: purgeMock,
});

const jobClient = new ExportHistoryJobClient(client, TEST_JOB_ID, TEST_STORAGE_OPTIONS);
await expect(jobClient.delete()).resolves.toBeUndefined();

const expectedOrchId = `${ORCHESTRATOR_INSTANCE_ID_PREFIX}${TEST_JOB_ID}`;
expect(terminateMock).toHaveBeenCalledWith(expectedOrchId, "Export job deleted");
expect(waitMock).toHaveBeenCalledWith(expectedOrchId, false, 30);
expect(purgeMock).toHaveBeenCalledWith(expectedOrchId);
});

it("should swallow gRPC NOT_FOUND error from terminateOrchestration", async () => {
const terminateMock = jest.fn().mockRejectedValue(
createGrpcError(GRPC_STATUS.NOT_FOUND, "Orchestration not found"),
);

const client = createMockClient({ terminateOrchestration: terminateMock });
const jobClient = new ExportHistoryJobClient(client, TEST_JOB_ID, TEST_STORAGE_OPTIONS);

await expect(jobClient.delete()).resolves.toBeUndefined();
});

it("should swallow gRPC NOT_FOUND error from purgeOrchestration", async () => {
const purgeMock = jest.fn().mockRejectedValue(
createGrpcError(GRPC_STATUS.NOT_FOUND, "Instance not found"),
);

const client = createMockClient({ purgeOrchestration: purgeMock });
const jobClient = new ExportHistoryJobClient(client, TEST_JOB_ID, TEST_STORAGE_OPTIONS);

await expect(jobClient.delete()).resolves.toBeUndefined();
});

it("should re-throw gRPC UNAVAILABLE error (network failure)", async () => {
const terminateMock = jest.fn().mockRejectedValue(
createGrpcError(GRPC_STATUS.UNAVAILABLE, "Connection refused"),
);

const client = createMockClient({ terminateOrchestration: terminateMock });
const jobClient = new ExportHistoryJobClient(client, TEST_JOB_ID, TEST_STORAGE_OPTIONS);

await expect(jobClient.delete()).rejects.toThrow("Connection refused");
});

it("should re-throw gRPC UNAUTHENTICATED error (auth failure)", async () => {
const terminateMock = jest.fn().mockRejectedValue(
createGrpcError(GRPC_STATUS.UNAUTHENTICATED, "Token expired"),
);

const client = createMockClient({ terminateOrchestration: terminateMock });
const jobClient = new ExportHistoryJobClient(client, TEST_JOB_ID, TEST_STORAGE_OPTIONS);

await expect(jobClient.delete()).rejects.toThrow("Token expired");
});

it("should re-throw gRPC INTERNAL error (server failure)", async () => {
const purgeMock = jest.fn().mockRejectedValue(
createGrpcError(GRPC_STATUS.INTERNAL, "Internal server error"),
);

const client = createMockClient({ purgeOrchestration: purgeMock });
const jobClient = new ExportHistoryJobClient(client, TEST_JOB_ID, TEST_STORAGE_OPTIONS);

await expect(jobClient.delete()).rejects.toThrow("Internal server error");
});

it("should re-throw timeout errors from waitForOrchestrationCompletion", async () => {
const waitMock = jest.fn().mockRejectedValue(new Error("Timed out waiting for orchestration"));

const client = createMockClient({ waitForOrchestrationCompletion: waitMock });
const jobClient = new ExportHistoryJobClient(client, TEST_JOB_ID, TEST_STORAGE_OPTIONS);

await expect(jobClient.delete()).rejects.toThrow("Timed out waiting for orchestration");
});

it("should re-throw plain errors without a gRPC code property", async () => {
const terminateMock = jest.fn().mockRejectedValue(new Error("Unexpected error"));

const client = createMockClient({ terminateOrchestration: terminateMock });
const jobClient = new ExportHistoryJobClient(client, TEST_JOB_ID, TEST_STORAGE_OPTIONS);

await expect(jobClient.delete()).rejects.toThrow("Unexpected error");
});

it("should still schedule entity deletion before attempting orchestration cleanup", async () => {
const scheduleMock = jest.fn().mockResolvedValue("op-instance-123");
const terminateMock = jest.fn().mockRejectedValue(
createGrpcError(GRPC_STATUS.NOT_FOUND, "Orchestration not found"),
);

const client = createMockClient({
scheduleNewOrchestration: scheduleMock,
terminateOrchestration: terminateMock,
});

const jobClient = new ExportHistoryJobClient(client, TEST_JOB_ID, TEST_STORAGE_OPTIONS);
await jobClient.delete();

expect(scheduleMock).toHaveBeenCalledTimes(1);
});
});
Loading