Skip to content

Commit 58da9af

Browse files
nickwinderclaude
andcommitted
Fix code quality issues from PR review
- Replace unsafe type assertion (as unknown as T) with getRemoteUrl() helper - Validate HTML assets upfront before calling registerAssets - Add comprehensive tests for URL support in action file inputs - Remove unused import and fix linting issues All 266 tests pass. Fixes all issues identified in PR #8 review. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 5328b79 commit 58da9af

5 files changed

Lines changed: 169 additions & 71 deletions

File tree

src/__tests__/unit/inputs.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isRemoteFileInput, processFileInput, validateFileInput } from '../../inputs';
1+
import { getRemoteUrl, isRemoteFileInput, processFileInput, validateFileInput } from '../../inputs';
22
import { ValidationError } from '../../errors';
33
import { Readable } from 'stream';
44
import fs from 'fs';
@@ -189,6 +189,42 @@ describe('Input Processing (Node.js only)', () => {
189189
});
190190
});
191191

192+
describe('getRemoteUrl', () => {
193+
it('should extract URL from URL string', () => {
194+
const url = 'https://example.com/test.pdf';
195+
expect(getRemoteUrl(url)).toBe(url);
196+
});
197+
198+
it('should return null for file path string', () => {
199+
expect(getRemoteUrl('test.pdf')).toBeNull();
200+
});
201+
202+
it('should extract URL from UrlInput object', () => {
203+
const urlInput = { type: 'url' as const, url: 'https://example.com/test.pdf' };
204+
expect(getRemoteUrl(urlInput)).toBe('https://example.com/test.pdf');
205+
});
206+
207+
it('should return null for Buffer', () => {
208+
expect(getRemoteUrl(Buffer.from('test'))).toBeNull();
209+
});
210+
211+
it('should return null for Uint8Array', () => {
212+
expect(getRemoteUrl(new Uint8Array([1, 2, 3]))).toBeNull();
213+
});
214+
215+
it('should return null for FilePathInput', () => {
216+
expect(getRemoteUrl({ type: 'file-path', path: 'test.pdf' })).toBeNull();
217+
});
218+
219+
it('should return null for BufferInput', () => {
220+
expect(getRemoteUrl({ type: 'buffer', buffer: Buffer.from('test'), filename: 'test.pdf' })).toBeNull();
221+
});
222+
223+
it('should return null for Uint8ArrayInput', () => {
224+
expect(getRemoteUrl({ type: 'uint8array', data: new Uint8Array([1, 2, 3]), filename: 'test.bin' })).toBeNull();
225+
});
226+
});
227+
192228
describe('processFileInput - Invalid inputs', () => {
193229
it('should throw for URL', async () => {
194230
await expect(processFileInput('https://example.com/test.pdf')).rejects.toThrow(

src/__tests__/unit/workflow.test.ts

Lines changed: 81 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ jest.mock('../../http');
1414
const mockValidateFileInput = inputsModule.validateFileInput as jest.MockedFunction<
1515
typeof inputsModule.validateFileInput
1616
>;
17+
const mockIsRemoteFileInput = inputsModule.isRemoteFileInput as jest.MockedFunction<
18+
typeof inputsModule.isRemoteFileInput
19+
>;
20+
const mockGetRemoteUrl = inputsModule.getRemoteUrl as jest.MockedFunction<
21+
typeof inputsModule.getRemoteUrl
22+
>;
1723
const mockSendRequest = httpModule.sendRequest as jest.MockedFunction<
1824
typeof httpModule.sendRequest
1925
>;
@@ -31,6 +37,8 @@ describe('WorkflowBuilder', () => {
3137
workflow = new WorkflowBuilder(mockClientOptions);
3238
// Default mocks
3339
mockValidateFileInput.mockReturnValue(true);
40+
mockIsRemoteFileInput.mockReturnValue(false);
41+
mockGetRemoteUrl.mockReturnValue(null); // Default: not a URL
3442
mockSendRequest.mockResolvedValue({
3543
data: new Blob(['mock response'], { type: 'application/pdf' }) as never,
3644
status: 200,
@@ -102,20 +110,16 @@ describe('WorkflowBuilder', () => {
102110
expect(result).toBe(workflow);
103111
});
104112

105-
it('should not call registerAssets when adding a file as a URL string', () => {
106-
// Mock isRemoteFileInput to return true for URL string
107-
jest.spyOn(inputsModule, 'isRemoteFileInput').mockReturnValueOnce(true);
108-
109-
// Create a spy on the registerAssets method
110-
const registerAssetsSpy = jest.spyOn(workflow as never, 'registerAssets');
111-
113+
it('should handle URL string input without adding to assets map', () => {
112114
const urlString = 'https://example.com/document.pdf';
115+
// Mock getRemoteUrl to return the URL (indicating it's a remote file)
116+
mockGetRemoteUrl.mockReturnValueOnce(urlString);
117+
113118
const result = workflow.addFilePart(urlString);
114119

115120
expect(result).toBe(workflow);
116-
expect(registerAssetsSpy).not.toHaveBeenCalled();
117121

118-
// Verify the file part was added with the URL
122+
// Verify the file part was added with the URL (not stored in assets)
119123
expect(
120124
workflow['buildInstructions'].parts[workflow['buildInstructions'].parts.length - 1],
121125
).toEqual(
@@ -124,21 +128,18 @@ describe('WorkflowBuilder', () => {
124128
}),
125129
);
126130

127-
registerAssetsSpy.mockRestore();
131+
// Verify no assets were registered (URLs are passed directly)
132+
expect(workflow['assets'].size).toBe(0);
128133
});
129134

130-
it('should not call registerAssets when adding a file as a URL object', () => {
131-
// Mock isRemoteFileInput to return true for URL object
132-
jest.spyOn(inputsModule, 'isRemoteFileInput').mockReturnValueOnce(true);
133-
134-
// Create a spy on the registerAssets method
135-
const registerAssetsSpy = jest.spyOn(workflow as never, 'registerAssets');
136-
135+
it('should handle URL object input without adding to assets map', () => {
137136
const urlObject: UrlInput = { type: 'url', url: 'https://example.com/document.pdf' };
137+
// Mock getRemoteUrl to return the URL (indicating it's a remote file)
138+
mockGetRemoteUrl.mockReturnValueOnce(urlObject.url);
139+
138140
const result = workflow.addFilePart(urlObject);
139141

140142
expect(result).toBe(workflow);
141-
expect(registerAssetsSpy).not.toHaveBeenCalled();
142143

143144
// Verify the file part was added with the URL
144145
expect(
@@ -149,7 +150,8 @@ describe('WorkflowBuilder', () => {
149150
}),
150151
);
151152

152-
registerAssetsSpy.mockRestore();
153+
// Verify no assets were registered (URLs are passed directly)
154+
expect(workflow['assets'].size).toBe(0);
153155
});
154156
});
155157

@@ -171,18 +173,14 @@ describe('WorkflowBuilder', () => {
171173
expect(result).toBe(workflow);
172174
});
173175

174-
it('should not call registerAssets when adding HTML as a URL string', () => {
175-
// Mock isRemoteFileInput to return true for URL string
176-
jest.spyOn(inputsModule, 'isRemoteFileInput').mockReturnValueOnce(true);
177-
178-
// Create a spy on the registerAssets method
179-
const registerAssetsSpy = jest.spyOn(workflow as never, 'registerAssets');
180-
176+
it('should handle HTML URL string input without adding to assets map', () => {
181177
const urlString = 'https://example.com/page.html';
178+
// Mock getRemoteUrl to return the URL (indicating it's a remote file)
179+
mockGetRemoteUrl.mockReturnValueOnce(urlString);
180+
182181
const result = workflow.addHtmlPart(urlString);
183182

184183
expect(result).toBe(workflow);
185-
expect(registerAssetsSpy).not.toHaveBeenCalled();
186184

187185
// Verify the HTML part was added with the URL
188186
expect(
@@ -193,21 +191,18 @@ describe('WorkflowBuilder', () => {
193191
}),
194192
);
195193

196-
registerAssetsSpy.mockRestore();
194+
// Verify no assets were registered (URLs are passed directly)
195+
expect(workflow['assets'].size).toBe(0);
197196
});
198197

199-
it('should not call registerAssets when adding HTML as a URL object', () => {
200-
// Mock isRemoteFileInput to return true for URL object
201-
jest.spyOn(inputsModule, 'isRemoteFileInput').mockReturnValueOnce(true);
202-
203-
// Create a spy on the registerAssets method
204-
const registerAssetsSpy = jest.spyOn(workflow as never, 'registerAssets');
205-
198+
it('should handle HTML URL object input without adding to assets map', () => {
206199
const urlObject: UrlInput = { type: 'url', url: 'https://example.com/page.html' };
200+
// Mock getRemoteUrl to return the URL (indicating it's a remote file)
201+
mockGetRemoteUrl.mockReturnValueOnce(urlObject.url);
202+
207203
const result = workflow.addHtmlPart(urlObject);
208204

209205
expect(result).toBe(workflow);
210-
expect(registerAssetsSpy).not.toHaveBeenCalled();
211206

212207
// Verify the HTML part was added with the URL
213208
expect(
@@ -218,7 +213,8 @@ describe('WorkflowBuilder', () => {
218213
}),
219214
);
220215

221-
registerAssetsSpy.mockRestore();
216+
// Verify no assets were registered (URLs are passed directly)
217+
expect(workflow['assets'].size).toBe(0);
222218
});
223219
});
224220

@@ -274,6 +270,54 @@ describe('WorkflowBuilder', () => {
274270
expect(result).toBe(workflow);
275271
expect(mockValidateFileInput).toHaveBeenCalledWith(xfdfFile);
276272
});
273+
274+
it('should handle URL input for watermarkImage action', () => {
275+
const urlInput: UrlInput = { type: 'url', url: 'https://example.com/watermark.png' };
276+
const action = BuildActions.watermarkImage(urlInput);
277+
278+
// Mock getRemoteUrl: first call for test.pdf (returns null), second for URL action (returns URL)
279+
mockGetRemoteUrl.mockReturnValueOnce(null).mockReturnValueOnce(urlInput.url);
280+
281+
workflow.addFilePart('test.pdf').applyAction(action);
282+
283+
// Verify the action's fileInput contains the URL
284+
expect(action.fileInput).toEqual(urlInput);
285+
286+
// Verify only the test.pdf was registered as local asset (not the watermark URL)
287+
expect(workflow['assets'].size).toBe(1);
288+
});
289+
290+
it('should handle URL input for applyInstantJson action', () => {
291+
const urlInput: UrlInput = { type: 'url', url: 'https://example.com/annotations.json' };
292+
const action = BuildActions.applyInstantJson(urlInput);
293+
294+
// Mock getRemoteUrl: first call for test.pdf (returns null), second for URL action (returns URL)
295+
mockGetRemoteUrl.mockReturnValueOnce(null).mockReturnValueOnce(urlInput.url);
296+
297+
workflow.addFilePart('test.pdf').applyAction(action);
298+
299+
// Verify the action's fileInput contains the URL
300+
expect(action.fileInput).toEqual(urlInput);
301+
302+
// Verify only the test.pdf was registered as local asset (not the JSON URL)
303+
expect(workflow['assets'].size).toBe(1);
304+
});
305+
306+
it('should handle URL input for applyXfdf action', () => {
307+
const urlInput: UrlInput = { type: 'url', url: 'https://example.com/annotations.xfdf' };
308+
const action = BuildActions.applyXfdf(urlInput);
309+
310+
// Mock getRemoteUrl: first call for test.pdf (returns null), second for URL action (returns URL)
311+
mockGetRemoteUrl.mockReturnValueOnce(null).mockReturnValueOnce(urlInput.url);
312+
313+
workflow.addFilePart('test.pdf').applyAction(action);
314+
315+
// Verify the action's fileInput contains the URL
316+
expect(action.fileInput).toEqual(urlInput);
317+
318+
// Verify only the test.pdf was registered as local asset (not the XFDF URL)
319+
expect(workflow['assets'].size).toBe(1);
320+
});
277321
});
278322

279323
describe('output methods', () => {

src/build.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import type { components } from './generated/api-types';
2-
import type { FileInput } from './types';
2+
import type { FileInputWithUrl } from './types';
33

44
const DEFAULT_DIMENSION = { value: 100, unit: '%' as const };
55

66
/**
7-
* Internal action type that holds FileInput for deferred registration
7+
* Internal action type that holds FileInput for deferred registration.
8+
* Supports both local files and URLs.
89
*/
910
export interface ActionWithFileInput<
1011
Action extends components['schemas']['BuildAction'] = components['schemas']['BuildAction'],
1112
> {
1213
__needsFileRegistration: true;
13-
fileInput: FileInput;
14+
fileInput: FileInputWithUrl;
1415
createAction: (fileHandle: components['schemas']['FileHandle']) => Action;
1516
}
1617

@@ -79,7 +80,7 @@ export const BuildActions = {
7980

8081
/**
8182
* Create an image watermark action
82-
* @param image - Watermark image
83+
* @param image - Watermark image (local file or URL)
8384
* @param options - Watermark options
8485
* @param options.width - Width dimension of the watermark (value and unit, e.g. {value: 100, unit: '%'})
8586
* @param options.height - Height dimension of the watermark (value and unit, e.g. {value: 100, unit: '%'})
@@ -91,7 +92,7 @@ export const BuildActions = {
9192
* @param options.opacity - Watermark opacity (0 is fully transparent, 1 is fully opaque)
9293
*/
9394
watermarkImage(
94-
image: FileInput,
95+
image: FileInputWithUrl,
9596
options: Partial<Omit<components['schemas']['ImageWatermarkAction'], 'type' | 'image'>> = {
9697
width: DEFAULT_DIMENSION,
9798
height: DEFAULT_DIMENSION,
@@ -127,10 +128,10 @@ export const BuildActions = {
127128

128129
/**
129130
* Create an apply Instant JSON action
130-
* @param file - Instant JSON file input
131+
* @param file - Instant JSON file input (local file or URL)
131132
*/
132133
applyInstantJson(
133-
file: FileInput,
134+
file: FileInputWithUrl,
134135
): ActionWithFileInput<components['schemas']['ApplyInstantJsonAction']> {
135136
return {
136137
__needsFileRegistration: true,
@@ -146,13 +147,13 @@ export const BuildActions = {
146147

147148
/**
148149
* Create an apply XFDF action
149-
* @param file - XFDF file input
150+
* @param file - XFDF file input (local file or URL)
150151
* @param options - Apply Xfdf options
151152
* @param options.ignorePageRotation - If true, ignores page rotation when applying XFDF data (default: false)
152153
* @param options.richTextEnabled - If true, plain text annotations will be converted to rich text annotations. If false, all text annotations will be plain text annotations (default: true)
153154
*/
154155
applyXfdf(
155-
file: FileInput,
156+
file: FileInputWithUrl,
156157
options?: Partial<Omit<components['schemas']['ApplyXfdfAction'], 'type' | 'file'>>,
157158
): ActionWithFileInput<components['schemas']['ApplyXfdfAction']> {
158159
return {

0 commit comments

Comments
 (0)