Skip to content

Commit 6e1c7d1

Browse files
committed
Merge branch 'fix/qb-reports-documents' into integration/quickbooks-doc-alignment
2 parents 970c092 + e8cc16d commit 6e1c7d1

9 files changed

Lines changed: 668 additions & 85 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { quickbooksAddAttachmentTool } from '@/tools/quickbooks/add_attachment'
6+
import {
7+
assertQuickBooksAttachmentExtension,
8+
buildQuickBooksAttachableMetadata,
9+
parseQuickBooksAttachableResponse,
10+
sanitizeQuickBooksFileName,
11+
validateQuickBooksAttachmentFileType,
12+
} from '@/tools/quickbooks/documents_utils'
13+
import { quickbooksDownloadAttachmentTool } from '@/tools/quickbooks/download_attachment'
14+
import { quickbooksDownloadTransactionPdfTool } from '@/tools/quickbooks/download_transaction_pdf'
15+
import { quickbooksReadAttachmentsTool } from '@/tools/quickbooks/read_attachments'
16+
17+
function attachableFaultResponse(): Response {
18+
return new Response(
19+
JSON.stringify({
20+
AttachableResponse: [
21+
{
22+
Fault: {
23+
Error: [{ code: '610', Message: 'Object Not Found', Detail: 'Attachable id 99' }],
24+
type: 'ValidationFault',
25+
},
26+
},
27+
],
28+
}),
29+
{ headers: { 'content-type': 'application/json' } }
30+
)
31+
}
32+
33+
describe('QuickBooks file tool wiring', () => {
34+
it('materializes only provider input for attachment creation', () => {
35+
const input = quickbooksAddAttachmentTool.operation.input({
36+
accessToken: 'token',
37+
realmId: '123',
38+
quickBooksEnvironment: 'sandbox',
39+
attachmentKind: 'note',
40+
targetType: 'invoice',
41+
targetId: 'invoice-1',
42+
note: 'Follow up',
43+
})
44+
45+
expect(quickbooksAddAttachmentTool.request).toBeUndefined()
46+
expect(input).toEqual({
47+
accessToken: 'token',
48+
realmId: '123',
49+
quickBooksEnvironment: 'sandbox',
50+
attachmentKind: 'note',
51+
targetType: 'invoice',
52+
targetId: 'invoice-1',
53+
file: undefined,
54+
fileName: undefined,
55+
contentType: undefined,
56+
description: undefined,
57+
note: 'Follow up',
58+
})
59+
})
60+
61+
it('does not accept execution scope from attachment download parameters', () => {
62+
const input = quickbooksDownloadAttachmentTool.operation.input({
63+
accessToken: 'token',
64+
realmId: '123',
65+
quickBooksEnvironment: 'sandbox',
66+
attachmentId: 'attachment-1',
67+
fileName: 'receipt.pdf',
68+
})
69+
70+
expect(quickbooksDownloadAttachmentTool.request).toBeUndefined()
71+
expect(input).toEqual({
72+
accessToken: 'token',
73+
realmId: '123',
74+
quickBooksEnvironment: 'sandbox',
75+
attachmentId: 'attachment-1',
76+
fileName: 'receipt.pdf',
77+
})
78+
})
79+
80+
it('materializes only provider input for PDF downloads', () => {
81+
const input = quickbooksDownloadTransactionPdfTool.operation.input({
82+
accessToken: 'token',
83+
realmId: '123',
84+
quickBooksEnvironment: 'sandbox',
85+
transactionType: 'invoice',
86+
transactionId: 'invoice-1',
87+
fileName: 'invoice.pdf',
88+
})
89+
90+
expect(quickbooksDownloadTransactionPdfTool.request).toBeUndefined()
91+
expect(input).toEqual({
92+
accessToken: 'token',
93+
realmId: '123',
94+
quickBooksEnvironment: 'sandbox',
95+
transactionType: 'invoice',
96+
transactionId: 'invoice-1',
97+
fileName: 'invoice.pdf',
98+
})
99+
})
100+
})
101+
102+
describe('QuickBooks attachment file guards', () => {
103+
it('refuses an unsupported extension from the file name alone', () => {
104+
expect(() => assertQuickBooksAttachmentExtension('backup.zip')).toThrow(
105+
'QuickBooks does not support the zip file type'
106+
)
107+
expect(() => assertQuickBooksAttachmentExtension('backup')).toThrow(
108+
'QuickBooks does not support an extensionless file type'
109+
)
110+
expect(() => assertQuickBooksAttachmentExtension('receipt.pdf')).not.toThrow()
111+
})
112+
113+
it('canonicalizes a .jpg upload to image/jpeg', () => {
114+
expect(validateQuickBooksAttachmentFileType('photo.jpg', 'image/jpg')).toBe('image/jpeg')
115+
expect(validateQuickBooksAttachmentFileType('photo.jpg', 'image/pjpeg')).toBe('image/jpeg')
116+
expect(() => validateQuickBooksAttachmentFileType('photo.jpg', 'image/png')).toThrow(
117+
'QuickBooks does not support the jpg / image/png file type combination'
118+
)
119+
})
120+
121+
it('reduces a traversal-shaped file name to a safe leaf', () => {
122+
expect(sanitizeQuickBooksFileName('../../etc/passwd', 'fallback.pdf')).toBe('passwd')
123+
expect(sanitizeQuickBooksFileName('..', 'fallback.pdf')).toBe('fallback.pdf')
124+
})
125+
126+
it('builds the documented Attachable metadata for the file_metadata_01 part', () => {
127+
expect(
128+
buildQuickBooksAttachableMetadata('invoice', 'invoice-1', {
129+
fileName: 'receipt.pdf',
130+
contentType: 'application/pdf',
131+
note: 'Signed copy',
132+
})
133+
).toEqual({
134+
AttachableRef: [{ EntityRef: { type: 'Invoice', value: 'invoice-1' } }],
135+
FileName: 'receipt.pdf',
136+
ContentType: 'application/pdf',
137+
Note: 'Signed copy',
138+
})
139+
})
140+
})
141+
142+
describe('QuickBooks Attachable fault reporting', () => {
143+
it('names the read operation when a read returns a nested fault', async () => {
144+
await expect(
145+
parseQuickBooksAttachableResponse(attachableFaultResponse(), undefined, 'attachment read')
146+
).rejects.toThrow(/^QuickBooks attachment read failed:/)
147+
})
148+
149+
it('still names the upload operation for callers that supply no label', async () => {
150+
await expect(parseQuickBooksAttachableResponse(attachableFaultResponse())).rejects.toThrow(
151+
/^QuickBooks attachment upload failed:/
152+
)
153+
})
154+
155+
it('routes attachment reads through the labelled parser', async () => {
156+
await expect(
157+
quickbooksReadAttachmentsTool.transformResponse?.(attachableFaultResponse(), {
158+
accessToken: 'token',
159+
realmId: '123',
160+
quickBooksEnvironment: 'sandbox',
161+
readMode: 'by_id',
162+
attachmentId: 'attachment-1',
163+
})
164+
).rejects.toThrow(/^QuickBooks attachment read failed:/)
165+
})
166+
})

