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
152 changes: 152 additions & 0 deletions packages/appkit/src/plugins/files/tests/_test-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { Readable } from "node:stream";
import { mockServiceContext, setupDatabricksEnv } from "@tools/test-helpers";
import { vi } from "vitest";
import { ServiceContext } from "../../../context/service-context";
import type { FilesPlugin } from "../plugin";
import { policy } from "../policy";

export const VOLUMES_CONFIG = {
volumes: {
uploads: { maxUploadSize: 100_000_000, policy: policy.allowAll() },
exports: { policy: policy.allowAll() },
},
};

/**
* Get a registered route handler from a FilesPlugin by HTTP method and path
* suffix. Useful when a test wants to invoke a single route in isolation.
*/
export function getRouteHandler(
plugin: FilesPlugin,
method: "get" | "post" | "delete",
pathSuffix: string,
) {
const mockRouter = {
use: vi.fn(),
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
patch: vi.fn(),
} as any;

plugin.injectRoutes(mockRouter);

const call = mockRouter[method].mock.calls.find(
(c: unknown[]) =>
typeof c[0] === "string" && (c[0] as string).endsWith(pathSuffix),
);
if (!call) throw new Error(`No route found for ${method} ...${pathSuffix}`);
return call[call.length - 1] as (req: any, res: any) => Promise<void>;
}

export function mockRes() {
const res: any = {
headersSent: false,
};
res.status = vi.fn().mockReturnValue(res);
res.json = vi.fn().mockReturnValue(res);
res.type = vi.fn().mockReturnValue(res);
res.send = vi.fn().mockReturnValue(res);
res.setHeader = vi.fn().mockReturnValue(res);
res.write = vi.fn().mockReturnValue(true);
res.destroy = vi.fn();
res.end = vi.fn();
res.on = vi.fn().mockReturnValue(res);
res.once = vi.fn().mockReturnValue(res);
res.emit = vi.fn().mockReturnValue(true);
res.removeListener = vi.fn().mockReturnValue(res);
res.pipe = vi.fn().mockReturnValue(res);
return res;
}

export function mockReq(
volumeKey: string,
overrides: Record<string, any> = {},
): any {
// Lowercase override header keys so `req.header(name)` (case-insensitive
// via toLowerCase) matches them regardless of how callers cased the keys.
const lowercased: Record<string, string> = {};
for (const [k, v] of Object.entries(overrides.headers ?? {})) {
lowercased[k.toLowerCase()] = v as string;
}
const headers: Record<string, string> = {
"x-forwarded-access-token": "test-token",
"x-forwarded-user": "test-user",
...lowercased,
};

const req: any = {
params: { volumeKey },
query: {},
...overrides,
headers,
header: (name: string) => headers[name.toLowerCase()],
};

return req;
}

/**
* Mock Express request that behaves as a Node Readable stream — needed by the
* upload handler which calls Readable.toWeb(req).
*/
export function mockUploadReq(
volumeKey: string,
bodyChunks: Buffer[],
overrides: Record<string, any> = {},
): any {
const headers: Record<string, string> = {
"x-forwarded-access-token": "test-token",
"x-forwarded-user": "test-user",
...(overrides.headers ?? {}),
};

let chunkIndex = 0;
const stream = new Readable({
read() {
if (chunkIndex < bodyChunks.length) {
this.push(bodyChunks[chunkIndex++]);
} else {
this.push(null);
}
},
});

(stream as any).params = { volumeKey };
(stream as any).query = overrides.query ?? {};
(stream as any).headers = headers;
(stream as any).header = (name: string) => headers[name.toLowerCase()];
(stream as any).body = overrides.body;

return stream;
}

export function makeStreamResponse(content: string) {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(content));
controller.close();
},
});
return { contents: stream };
}

