1+ import { describe , it , expect , beforeAll , afterAll , beforeEach } from 'vitest' ;
2+ import { GenericContainer , type StartedTestContainer } from 'testcontainers' ;
3+ import { SQSClient , CreateQueueCommand , DeleteQueueCommand , PurgeQueueCommand } from '@aws-sdk/client-sqs' ;
4+ import { SqsQueue } from '../../src/drivers/sqs.ts' ;
5+ import { SQSClientV3Adapter } from '../utils/sqs-v3-adapter.ts' ;
6+
7+ interface TestJobs {
8+ 'process-data' : { id : number ; data : string } ;
9+ 'send-notification' : { email : string ; message : string } ;
10+ }
11+
12+ describe ( 'SQS Integration Tests (LocalStack)' , ( ) => {
13+ let container : StartedTestContainer ;
14+ let sqsClient : SQSClient ;
15+ let queueUrl : string ;
16+ let queue : SqsQueue < TestJobs > ;
17+
18+ beforeAll ( async ( ) => {
19+ // Start LocalStack container
20+ console . log ( 'Starting LocalStack container...' ) ;
21+ container = await new GenericContainer ( 'localstack/localstack:3.0' )
22+ . withEnvironment ( {
23+ 'SERVICES' : 'sqs' ,
24+ 'DEBUG' : '1' ,
25+ 'PERSISTENCE' : '0'
26+ } )
27+ . withExposedPorts ( 4566 )
28+ . withStartupTimeout ( 90000 )
29+ . start ( ) ;
30+
31+ const endpoint = `http://${ container . getHost ( ) } :${ container . getMappedPort ( 4566 ) } ` ;
32+ console . log ( `LocalStack started at ${ endpoint } ` ) ;
33+
34+ // Create SQS client pointing to LocalStack
35+ sqsClient = new SQSClient ( {
36+ region : 'us-east-1' ,
37+ endpoint,
38+ credentials : {
39+ accessKeyId : 'test' ,
40+ secretAccessKey : 'test'
41+ }
42+ } ) ;
43+
44+ // Create test queue
45+ const createQueueResult = await sqsClient . send ( new CreateQueueCommand ( {
46+ QueueName : 'test-queue'
47+ } ) ) ;
48+
49+ queueUrl = createQueueResult . QueueUrl ! ;
50+ console . log ( `Test queue created: ${ queueUrl } ` ) ;
51+ } , 120000 ) ; // 2 minute timeout for container startup
52+
53+ afterAll ( async ( ) => {
54+ // Clean up
55+ if ( sqsClient && queueUrl ) {
56+ try {
57+ await sqsClient . send ( new DeleteQueueCommand ( { QueueUrl : queueUrl } ) ) ;
58+ } catch ( error ) {
59+ console . warn ( 'Failed to delete test queue:' , error ) ;
60+ }
61+ }
62+
63+ if ( container ) {
64+ await container . stop ( ) ;
65+ }
66+ } ) ;
67+
68+ beforeEach ( async ( ) => {
69+ // Purge queue before each test
70+ try {
71+ await sqsClient . send ( new PurgeQueueCommand ( { QueueUrl : queueUrl } ) ) ;
72+ } catch ( error ) {
73+ // Queue might be empty, ignore
74+ }
75+
76+ // Create fresh queue instance using the v3 adapter
77+ const adapter = new SQSClientV3Adapter ( sqsClient ) ;
78+ queue = new SqsQueue < TestJobs > ( adapter , queueUrl ) ;
79+ } ) ;
80+
81+ describe ( 'Real SQS Operations' , ( ) => {
82+ it ( 'should add a job and retrieve it from real SQS' , async ( ) => {
83+ const jobId = await queue . addJob ( 'process-data' , {
84+ payload : { id : 1 , data : 'test data' }
85+ } ) ;
86+
87+ expect ( jobId ) . toBeTruthy ( ) ;
88+
89+ // Reserve the job
90+ const reserved = await queue [ 'reserve' ] ( 1 ) ;
91+ expect ( reserved ) . not . toBeNull ( ) ;
92+ expect ( reserved ! . id ) . toBe ( jobId ) ;
93+
94+ // Parse the payload
95+ const payload = JSON . parse ( reserved ! . payload ) ;
96+ expect ( payload ) . toEqual ( {
97+ name : 'process-data' ,
98+ payload : { id : 1 , data : 'test data' }
99+ } ) ;
100+ } ) ;
101+
102+ it ( 'should handle job execution lifecycle' , async ( ) => {
103+ const processedJobs : Array < { id : number ; data : string } > = [ ] ;
104+
105+ // Register job handler
106+ queue . onJob ( 'process-data' , async ( payload ) => {
107+ processedJobs . push ( payload ) ;
108+ return `Processed: ${ payload . data } ` ;
109+ } ) ;
110+
111+ // Add job
112+ await queue . addJob ( 'process-data' , {
113+ payload : { id : 2 , data : 'lifecycle test' }
114+ } ) ;
115+
116+ // Process job
117+ await queue . run ( false ) ; // Run once
118+
119+ expect ( processedJobs ) . toHaveLength ( 1 ) ;
120+ expect ( processedJobs [ 0 ] ) . toEqual ( { id : 2 , data : 'lifecycle test' } ) ;
121+ } ) ;
122+
123+ it ( 'should respect delay seconds' , async ( ) => {
124+ const startTime = Date . now ( ) ;
125+
126+ await queue . addJob ( 'process-data' , {
127+ payload : { id : 3 , data : 'delayed job' } ,
128+ delay : 2 // 2 seconds delay
129+ } ) ;
130+
131+ // Immediate attempt should return null (job not available yet)
132+ const immediateReserve = await queue [ 'reserve' ] ( 0 ) ;
133+ expect ( immediateReserve ) . toBeNull ( ) ;
134+
135+ // Wait for delay + small buffer
136+ await new Promise ( resolve => setTimeout ( resolve , 3000 ) ) ;
137+
138+ const delayedReserve = await queue [ 'reserve' ] ( 1 ) ;
139+ expect ( delayedReserve ) . not . toBeNull ( ) ;
140+
141+ const elapsedTime = Date . now ( ) - startTime ;
142+ expect ( elapsedTime ) . toBeGreaterThanOrEqual ( 2000 ) ; // At least 2 seconds
143+ } ) ;
144+
145+ it ( 'should handle TTR (Time To Run) correctly' , async ( ) => {
146+ await queue . addJob ( 'process-data' , {
147+ payload : { id : 4 , data : 'ttr test' } ,
148+ ttr : 5 // 5 seconds TTR
149+ } ) ;
150+
151+ // Reserve job
152+ const reserved = await queue [ 'reserve' ] ( 1 ) ;
153+ expect ( reserved ) . not . toBeNull ( ) ;
154+ expect ( reserved ! . meta . ttr ) . toBe ( 5 ) ;
155+
156+ // Verify job metadata contains receiptHandle for SQS
157+ expect ( reserved ! . meta . receiptHandle ) . toBeTruthy ( ) ;
158+ } ) ;
159+
160+ it ( 'should handle multiple jobs concurrently' , async ( ) => {
161+ const promises = [ ] ;
162+
163+ // Add multiple jobs
164+ for ( let i = 0 ; i < 5 ; i ++ ) {
165+ promises . push (
166+ queue . addJob ( 'process-data' , {
167+ payload : { id : i , data : `concurrent job ${ i } ` }
168+ } )
169+ ) ;
170+ }
171+
172+ const jobIds = await Promise . all ( promises ) ;
173+ expect ( jobIds ) . toHaveLength ( 5 ) ;
174+ expect ( new Set ( jobIds ) . size ) . toBe ( 5 ) ; // All IDs should be unique
175+
176+ // Process all jobs
177+ const processedJobs : Array < { id : number ; data : string } > = [ ] ;
178+ queue . onJob ( 'process-data' , async ( payload ) => {
179+ processedJobs . push ( payload ) ;
180+ } ) ;
181+
182+ // Run queue multiple times to process all jobs
183+ for ( let i = 0 ; i < 5 ; i ++ ) {
184+ await queue . run ( false ) ;
185+ }
186+
187+ expect ( processedJobs ) . toHaveLength ( 5 ) ;
188+ } ) ;
189+
190+ it ( 'should handle job errors and event system' , async ( ) => {
191+ let errorOccurred = false ;
192+ let beforeExecCalled = false ;
193+ let afterErrorCalled = false ;
194+
195+ // Register event handlers
196+ queue . on ( 'beforeExec' , ( ) => {
197+ beforeExecCalled = true ;
198+ } ) ;
199+
200+ queue . on ( 'afterError' , ( event ) => {
201+ afterErrorCalled = true ;
202+ expect ( event . error ) . toBeInstanceOf ( Error ) ;
203+ } ) ;
204+
205+ // Register failing job handler
206+ queue . onJob ( 'process-data' , async ( ) => {
207+ throw new Error ( 'Intentional test failure' ) ;
208+ } ) ;
209+
210+ await queue . addJob ( 'process-data' , {
211+ payload : { id : 5 , data : 'error test' }
212+ } ) ;
213+
214+ // Process the failing job
215+ await queue . run ( false ) ;
216+
217+ expect ( beforeExecCalled ) . toBe ( true ) ;
218+ expect ( afterErrorCalled ) . toBe ( true ) ;
219+ } ) ;
220+
221+ it ( 'should support different job types' , async ( ) => {
222+ const processedData : any [ ] = [ ] ;
223+ const processedNotifications : any [ ] = [ ] ;
224+
225+ queue . onJob ( 'process-data' , async ( payload ) => {
226+ processedData . push ( payload ) ;
227+ } ) ;
228+
229+ queue . onJob ( 'send-notification' , async ( payload ) => {
230+ processedNotifications . push ( payload ) ;
231+ } ) ;
232+
233+ await queue . addJob ( 'process-data' , {
234+ payload : { id : 6 , data : 'data job' }
235+ } ) ;
236+
237+ await queue . addJob ( 'send-notification' , {
238+ payload : { email : 'test@example.com' , message : 'Hello' }
239+ } ) ;
240+
241+ // Process both jobs
242+ await queue . run ( false ) ;
243+ await queue . run ( false ) ;
244+
245+ expect ( processedData ) . toHaveLength ( 1 ) ;
246+ expect ( processedData [ 0 ] ) . toEqual ( { id : 6 , data : 'data job' } ) ;
247+
248+ expect ( processedNotifications ) . toHaveLength ( 1 ) ;
249+ expect ( processedNotifications [ 0 ] ) . toEqual ( {
250+ email : 'test@example.com' ,
251+ message : 'Hello'
252+ } ) ;
253+ } ) ;
254+
255+ it ( 'should handle SQS status limitation gracefully' , async ( ) => {
256+ const jobId = await queue . addJob ( 'process-data' , {
257+ payload : { id : 7 , data : 'status test' }
258+ } ) ;
259+
260+ // SQS doesn't support job status tracking, so this should throw
261+ await expect ( queue . status ( jobId ) ) . rejects . toThrow ( 'SQS does not support status' ) ;
262+ } ) ;
263+ } ) ;
264+ } ) ;
0 commit comments