‎apps/sim/tools/quickbooks/documents_utils.ts‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ interface QuickBooksFileType {
7171
* Extension allowlist for QuickBooks attachments. Each entry follows Intuit's
7272
* published extension/content-type table, tolerates common browser and OS MIME
7373
* aliases, and normalizes them to one canonical content type before upload.
74+
*
75+
* Sending `image/jpeg` for a `.jpg` file is deliberate and must not be "fixed" back to
76+
* `image/jpg`. Intuit's Attachable upload sample sends `image/jpg` while the read response for
77+
* that same file returns `image/jpeg`, so QuickBooks normalizes on ingest; and the
78+
* `attachablerequest` model has no `ContentType` property at all, which makes the multipart
79+
* metadata content type advisory rather than contractual.
7480
*/
7581
const QUICKBOOKS_FILE_TYPES: Record<string, QuickBooksFileType> = {
7682
ai: {
@@ -207,8 +213,14 @@ export function sanitizeQuickBooksFileName(value: string | undefined, fallback:
207213
return (value ? sanitize(value) : undefined) ?? sanitize(fallback) ?? 'quickbooks-file'
208214
}
209215

216+
/**
217+
* Returns the lowercased extension, or an empty string when the name carries none. Splitting on
218+
* `.` alone would report a dotless name as its own extension, so an unattachable `backup` file
219+
* would be refused as "the backup file type" instead of as an extensionless one.
220+
*/
210221
function getQuickBooksFileExtension(fileName: string): string {
211-
return fileName.split('.').pop()?.toLowerCase() ?? ''
222+
const separator = fileName.lastIndexOf('.')
223+
return separator > 0 ? fileName.slice(separator + 1).toLowerCase() : ''
212224
}
213225

214226
/**
@@ -273,9 +285,17 @@ export function sanitizeQuickBooksAttachable(
273285
return safeAttachment as QuickBooksAttachable
274286
}
275287

288+
/**
289+
* Parses an Intuit Attachable envelope for any operation that returns one.
290+
*
291+
* `operationLabel` names the operation in the nested-fault error message. It defaults to the
292+
* upload wording because the upload path is the older caller; every other caller passes its own
293+
* label so a read failure is not reported as a failed upload.
294+
*/
276295
export async function parseQuickBooksAttachableResponse(
277296
response: Response,
278-
signal?: AbortSignal
297+
signal?: AbortSignal,
298+
operationLabel = 'attachment upload'
279299
): Promise<{ attachment: QuickBooksAttachable; time: string | null }> {
280300
const data = await parseQuickBooksJson<QuickBooksAttachableEnvelope>(
281301
response,
@@ -286,7 +306,7 @@ export async function parseQuickBooksAttachableResponse(
286306
const sanitizedFault = sanitizeQuickBooksFaultData({ Fault: nestedFault })
287307
if (sanitizedFault) {
288308
throw new Error(
289-
`QuickBooks attachment upload failed: ${formatQuickBooksFaultDetail(sanitizedFault)}`
309+
`QuickBooks ${operationLabel} failed: ${formatQuickBooksFaultDetail(sanitizedFault)}`
290310
)
291311
}
292312
const attachment = data.Attachable ?? data.AttachableResponse?.[0]?.Attachable
@@ -303,6 +323,12 @@ export async function parseQuickBooksAttachableResponse(
303323
}
304324
}
305325

326+
/**
327+
* Builds the `file_metadata_01` part of an Attachable multipart upload.
328+
*
329+
* Intuit's `attachablerequest` model has exactly one free-text field, `Note`, so `description`
330+
* and `note` both land there and `note` wins when a caller supplies both.
331+
*/
306332
export function buildQuickBooksAttachableMetadata(
307333
targetType: QuickBooksAttachmentTargetType,
308334
targetId: string,

‎apps/sim/tools/quickbooks/file_operations.test.ts‎

Lines changed: 0 additions & 76 deletions
This file was deleted.

‎apps/sim/tools/quickbooks/read_attachments.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ export const quickbooksReadAttachmentsTool: ToolConfig<
132132
transformResponse: async (response, params) => {
133133
if (!params) throw new Error('QuickBooks Read Attachments parameters are required')
134134
if (params.readMode === 'by_id') {
135-
const parsed = await parseQuickBooksAttachableResponse(response)
135+
const parsed = await parseQuickBooksAttachableResponse(response, undefined, 'attachment read')
136136
return { success: true, output: { item: parsed.attachment, time: parsed.time } }
137137
}
138138
const pagination = validateQuickBooksPagination(

0 commit comments

Comments
 (0)