@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.
- package/CHANGELOG.md +8 -0
- package/__fixtures__/jobs.seed.sql +67 -0
- package/__tests__/jobs.e2e.test.ts +662 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +370 -1
- package/dist/run.d.ts +1 -6
- package/dist/run.js +7 -79
- package/dist/types.d.ts +24 -0
- package/dist/types.js +2 -0
- package/package.json +25 -7
- package/src/index.ts +445 -1
- package/src/run.ts +2 -87
- package/src/types.ts +30 -0
|
@@ -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';
|