Skip to content

Commit 2a6cc9c

Browse files
test: add files plugin upload + error-handling coverage (50 tests) (#319)
* test: add files plugin upload + error-handling coverage (50 tests) Adds a focused test file covering FilesPlugin paths that were lightly exercised: the upload streaming path, error mapping in _handleApiError, cache invalidation around mutations, raw/download response headers, shutdown behavior, volume discovery merging, path validation, and the clientConfig surface. Originally proposed in PR #256 alongside the analytics ARROW_STREAM work, but the file shipped with three classes of failures and was removed from that PR to unblock its CI: 1. Missing per-volume policy on test configs. The default policy is publicRead(), which denies upload/mkdir/delete and surfaced as 403 instead of the SDK-error status codes the tests asserted. Fixed by setting policy: policy.allowAll() on every test volume. 2. vi.spyOn(connector, "upload") inferred a stricter signature than the test's three-arg async mock. Switched the mock to spread args with a typed cast. 3. AuthenticationError test asserted "token" in the error message, but _extractUser actually surfaces the missing-x-forwarded-user path. Updated the assertion to match the real behavior. Co-authored-by: Isaac Signed-off-by: James Broadhead <jamesbroadhead@gmail.com> * test: split upload-and-write.test.ts into focused per-feature files The previous single file name implied upload + write coverage, but the file actually held tests for ~10 separate concerns (download, raw, path validation, shutdown, volume discovery, etc). Split into focused files: - error-handling.test.ts _handleApiError + _sendStatusError - upload.test.ts upload streaming + cache invalidation - raw-endpoint.test.ts /raw security headers - download-endpoint.test.ts /download Content-Disposition - delete.test.ts delete + cache invalidation - mkdir.test.ts mkdir + cache invalidation - shutdown.test.ts shutdown + trackWrite - volume-config.test.ts discoverVolumes + clientConfig - path-validation.test.ts null-byte / 4096-cap / required-path Shared pure helpers (mockReq/mockRes/getRouteHandler/etc) extracted to _test-helpers.ts. The vi.hoisted/vi.mock block stays inlined per file to match the convention in plugin.test.ts. No test logic changes: 50/50 still passing in the files plugin (1242 across appkit). Co-authored-by: Isaac Signed-off-by: James Broadhead <jamesbroadhead@gmail.com> * test: address ACE multi-model review findings Three test-quality fixes flagged by GPT 5.4 + Gemini 3.1 Pro review: - shutdown.test.ts: assert the shutdown promise stays unresolved while inflightWrites > 0 — the previous test would silently pass even if shutdown() returned immediately (the final inflightWrites === 0 check is trivially true regardless of waiting). - path-validation.test.ts: assert the SDK connector was not called when the handler rejects the input. Previously a regression that hit the SDK *and* returned 400 would still pass. - _test-helpers.ts: lowercase override.headers keys in mockReq so a caller passing "Content-Type" matches the case-insensitive req.header() lookup. Co-authored-by: Isaac Signed-off-by: James Broadhead <jamesbroadhead@gmail.com> --------- Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
1 parent 1cbf07b commit 2a6cc9c

10 files changed

Lines changed: 1828 additions & 0 deletions
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { Readable } from "node:stream";
2+
import { mockServiceContext, setupDatabricksEnv } from "@tools/test-helpers";
3+
import { vi } from "vitest";
4+
import { ServiceContext } from "../../../context/service-context";
5+
import type { FilesPlugin } from "../plugin";
6+
import { policy } from "../policy";
7+
8+
export const VOLUMES_CONFIG = {
9+
volumes: {
10+
uploads: { maxUploadSize: 100_000_000, policy: policy.allowAll() },
11+
exports: { policy: policy.allowAll() },
12+
},
13+
};
14+
15+
/**
16+
* Get a registered route handler from a FilesPlugin by HTTP method and path
17+
* suffix. Useful when a test wants to invoke a single route in isolation.
18+
*/
19+
export function getRouteHandler(
20+
plugin: FilesPlugin,
21+
method: "get" | "post" | "delete",
22+
pathSuffix: string,
23+
) {
24+
const mockRouter = {
25+
use: vi.fn(),
26+
get: vi.fn(),
27+
post: vi.fn(),
28+
put: vi.fn(),
29+
delete: vi.fn(),
30+
patch: vi.fn(),
31+
} as any;
32+
33+
plugin.injectRoutes(mockRouter);
34+
35+
const call = mockRouter[method].mock.calls.find(
36+
(c: unknown[]) =>
37+
typeof c[0] === "string" && (c[0] as string).endsWith(pathSuffix),
38+
);
39+
if (!call) throw new Error(`No route found for ${method} ...${pathSuffix}`);
40+
return call[call.length - 1] as (req: any, res: any) => Promise<void>;
41+
}
42+
43+
export function mockRes() {
44+
const res: any = {
45+
headersSent: false,
46+
};
47+
res.status = vi.fn().mockReturnValue(res);
48+
res.json = vi.fn().mockReturnValue(res);
49+
res.type = vi.fn().mockReturnValue(res);
50+
res.send = vi.fn().mockReturnValue(res);
51+
res.setHeader = vi.fn().mockReturnValue(res);
52+
res.write = vi.fn().mockReturnValue(true);
53+
res.destroy = vi.fn();
54+
res.end = vi.fn();
55+
res.on = vi.fn().mockReturnValue(res);
56+
res.once = vi.fn().mockReturnValue(res);
57+
res.emit = vi.fn().mockReturnValue(true);
58+
res.removeListener = vi.fn().mockReturnValue(res);
59+
res.pipe = vi.fn().mockReturnValue(res);
60+
return res;
61+
}
62+
63+
export function mockReq(
64+
volumeKey: string,
65+
overrides: Record<string, any> = {},
66+
): any {
67+
// Lowercase override header keys so `req.header(name)` (case-insensitive
68+
// via toLowerCase) matches them regardless of how callers cased the keys.
69+
const lowercased: Record<string, string> = {};
70+
for (const [k, v] of Object.entries(overrides.headers ?? {})) {
71+
lowercased[k.toLowerCase()] = v as string;
72+
}
73+
const headers: Record<string, string> = {
74+
"x-forwarded-access-token": "test-token",
75+
"x-forwarded-user": "test-user",
76+
...lowercased,
77+
};
78+
79+
const req: any = {
80+
params: { volumeKey },
81+
query: {},
82+
...overrides,
83+
headers,
84+
header: (name: string) => headers[name.toLowerCase()],
85+
};
86+
87+
return req;
88+
}
89+
90+
/**
91+
* Mock Express request that behaves as a Node Readable stream — needed by the
92+
* upload handler which calls Readable.toWeb(req).
93+
*/
94+
export function mockUploadReq(
95+
volumeKey: string,
96+
bodyChunks: Buffer[],
97+
overrides: Record<string, any> = {},
98+
): any {
99+
const headers: Record<string, string> = {
100+
"x-forwarded-access-token": "test-token",
101+
"x-forwarded-user": "test-user",
102+
...(overrides.headers ?? {}),
103+
};
104+
105+
let chunkIndex = 0;
106+
const stream = new Readable({
107+
read() {
108+
if (chunkIndex < bodyChunks.length) {
109+
this.push(bodyChunks[chunkIndex++]);
110+
} else {
111+
this.push(null);
112+
}
113+
},
114+
});
115+
116+
(stream as any).params = { volumeKey };
117+
(stream as any).query = overrides.query ?? {};
118+
(stream as any).headers = headers;
119+
(stream as any).header = (name: string) => headers[name.toLowerCase()];
120+
(stream as any).body = overrides.body;
121+
122+
return stream;
123+
}
124+
125+
export function makeStreamResponse(content: string) {
126+
const stream = new ReadableStream<Uint8Array>({
127+
start(controller) {
128+
controller.enqueue(new TextEncoder().encode(content));
129+
controller.close();
130+
},
131+
});
132+
return { contents: stream };
133+
}
134+
135+
export async function setupTestEnv() {
136+
vi.clearAllMocks();
137+
setupDatabricksEnv();
138+
ServiceContext.reset();
139+
process.env.DATABRICKS_VOLUME_UPLOADS = "/Volumes/catalog/schema/uploads";
140+
process.env.DATABRICKS_VOLUME_EXPORTS = "/Volumes/catalog/schema/exports";
141+
return mockServiceContext();
142+
}
143+
144+
export function teardownTestEnv(
145+
serviceContextMock:
146+
| Awaited<ReturnType<typeof mockServiceContext>>
147+
| undefined,
148+
) {
149+
serviceContextMock?.restore();
150+
delete process.env.DATABRICKS_VOLUME_UPLOADS;
151+
delete process.env.DATABRICKS_VOLUME_EXPORTS;
152+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2+
import { FilesPlugin } from "../plugin";
3+
import {
4+
getRouteHandler,
5+
mockReq,
6+
mockRes,
7+
setupTestEnv,
8+
teardownTestEnv,
9+
VOLUMES_CONFIG,
10+
} from "./_test-helpers";
11+
12+
const { mockClient, MockApiError, mockCacheInstance } = vi.hoisted(() => {
13+
const mockFilesApi = {
14+
listDirectoryContents: vi.fn(),
15+
download: vi.fn(),
16+
getMetadata: vi.fn(),
17+
upload: vi.fn(),
18+
createDirectory: vi.fn(),
19+
delete: vi.fn(),
20+
};
21+
const mockClient = {
22+
files: mockFilesApi,
23+
config: {
24+
host: "https://test.databricks.com",
25+
authenticate: vi.fn(),
26+
},
27+
};
28+
class MockApiError extends Error {
29+
statusCode: number;
30+
constructor(message: string, statusCode: number) {
31+
super(message);
32+
this.name = "ApiError";
33+
this.statusCode = statusCode;
34+
}
35+
}
36+
const mockCacheInstance = {
37+
get: vi.fn(),
38+
set: vi.fn(),
39+
delete: vi.fn(),
40+
getOrExecute: vi.fn(async (_key: unknown[], fn: () => Promise<unknown>) =>
41+
fn(),
42+
),
43+
generateKey: vi.fn((...args: unknown[]) => JSON.stringify(args)),
44+
};
45+
return { mockClient, MockApiError, mockCacheInstance };
46+
});
47+
48+
vi.mock("@databricks/sdk-experimental", () => ({
49+
WorkspaceClient: vi.fn(() => mockClient),
50+
ApiError: MockApiError,
51+
}));
52+
53+
vi.mock("../../../context", async (importOriginal) => {
54+
const actual = await importOriginal<typeof import("../../../context")>();
55+
return {
56+
...actual,
57+
getWorkspaceClient: vi.fn(() => mockClient),
58+
isInUserContext: vi.fn(() => true),
59+
};
60+
});
61+
62+
vi.mock("../../../cache", () => ({
63+
CacheManager: {
64+
getInstanceSync: vi.fn(() => mockCacheInstance),
65+
},
66+
}));
67+
68+
describe("FilesPlugin delete", () => {
69+
let serviceContextMock: Awaited<ReturnType<typeof setupTestEnv>>;
70+
71+
beforeEach(async () => {
72+
serviceContextMock = await setupTestEnv();
73+
});
74+
75+
afterEach(() => {
76+
teardownTestEnv(serviceContextMock);
77+
});
78+
79+
test("successful delete invalidates list cache", async () => {
80+
const plugin = new FilesPlugin(VOLUMES_CONFIG);
81+
const handler = getRouteHandler(plugin, "delete", "");
82+
const res = mockRes();
83+
84+
mockClient.files.delete.mockResolvedValue(undefined);
85+
86+
await handler(
87+
mockReq("uploads", {
88+
query: { path: "/Volumes/catalog/schema/uploads/dir/file.txt" },
89+
}),
90+
res,
91+
);
92+
93+
expect(res.json).toHaveBeenCalledWith(
94+
expect.objectContaining({ success: true }),
95+
);
96+
expect(mockCacheInstance.generateKey).toHaveBeenCalled();
97+
expect(mockCacheInstance.delete).toHaveBeenCalled();
98+
});
99+
100+
test("delete without path returns 400", async () => {
101+
const plugin = new FilesPlugin(VOLUMES_CONFIG);
102+
const handler = getRouteHandler(plugin, "delete", "");
103+
const res = mockRes();
104+
105+
await handler(mockReq("uploads", { query: {} }), res);
106+
107+
expect(res.status).toHaveBeenCalledWith(400);
108+
expect(res.json).toHaveBeenCalledWith(
109+
expect.objectContaining({ error: "path is required" }),
110+
);
111+
});
112+
113+
test("delete that throws ApiError returns proper status", async () => {
114+
const plugin = new FilesPlugin(VOLUMES_CONFIG);
115+
const handler = getRouteHandler(plugin, "delete", "");
116+
const res = mockRes();
117+
118+
mockClient.files.delete.mockRejectedValue(
119+
new MockApiError("Not found", 404),
120+
);
121+
122+
await handler(
123+
mockReq("uploads", {
124+
query: { path: "/Volumes/catalog/schema/uploads/missing.txt" },
125+
}),
126+
res,
127+
);
128+
129+
// SDK errors go through execute() which returns {ok: false, status: 404}
130+
// then _sendStatusError is called with STATUS_CODES[404] = "Not Found"
131+
expect(res.status).toHaveBeenCalledWith(404);
132+
});
133+
});

0 commit comments

Comments
 (0)