export async function setupTestEnv() {
vi.clearAllMocks();
setupDatabricksEnv();
ServiceContext.reset();
process.env.DATABRICKS_VOLUME_UPLOADS = "/Volumes/catalog/schema/uploads";
process.env.DATABRICKS_VOLUME_EXPORTS = "/Volumes/catalog/schema/exports";
return mockServiceContext();
}

export function teardownTestEnv(
serviceContextMock:
| Awaited<ReturnType<typeof mockServiceContext>>
| undefined,
) {
serviceContextMock?.restore();
delete process.env.DATABRICKS_VOLUME_UPLOADS;
delete process.env.DATABRICKS_VOLUME_EXPORTS;
}
133 changes: 133 additions & 0 deletions packages/appkit/src/plugins/files/tests/delete.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { FilesPlugin } from "../plugin";
import {
getRouteHandler,
mockReq,
mockRes,
setupTestEnv,
teardownTestEnv,
VOLUMES_CONFIG,
} from "./_test-helpers";

const { mockClient, MockApiError, mockCacheInstance } = vi.hoisted(() => {
const mockFilesApi = {
listDirectoryContents: vi.fn(),
download: vi.fn(),
getMetadata: vi.fn(),
upload: vi.fn(),
createDirectory: vi.fn(),
delete: vi.fn(),
};
const mockClient = {
files: mockFilesApi,
config: {
host: "https://test.databricks.com",
authenticate: vi.fn(),
},
};
class MockApiError extends Error {
statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
}
}
const mockCacheInstance = {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
getOrExecute: vi.fn(async (_key: unknown[], fn: () => Promise<unknown>) =>
fn(),
),
generateKey: vi.fn((...args: unknown[]) => JSON.stringify(args)),
};
return { mockClient, MockApiError, mockCacheInstance };
});

vi.mock("@databricks/sdk-experimental", () => ({
WorkspaceClient: vi.fn(() => mockClient),
ApiError: MockApiError,
}));

vi.mock("../../../context", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../context")>();
return {
...actual,
getWorkspaceClient: vi.fn(() => mockClient),
isInUserContext: vi.fn(() => true),
};
});

vi.mock("../../../cache", () => ({
CacheManager: {
getInstanceSync: vi.fn(() => mockCacheInstance),
},
}));

describe("FilesPlugin delete", () => {
let serviceContextMock: Awaited<ReturnType<typeof setupTestEnv>>;

beforeEach(async () => {
serviceContextMock = await setupTestEnv();
});

afterEach(() => {
teardownTestEnv(serviceContextMock);
});

test("successful delete invalidates list cache", async () => {
const plugin = new FilesPlugin(VOLUMES_CONFIG);
const handler = getRouteHandler(plugin, "delete", "");
const res = mockRes();

mockClient.files.delete.mockResolvedValue(undefined);

await handler(
mockReq("uploads", {
query: { path: "/Volumes/catalog/schema/uploads/dir/file.txt" },
}),
res,
);

expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ success: true }),
);
expect(mockCacheInstance.generateKey).toHaveBeenCalled();
expect(mockCacheInstance.delete).toHaveBeenCalled();
});

test("delete without path returns 400", async () => {
const plugin = new FilesPlugin(VOLUMES_CONFIG);
const handler = getRouteHandler(plugin, "delete", "");
const res = mockRes();

await handler(mockReq("uploads", { query: {} }), res);

expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: "path is required" }),
);
});

test("delete that throws ApiError returns proper status", async () => {
const plugin = new FilesPlugin(VOLUMES_CONFIG);
const handler = getRouteHandler(plugin, "delete", "");
const res = mockRes();

mockClient.files.delete.mockRejectedValue(
new MockApiError("Not found", 404),
);

await handler(
mockReq("uploads", {
query: { path: "/Volumes/catalog/schema/uploads/missing.txt" },
}),
res,
);

// SDK errors go through execute() which returns {ok: false, status: 404}
// then _sendStatusError is called with STATUS_CODES[404] = "Not Found"
expect(res.status).toHaveBeenCalledWith(404);
});
});
Loading
Loading