11import { describe , it , expect , mock , beforeEach } from 'bun:test' ;
22import { extractPorts } from '@sentris/component-sdk' ;
3- import { definition , validateDiscordWebhookUrl } from '../discord' ;
3+ import {
4+ definition ,
5+ validateDiscordWebhookUrl ,
6+ normalizeDiscordAttachments ,
7+ buildDiscordWebhookRequest ,
8+ } from '../discord' ;
49
510const VALID_WEBHOOK =
611 'https://discord.com/api/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyz123456' ;
712
13+ const defaultParams = {
14+ variables : [ ] as { name : string ; type ?: string } [ ] ,
15+ attachmentFileName : 'report.txt' ,
16+ attachmentContentFormat : 'text' as const ,
17+ attachmentMimeType : 'text/plain' ,
18+ } ;
19+
820describe ( 'Discord Webhook Component' , ( ) => {
921 let httpFetchMock : ReturnType < typeof mock > ;
1022
@@ -65,7 +77,7 @@ describe('Discord Webhook Component', () => {
6577 } ,
6678 ] ,
6779 } ,
68- params : { variables : [ ] } ,
80+ params : defaultParams ,
6981 } as any ,
7082 mockContext ,
7183 ) ;
@@ -88,7 +100,7 @@ describe('Discord Webhook Component', () => {
88100 content : 'Plain text' ,
89101 embeds : '[{"title": "Join", "description": "{{user}} joined the channel"}]' ,
90102 } ,
91- params : { variables : [ ] } ,
103+ params : defaultParams ,
92104 } as any ,
93105 mockContext ,
94106 ) ;
@@ -106,7 +118,7 @@ describe('Discord Webhook Component', () => {
106118 webhookUrl : VALID_WEBHOOK ,
107119 content : 'Hello' ,
108120 } ,
109- params : { username : 'Sentris Flow' , variables : [ ] } ,
121+ params : { ... defaultParams , username : 'Sentris Flow' } ,
110122 } as any ,
111123 mockContext ,
112124 ) ;
@@ -133,7 +145,7 @@ describe('Discord Webhook Component', () => {
133145 webhookUrl : VALID_WEBHOOK ,
134146 content : 'Hello' ,
135147 } ,
136- params : { variables : [ ] } ,
148+ params : defaultParams ,
137149 } as any ,
138150 mockContext ,
139151 ) ;
@@ -149,7 +161,7 @@ describe('Discord Webhook Component', () => {
149161 definition . execute (
150162 {
151163 inputs : { content : 'Hello' } ,
152- params : { variables : [ ] } ,
164+ params : defaultParams ,
153165 } as any ,
154166 mockContext ,
155167 ) ,
@@ -166,11 +178,148 @@ describe('Discord Webhook Component', () => {
166178 webhookUrl : VALID_WEBHOOK ,
167179 content : ' ' ,
168180 } ,
169- params : { variables : [ ] } ,
181+ params : {
182+ variables : [ ] ,
183+ attachmentFileName : 'report.txt' ,
184+ attachmentContentFormat : 'text' ,
185+ attachmentMimeType : 'text/plain' ,
186+ } ,
170187 } as any ,
171188 mockContext ,
172189 ) ,
173- ) . rejects . toThrow ( / c o n t e n t o r e m b e d s / i) ;
190+ ) . rejects . toThrow ( / c o n t e n t , e m b e d s , o r a t t a c h m e n t s / i) ;
191+ } ) ;
192+
193+ it ( 'sends attachment-only messages via multipart form data' , async ( ) => {
194+ const mockContext = createMockContext ( ) ;
195+
196+ const result = await definition . execute (
197+ {
198+ inputs : {
199+ webhookUrl : VALID_WEBHOOK ,
200+ attachmentContent : '{"findings":1}' ,
201+ } ,
202+ params : {
203+ ...defaultParams ,
204+ attachmentFileName : 'report.json' ,
205+ attachmentContentFormat : 'json' ,
206+ attachmentMimeType : 'application/json' ,
207+ } ,
208+ } as any ,
209+ mockContext ,
210+ ) ;
211+
212+ expect ( result . ok ) . toBe ( true ) ;
213+ const requestInit = httpFetchMock . mock . calls [ 0 ] [ 1 ] ;
214+ expect ( requestInit . headers ) . toBeUndefined ( ) ;
215+ expect ( requestInit . body ) . toBeInstanceOf ( FormData ) ;
216+ const payload = JSON . parse ( String ( requestInit . body . get ( 'payload_json' ) ) ) ;
217+ expect ( payload . attachments ) . toEqual ( [ { id : 0 , filename : 'report.json' } ] ) ;
218+ expect ( requestInit . body . get ( 'files[0]' ) ) . toBeInstanceOf ( Blob ) ;
219+ } ) ;
220+
221+ it ( 'sends File Loader objects and embed images using attachment scheme' , async ( ) => {
222+ const mockContext = createMockContext ( ) ;
223+ const pngBytes = Buffer . from ( 'fake-png-bytes' ) ;
224+
225+ const result = await definition . execute (
226+ {
227+ inputs : {
228+ webhookUrl : VALID_WEBHOOK ,
229+ content : 'Scan complete' ,
230+ embeds : [
231+ {
232+ title : 'Chart' ,
233+ image : { url : 'attachment://chart.png' } ,
234+ } ,
235+ ] ,
236+ attachments : {
237+ name : 'chart.png' ,
238+ mimeType : 'image/png' ,
239+ content : pngBytes . toString ( 'base64' ) ,
240+ } ,
241+ } ,
242+ params : defaultParams ,
243+ } as any ,
244+ mockContext ,
245+ ) ;
246+
247+ expect ( result . ok ) . toBe ( true ) ;
248+ const requestInit = httpFetchMock . mock . calls [ 0 ] [ 1 ] ;
249+ const payload = JSON . parse ( String ( requestInit . body . get ( 'payload_json' ) ) ) ;
250+ expect ( payload . content ) . toBe ( 'Scan complete' ) ;
251+ expect ( payload . embeds [ 0 ] . image . url ) . toBe ( 'attachment://chart.png' ) ;
252+ expect ( payload . attachments ) . toEqual ( [ { id : 0 , filename : 'chart.png' } ] ) ;
253+ } ) ;
254+
255+ it ( 'normalizes multiple attachment descriptors' , ( ) => {
256+ const parts = normalizeDiscordAttachments ( {
257+ attachments : [
258+ {
259+ fileName : 'a.txt' ,
260+ content : 'hello' ,
261+ contentFormat : 'text' ,
262+ mimeType : 'text/plain' ,
263+ } ,
264+ {
265+ name : 'b.json' ,
266+ content : Buffer . from ( '{}' ) . toString ( 'base64' ) ,
267+ mimeType : 'application/json' ,
268+ contentFormat : 'base64' ,
269+ } ,
270+ ] ,
271+ attachmentFileName : 'ignored.txt' ,
272+ attachmentContentFormat : 'text' ,
273+ attachmentMimeType : 'text/plain' ,
274+ } ) ;
275+
276+ expect ( parts ) . toHaveLength ( 2 ) ;
277+ expect ( parts [ 0 ] . fileName ) . toBe ( 'a.txt' ) ;
278+ expect ( parts [ 1 ] . fileName ) . toBe ( 'b.json' ) ;
279+ } ) ;
280+
281+ it ( 'rejects invalid base64 attachment content' , ( ) => {
282+ expect ( ( ) =>
283+ normalizeDiscordAttachments ( {
284+ attachments : {
285+ fileName : 'bad.bin' ,
286+ content : 'not valid base64!' ,
287+ contentFormat : 'base64' ,
288+ mimeType : 'application/octet-stream' ,
289+ } ,
290+ attachmentFileName : 'ignored.txt' ,
291+ attachmentContentFormat : 'text' ,
292+ attachmentMimeType : 'text/plain' ,
293+ } ) ,
294+ ) . toThrow ( / b a s e 6 4 / i) ;
295+ } ) ;
296+
297+ it ( 'builds JSON requests when no attachments are present' , ( ) => {
298+ const request = buildDiscordWebhookRequest ( { content : 'hello' } , [ ] ) ;
299+ expect ( request . headers ) . toEqual ( { 'Content-Type' : 'application/json' } ) ;
300+ expect ( JSON . parse ( String ( request . body ) ) ) . toEqual ( { content : 'hello' } ) ;
301+ } ) ;
302+
303+ it ( 'throws ValidationError for un-serializable embed objects' , async ( ) => {
304+ const mockContext = createMockContext ( ) ;
305+ const circularEmbed : Record < string , unknown > = { title : 'Loop' } ;
306+ circularEmbed . self = circularEmbed ;
307+
308+ await expect (
309+ definition . execute (
310+ {
311+ inputs : {
312+ webhookUrl : VALID_WEBHOOK ,
313+ content : 'This should not send' ,
314+ embeds : circularEmbed ,
315+ } ,
316+ params : defaultParams ,
317+ } as any ,
318+ mockContext ,
319+ ) ,
320+ ) . rejects . toThrow ( / e m b e d s / i) ;
321+
322+ expect ( httpFetchMock ) . not . toHaveBeenCalled ( ) ;
174323 } ) ;
175324
176325 it ( 'throws ValidationError when content exceeds Discord limit' , async ( ) => {
@@ -183,7 +332,7 @@ describe('Discord Webhook Component', () => {
183332 webhookUrl : VALID_WEBHOOK ,
184333 content : 'x' . repeat ( 2001 ) ,
185334 } ,
186- params : { variables : [ ] } ,
335+ params : defaultParams ,
187336 } as any ,
188337 mockContext ,
189338 ) ,
@@ -192,6 +341,7 @@ describe('Discord Webhook Component', () => {
192341
193342 it ( 'resolves dynamic ports for template variables' , ( ) => {
194343 const resolved = definition . resolvePorts ! ( {
344+ ...defaultParams ,
195345 variables : [
196346 { name : 'error_msg' , type : 'string' } ,
197347 { name : 'timestamp' , type : 'string' } ,
@@ -201,7 +351,8 @@ describe('Discord Webhook Component', () => {
201351 const ports = extractPorts ( resolved . inputs ! ) ;
202352 expect ( ports . find ( ( portMeta ) => portMeta . id === 'webhookUrl' ) ) . toBeDefined ( ) ;
203353 expect ( ports . find ( ( portMeta ) => portMeta . id === 'content' ) ) . toBeDefined ( ) ;
204- expect ( ports . find ( ( portMeta ) => portMeta . id === 'embeds' ) ) . toBeDefined ( ) ;
354+ expect ( ports . find ( ( portMeta ) => portMeta . id === 'attachments' ) ) . toBeDefined ( ) ;
355+ expect ( ports . find ( ( portMeta ) => portMeta . id === 'attachmentContent' ) ) . toBeDefined ( ) ;
205356 expect ( ports . find ( ( portMeta ) => portMeta . id === 'error_msg' ) ) . toBeDefined ( ) ;
206357 expect ( ports . find ( ( portMeta ) => portMeta . id === 'timestamp' ) ) . toBeDefined ( ) ;
207358 } ) ;
0 commit comments