@constructive-io/knative-job-service 0.6.15 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,662 @@
1
+ import { dirname, join } from 'path';
2
+ import type { Server as HttpServer } from 'http';
3
+ import supertest from 'supertest';
4
+ import { Server as GraphQLServer } from '@constructive-io/graphql-server';
5
+ import type { ConstructiveOptions } from '@constructive-io/graphql-types';
6
+ import { createJobApp } from '@constructive-io/knative-job-fn';
7
+
8
+ import { PgpmInit, PgpmMigrate } from '@pgpmjs/core';
9
+ import { getConnections, seed, type PgTestClient } from 'pgsql-test';
10
+
11
+ import type { KnativeJobsSvc as KnativeJobsSvcType } from '../src';
12
+ import type { KnativeJobsSvcOptions, FunctionServiceConfig } from '../src/types';
13
+
14
+ jest.setTimeout(120000);
15
+
16
+ const delay = (ms: number) =>
17
+ new Promise<void>((resolve) => setTimeout(resolve, ms));
18
+
19
+ type GraphqlClient = {
20
+ http: ReturnType<typeof supertest>;
21
+ path: string;
22
+ host?: string;
23
+ };
24
+
25
+ const buildGraphqlClient = (
26
+ rawUrl: string,
27
+ host?: string
28
+ ): GraphqlClient => {
29
+ const parsed = new URL(rawUrl);
30
+ const origin = `${parsed.protocol}//${parsed.host}`;
31
+ const path =
32
+ parsed.pathname === '/' ? '/graphql' : `${parsed.pathname}${parsed.search}`;
33
+
34
+ return {
35
+ http: supertest(origin),
36
+ path,
37
+ host
38
+ };
39
+ };
40
+
41
+ const getGraphqlClient = (): GraphqlClient => {
42
+ const rawUrl =
43
+ process.env.TEST_GRAPHQL_URL ||
44
+ process.env.GRAPHQL_URL ||
45
+ 'http://localhost:3000/graphql';
46
+ const host = process.env.TEST_GRAPHQL_HOST || process.env.GRAPHQL_HOST;
47
+
48
+ return buildGraphqlClient(rawUrl, host);
49
+ };
50
+
51
+ const sendGraphql = async (
52
+ client: GraphqlClient,
53
+ query: string,
54
+ variables?: Record<string, unknown>
55
+ ) => {
56
+ let req = client.http
57
+ .post(client.path)
58
+ .set('Content-Type', 'application/json');
59
+ if (client.host) {
60
+ req = req.set('Host', client.host);
61
+ }
62
+ return req.send({ query, variables });
63
+ };
64
+
65
+ const addJobMutation = `
66
+ mutation AddJob($input: AddJobInput!) {
67
+ addJob(input: $input) {
68
+ job {
69
+ id
70
+ }
71
+ }
72
+ }
73
+ `;
74
+
75
+ const jobByIdQuery = `
76
+ query JobById($id: BigInt!) {
77
+ job(id: $id) {
78
+ id
79
+ lastError
80
+ attempts
81
+ maxAttempts
82
+ }
83
+ }
84
+ `;
85
+
86
+ const unwrapGraphqlData = <T>(
87
+ response: supertest.Response,
88
+ label: string
89
+ ): T => {
90
+ if (response.status !== 200) {
91
+ throw new Error(`${label} failed: HTTP ${response.status}`);
92
+ }
93
+ if (response.body?.errors?.length) {
94
+ throw new Error(
95
+ `${label} failed: ${response.body.errors
96
+ .map((err: { message: string }) => err.message)
97
+ .join('; ')}`
98
+ );
99
+ }
100
+ if (!response.body?.data) {
101
+ throw new Error(`${label} returned no data`);
102
+ }
103
+ return response.body.data as T;
104
+ };
105
+
106
+ type JobDetails = {
107
+ lastError?: string | null;
108
+ attempts?: number | null;
109
+ maxAttempts?: number | null;
110
+ };
111
+
112
+ const getJobById = async (
113
+ client: GraphqlClient,
114
+ jobId: string | number
115
+ ) => {
116
+ const response = await sendGraphql(client, jobByIdQuery, {
117
+ id: String(jobId)
118
+ });
119
+ const data = unwrapGraphqlData<{ job: JobDetails | null }>(
120
+ response,
121
+ 'Job query'
122
+ );
123
+ return data.job;
124
+ };
125
+
126
+ const waitForJobCompletion = async (
127
+ client: GraphqlClient,
128
+ jobId: string | number
129
+ ) => {
130
+ const timeoutMs = 30000;
131
+ const started = Date.now();
132
+
133
+ while (Date.now() - started < timeoutMs) {
134
+ const job = await getJobById(client, jobId);
135
+
136
+ if (!job) return;
137
+
138
+ if (job.lastError) {
139
+ const attempts = job.attempts ?? 0;
140
+ throw new Error(`Job ${jobId} failed after ${attempts} attempt(s): ${job.lastError}`);
141
+ }
142
+
143
+ await delay(250);
144
+ }
145
+
146
+ throw new Error(`Job ${jobId} did not complete within ${timeoutMs}ms`);
147
+ };
148
+
149
+ const waitForJobFailure = async (
150
+ client: GraphqlClient,
151
+ jobId: string | number,
152
+ {
153
+ minAttempts = 1,
154
+ timeoutMs = 30000
155
+ }: { minAttempts?: number; timeoutMs?: number } = {}
156
+ ) => {
157
+ const started = Date.now();
158
+ let lastJob: JobDetails | null = null;
159
+
160
+ while (Date.now() - started < timeoutMs) {
161
+ const job = await getJobById(client, jobId);
162
+
163
+ if (!job) {
164
+ throw new Error(`Job ${jobId} disappeared before failure was observed`);
165
+ }
166
+
167
+ lastJob = job;
168
+ const attempts = job.attempts ?? 0;
169
+
170
+ if (job.lastError && attempts >= minAttempts) {
171
+ return job;
172
+ }
173
+
174
+ await delay(250);
175
+ }
176
+
177
+ throw new Error(
178
+ `Job ${jobId} did not fail after ${minAttempts} attempt(s) within ${timeoutMs}ms (attempts=${
179
+ lastJob?.attempts ?? 'unknown'
180
+ }, maxAttempts=${lastJob?.maxAttempts ?? 'unknown'}, lastError=${
181
+ lastJob?.lastError ?? 'null'
182
+ })`
183
+ );
184
+ };
185
+
186
+ const closeHttpServer = async (server?: HttpServer | null): Promise<void> => {
187
+ if (!server || !server.listening) return;
188
+ await new Promise<void>((resolveClose, rejectClose) => {
189
+ server.close((err) => {
190
+ if (err) {
191
+ rejectClose(err);
192
+ return;
193
+ }
194
+ resolveClose();
195
+ });
196
+ });
197
+ };
198
+
199
+ type MailgunFailurePayload = {
200
+ to?: string;
201
+ subject?: string;
202
+ html?: string;
203
+ text?: string;
204
+ from?: string;
205
+ replyTo?: string;
206
+ };
207
+
208
+ const createMailgunFailureApp = () => {
209
+ const { send: sendPostmaster } = require('@launchql/postmaster');
210
+ const app = createJobApp();
211
+
212
+ app.post('/', async (req: any, res: any, next: any) => {
213
+ try {
214
+ const payload = (req.body || {}) as MailgunFailurePayload;
215
+ const to = payload.to ?? 'user@example.com';
216
+ const subject = payload.subject ?? 'Mailgun failure test';
217
+ const html = payload.html ?? '<p>mailgun failure</p>';
218
+ const from = payload.from ?? process.env.MAILGUN_FROM;
219
+ const replyTo = payload.replyTo ?? process.env.MAILGUN_REPLY;
220
+
221
+ await sendPostmaster({
222
+ to,
223
+ subject,
224
+ ...(html && { html }),
225
+ ...(payload.text && { text: payload.text }),
226
+ ...(from && { from }),
227
+ ...(replyTo && { replyTo })
228
+ });
229
+
230
+ res.status(200).json({ complete: true });
231
+ } catch (err) {
232
+ next(err);
233
+ }
234
+ });
235
+
236
+ return app;
237
+ };
238
+
239
+ const seededDatabaseId = '0b22e268-16d6-582b-950a-24e108688849';
240
+ const metaDbExtensions = ['citext', 'uuid-ossp', 'unaccent', 'pgcrypto', 'hstore'];
241
+ // Ports are fixed by test design, if these're occupied, then the test just feel free to fail.
242
+ const GRAPHQL_PORT = 3000;
243
+ const CALLBACK_PORT = 12345;
244
+ const SIMPLE_EMAIL_PORT = 8081;
245
+ const SEND_EMAIL_LINK_PORT = 8082;
246
+ const MAILGUN_FAILURE_PORT = 8083;
247
+
248
+ const getPgpmModulePath = (pkgName: string): string =>
249
+ dirname(require.resolve(`${pkgName}/pgpm.plan`));
250
+
251
+ const metaSeedModules = [
252
+ getPgpmModulePath('@pgpm/verify'),
253
+ getPgpmModulePath('@pgpm/types'),
254
+ getPgpmModulePath('@pgpm/inflection'),
255
+ getPgpmModulePath('@pgpm/database-jobs'),
256
+ getPgpmModulePath('@pgpm/metaschema-schema'),
257
+ getPgpmModulePath('@pgpm/services'),
258
+ getPgpmModulePath('@pgpm/metaschema-modules')
259
+ ];
260
+
261
+ const sql = (f: string) => join(__dirname, '..', '__fixtures__', f);
262
+
263
+ type SeededConnections = {
264
+ db: PgTestClient;
265
+ pg: PgTestClient;
266
+ teardown: () => Promise<void>;
267
+ };
268
+
269
+ type PgConfigLike = PgTestClient['config'];
270
+
271
+ const runMetaMigrations = async (config: PgConfigLike) => {
272
+ const migrator = new PgpmMigrate(config);
273
+ for (const modulePath of metaSeedModules) {
274
+ const result = await migrator.deploy({ modulePath, usePlan: true });
275
+ if (result.failed) {
276
+ throw new Error(`Failed to deploy ${modulePath}: ${result.failed}`);
277
+ }
278
+ }
279
+ };
280
+
281
+ const bootstrapAdminUsers = seed.fn(async ({ admin, config, connect }) => {
282
+ const roles = connect?.roles;
283
+ const connections = connect?.connections;
284
+
285
+ if (!roles || !connections) {
286
+ throw new Error('Missing pgpm role or connection defaults for admin users.');
287
+ }
288
+
289
+ const init = new PgpmInit(config);
290
+ try {
291
+ await init.bootstrapRoles(roles);
292
+ await init.bootstrapTestRoles(roles, connections);
293
+ } finally {
294
+ await init.close();
295
+ }
296
+
297
+ const appUser = connections.app?.user;
298
+ if (appUser) {
299
+ await admin.grantRole(roles.administrator, appUser, config.database);
300
+ }
301
+ });
302
+
303
+ const deployMetaModules = seed.fn(async ({ config }) => {
304
+ await runMetaMigrations(config);
305
+ });
306
+
307
+ const createTestDb = async (): Promise<SeededConnections> => {
308
+ const { db, pg, teardown } = await getConnections(
309
+ { db: { extensions: metaDbExtensions } },
310
+ [
311
+ bootstrapAdminUsers,
312
+ deployMetaModules,
313
+ seed.sqlfile([sql('jobs.seed.sql')])
314
+ ]
315
+ );
316
+
317
+ return { db, pg, teardown };
318
+ };
319
+
320
+
321
+
322
+ describe('jobs e2e', () => {
323
+ let teardown: () => Promise<void>;
324
+ let graphqlClient: GraphqlClient;
325
+ let graphqlServer: GraphQLServer | null = null;
326
+ let databaseId = '';
327
+ let pg: PgTestClient | undefined;
328
+ let knativeJobsSvc: KnativeJobsSvcType | null = null;
329
+ let mailgunServer: HttpServer | null = null;
330
+ const envSnapshot: Record<string, string | undefined> = {
331
+ NODE_ENV: process.env.NODE_ENV,
332
+ TEST_DB: process.env.TEST_DB,
333
+ PGHOST: process.env.PGHOST,
334
+ PGPORT: process.env.PGPORT,
335
+ PGUSER: process.env.PGUSER,
336
+ PGPASSWORD: process.env.PGPASSWORD,
337
+ PGDATABASE: process.env.PGDATABASE,
338
+ TEST_DATABASE_ID: process.env.TEST_DATABASE_ID,
339
+ DEFAULT_DATABASE_ID: process.env.DEFAULT_DATABASE_ID,
340
+ TEST_GRAPHQL_URL: process.env.TEST_GRAPHQL_URL,
341
+ TEST_GRAPHQL_HOST: process.env.TEST_GRAPHQL_HOST,
342
+ GRAPHQL_URL: process.env.GRAPHQL_URL,
343
+ META_GRAPHQL_URL: process.env.META_GRAPHQL_URL,
344
+ SIMPLE_EMAIL_DRY_RUN: process.env.SIMPLE_EMAIL_DRY_RUN,
345
+ SEND_EMAIL_LINK_DRY_RUN: process.env.SEND_EMAIL_LINK_DRY_RUN,
346
+ LOCAL_APP_PORT: process.env.LOCAL_APP_PORT,
347
+ MAILGUN_DOMAIN: process.env.MAILGUN_DOMAIN,
348
+ MAILGUN_FROM: process.env.MAILGUN_FROM,
349
+ MAILGUN_REPLY: process.env.MAILGUN_REPLY,
350
+ MAILGUN_API_KEY: process.env.MAILGUN_API_KEY,
351
+ MAILGUN_KEY: process.env.MAILGUN_KEY,
352
+ JOBS_SUPPORT_ANY: process.env.JOBS_SUPPORT_ANY,
353
+ JOBS_SUPPORTED: process.env.JOBS_SUPPORTED,
354
+ INTERNAL_GATEWAY_DEVELOPMENT_MAP:
355
+ process.env.INTERNAL_GATEWAY_DEVELOPMENT_MAP,
356
+ INTERNAL_JOBS_CALLBACK_PORT: process.env.INTERNAL_JOBS_CALLBACK_PORT,
357
+ JOBS_CALLBACK_BASE_URL: process.env.JOBS_CALLBACK_BASE_URL,
358
+ FEATURES_POSTGIS: process.env.FEATURES_POSTGIS
359
+ };
360
+
361
+ beforeAll(async () => {
362
+ delete process.env.TEST_DB;
363
+ delete process.env.PGDATABASE;
364
+
365
+ ({ teardown, pg } = await createTestDb());
366
+ if (!pg) {
367
+ throw new Error('Test database connection is missing');
368
+ }
369
+ databaseId = seededDatabaseId;
370
+ if (pg?.oneOrNone) {
371
+ const row = await pg.oneOrNone<{ id: string }>(
372
+ 'SELECT id FROM metaschema_public.database WHERE id = $1',
373
+ [databaseId]
374
+ );
375
+ if (!row?.id) {
376
+ throw new Error(`Seeded database id ${databaseId} was not found`);
377
+ }
378
+ }
379
+
380
+ if (!pg?.config.database) {
381
+ throw new Error('Test database config is missing a database name');
382
+ }
383
+
384
+ const graphqlUrl = `http://127.0.0.1:${GRAPHQL_PORT}/graphql`;
385
+ const callbackUrl = `http://127.0.0.1:${CALLBACK_PORT}/callback`;
386
+
387
+ process.env.NODE_ENV = 'test';
388
+ process.env.PGDATABASE = pg.config.database;
389
+ process.env.TEST_DATABASE_ID = databaseId;
390
+ process.env.DEFAULT_DATABASE_ID = databaseId;
391
+ process.env.TEST_GRAPHQL_URL = graphqlUrl;
392
+ process.env.GRAPHQL_URL = graphqlUrl;
393
+ process.env.META_GRAPHQL_URL = graphqlUrl;
394
+ process.env.SIMPLE_EMAIL_DRY_RUN = 'true';
395
+ process.env.SEND_EMAIL_LINK_DRY_RUN = 'true';
396
+ process.env.LOCAL_APP_PORT = String(GRAPHQL_PORT);
397
+ process.env.MAILGUN_DOMAIN = 'mg.constructive.io';
398
+ process.env.MAILGUN_FROM = 'no-reply@mg.constructive.io';
399
+ process.env.MAILGUN_REPLY = 'info@mg.constructive.io';
400
+ process.env.MAILGUN_API_KEY = 'change-me-mailgun-api-key';
401
+ process.env.MAILGUN_KEY = 'change-me-mailgun-api-key';
402
+ process.env.JOBS_SUPPORT_ANY = 'false';
403
+ process.env.JOBS_SUPPORTED = 'simple-email,send-email-link,mailgun-failure';
404
+ process.env.INTERNAL_GATEWAY_DEVELOPMENT_MAP = JSON.stringify({
405
+ 'simple-email': `http://127.0.0.1:${SIMPLE_EMAIL_PORT}`,
406
+ 'send-email-link': `http://127.0.0.1:${SEND_EMAIL_LINK_PORT}`,
407
+ 'mailgun-failure': `http://127.0.0.1:${MAILGUN_FAILURE_PORT}`
408
+ });
409
+ process.env.INTERNAL_JOBS_CALLBACK_PORT = String(CALLBACK_PORT);
410
+ process.env.JOBS_CALLBACK_BASE_URL = callbackUrl;
411
+ process.env.FEATURES_POSTGIS = 'false';
412
+
413
+ if (pg.config.host) process.env.PGHOST = pg.config.host;
414
+ if (pg.config.port) process.env.PGPORT = String(pg.config.port);
415
+ if (pg.config.user) process.env.PGUSER = pg.config.user;
416
+ if (pg.config.password) process.env.PGPASSWORD = pg.config.password;
417
+
418
+ const services: FunctionServiceConfig[] = [
419
+ { name: 'simple-email', port: SIMPLE_EMAIL_PORT },
420
+ { name: 'send-email-link', port: SEND_EMAIL_LINK_PORT }
421
+ ];
422
+
423
+ const graphqlOptions: ConstructiveOptions = {
424
+ pg: {
425
+ host: pg.config.host,
426
+ port: pg.config.port,
427
+ user: pg.config.user,
428
+ password: pg.config.password,
429
+ database: pg.config.database
430
+ },
431
+ server: {
432
+ host: '127.0.0.1',
433
+ port: GRAPHQL_PORT
434
+ },
435
+ api: {
436
+ enableServicesApi: false,
437
+ exposedSchemas: [
438
+ 'app_jobs',
439
+ 'app_public',
440
+ 'metaschema_modules_public',
441
+ 'metaschema_public',
442
+ 'services_public'
443
+ ],
444
+ anonRole: 'administrator',
445
+ roleName: 'administrator',
446
+ defaultDatabaseId: databaseId
447
+ },
448
+ features: {
449
+ postgis: false
450
+ }
451
+ };
452
+
453
+ graphqlServer = new GraphQLServer(graphqlOptions);
454
+ graphqlServer.addEventListener();
455
+ const graphqlHttpServer = graphqlServer.listen();
456
+ if (!graphqlHttpServer.listening) {
457
+ await new Promise<void>((resolveListen) => {
458
+ graphqlHttpServer.once('listening', () => resolveListen());
459
+ });
460
+ }
461
+
462
+ const knativeJobsSvcOptions: KnativeJobsSvcOptions = {
463
+ functions: {
464
+ enabled: true,
465
+ services
466
+ },
467
+ jobs: { enabled: true }
468
+ };
469
+
470
+ const { KnativeJobsSvc } = await import('../src');
471
+ knativeJobsSvc = new KnativeJobsSvc(knativeJobsSvcOptions);
472
+ await knativeJobsSvc.start();
473
+
474
+ graphqlClient = getGraphqlClient();
475
+
476
+ const mailgunApp = createMailgunFailureApp();
477
+ mailgunServer = await new Promise<HttpServer>((resolve, reject) => {
478
+ const server = mailgunApp.listen(MAILGUN_FAILURE_PORT, () => resolve(server));
479
+ server.on('error', reject);
480
+ });
481
+ });
482
+
483
+ afterAll(async () => {
484
+ if (knativeJobsSvc) {
485
+ await knativeJobsSvc.stop();
486
+ }
487
+ if (graphqlServer) {
488
+ await graphqlServer.close({ closeCaches: true });
489
+ graphqlServer = null;
490
+ }
491
+ await closeHttpServer(mailgunServer);
492
+ mailgunServer = null;
493
+ if (teardown) {
494
+ await teardown();
495
+ }
496
+ for (const [key, value] of Object.entries(envSnapshot)) {
497
+ if (value === undefined) {
498
+ delete process.env[key];
499
+ } else {
500
+ process.env[key] = value;
501
+ }
502
+ }
503
+ });
504
+
505
+ it('creates and processes a simple-email job', async () => {
506
+ const jobInput = {
507
+ dbId: databaseId,
508
+ identifier: 'simple-email',
509
+ payload: {
510
+ to: 'user@example.com',
511
+ subject: 'Jobs e2e',
512
+ html: '<p>jobs test</p>'
513
+ }
514
+ };
515
+
516
+ const response = await sendGraphql(graphqlClient, addJobMutation, {
517
+ input: jobInput
518
+ });
519
+
520
+ expect(response.status).toBe(200);
521
+ expect(response.body?.errors).toBeUndefined();
522
+
523
+ const jobId = response.body?.data?.addJob?.job?.id;
524
+
525
+ expect(jobId).toBeTruthy();
526
+
527
+ await waitForJobCompletion(graphqlClient, jobId);
528
+ });
529
+
530
+ it('creates and processes a send-email-link job', async () => {
531
+ const jobInput = {
532
+ dbId: databaseId,
533
+ identifier: 'send-email-link',
534
+ payload: {
535
+ email_type: 'invite_email',
536
+ email: 'user@example.com',
537
+ invite_token: 'invite123',
538
+ sender_id: '00000000-0000-0000-0000-000000000000'
539
+ }
540
+ };
541
+
542
+ const response = await sendGraphql(graphqlClient, addJobMutation, {
543
+ input: jobInput
544
+ });
545
+
546
+ expect(response.status).toBe(200);
547
+ expect(response.body?.errors).toBeUndefined();
548
+
549
+ const jobId = response.body?.data?.addJob?.job?.id;
550
+
551
+ expect(jobId).toBeTruthy();
552
+
553
+ await waitForJobCompletion(graphqlClient, jobId);
554
+ });
555
+
556
+ it('records failed jobs when a function throws', async () => {
557
+ const jobInput = {
558
+ dbId: databaseId,
559
+ identifier: 'simple-email',
560
+ maxAttempts: 1,
561
+ payload: {
562
+ to: 'user@example.com',
563
+ html: '<p>missing subject</p>'
564
+ }
565
+ };
566
+
567
+ const response = await sendGraphql(graphqlClient, addJobMutation, {
568
+ input: jobInput
569
+ });
570
+
571
+ expect(response.status).toBe(200);
572
+ expect(response.body?.errors).toBeUndefined();
573
+
574
+ const jobId = response.body?.data?.addJob?.job?.id;
575
+
576
+ expect(jobId).toBeTruthy();
577
+
578
+ const job = await waitForJobFailure(graphqlClient, jobId, {
579
+ minAttempts: 1,
580
+ timeoutMs: 30000
581
+ });
582
+
583
+ expect(job.attempts).toBe(1);
584
+ expect(job.maxAttempts).toBe(1);
585
+ expect(job.lastError).toContain('Missing required field');
586
+ });
587
+
588
+ it('retries failed jobs until max attempts is reached', async () => {
589
+ const jobInput = {
590
+ dbId: databaseId,
591
+ identifier: 'simple-email',
592
+ maxAttempts: 2,
593
+ payload: {
594
+ to: 'user@example.com',
595
+ html: '<p>missing subject</p>'
596
+ }
597
+ };
598
+
599
+ const response = await sendGraphql(graphqlClient, addJobMutation, {
600
+ input: jobInput
601
+ });
602
+
603
+ expect(response.status).toBe(200);
604
+ expect(response.body?.errors).toBeUndefined();
605
+
606
+ const jobId = response.body?.data?.addJob?.job?.id;
607
+
608
+ expect(jobId).toBeTruthy();
609
+
610
+ const firstFailure = await waitForJobFailure(graphqlClient, jobId, {
611
+ minAttempts: 1,
612
+ timeoutMs: 30000
613
+ });
614
+
615
+ expect(firstFailure.attempts).toBe(1);
616
+
617
+ const retried = await waitForJobFailure(graphqlClient, jobId, {
618
+ minAttempts: 2,
619
+ timeoutMs: 60000
620
+ });
621
+
622
+ expect(retried.attempts).toBe(2);
623
+ expect(retried.maxAttempts).toBe(2);
624
+ expect(retried.lastError).toContain('Missing required field');
625
+ });
626
+
627
+ it('records mailgun failures when dry run is disabled', async () => {
628
+ process.env.MAILGUN_API_KEY = 'invalid-mailgun-api-key';
629
+ process.env.MAILGUN_KEY = 'invalid-mailgun-api-key';
630
+
631
+ const jobInput = {
632
+ dbId: databaseId,
633
+ identifier: 'mailgun-failure',
634
+ maxAttempts: 1,
635
+ payload: {
636
+ to: 'user@example.com',
637
+ subject: 'Mailgun failure test',
638
+ html: '<p>mailgun should reject this</p>'
639
+ }
640
+ };
641
+
642
+ const response = await sendGraphql(graphqlClient, addJobMutation, {
643
+ input: jobInput
644
+ });
645
+
646
+ expect(response.status).toBe(200);
647
+ expect(response.body?.errors).toBeUndefined();
648
+
649
+ const jobId = response.body?.data?.addJob?.job?.id;
650
+
651
+ expect(jobId).toBeTruthy();
652
+
653
+ const job = await waitForJobFailure(graphqlClient, jobId, {
654
+ minAttempts: 1,
655
+ timeoutMs: 60000
656
+ });
657
+
658
+ expect(job.attempts).toBe(1);
659
+ expect(job.maxAttempts).toBe(1);
660
+ expect(job.lastError).toBeTruthy();
661
+ });
662
+ });
package/dist/index.d.ts CHANGED
@@ -0,0 +1,24 @@
1
+ import { KnativeJobsSvcOptions, KnativeJobsSvcResult } from './types';
2
+ export declare class KnativeJobsSvc {
3
+ private options;
4
+ private started;
5
+ private result;
6
+ private functionServers;
7
+ private jobsHttpServer?;
8
+ private worker?;
9
+ private scheduler?;
10
+ private jobsPoolManager?;
11
+ constructor(options?: KnativeJobsSvcOptions);
12
+ start(): Promise<KnativeJobsSvcResult>;
13
+ stop(): Promise<void>;
14
+ private startJobs;
15
+ }
16
+ export declare const buildKnativeJobsSvcOptionsFromEnv: () => KnativeJobsSvcOptions;
17
+ export declare const startKnativeJobsSvcFromEnv: () => Promise<KnativeJobsSvcResult>;
18
+ export declare const startJobsServices: () => {
19
+ pgPool: import("pg").Pool;
20
+ httpServer: any;
21
+ };
22
+ export declare const waitForJobsPrereqs: () => Promise<void>;
23
+ export declare const bootJobs: () => Promise<void>;
24
+ export * from './types';