Skip to content

Commit 6230fbc

Browse files
committed
E2E Test cloud fns
1 parent 1baffe2 commit 6230fbc

4 files changed

Lines changed: 494 additions & 0 deletions

File tree

.github/workflows/jobs-e2e.yaml

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
name: Jobs E2E
2+
on:
3+
push:
4+
branches:
5+
- main
6+
- v1
7+
pull_request:
8+
branches:
9+
- main
10+
- v1
11+
workflow_dispatch:
12+
13+
concurrency:
14+
group: ${{ github.workflow }}-${{ github.ref }}-jobs-e2e
15+
cancel-in-progress: true
16+
17+
jobs:
18+
jobs-e2e:
19+
runs-on: ubuntu-latest
20+
21+
env:
22+
PGHOST: localhost
23+
PGPORT: 5432
24+
PGUSER: postgres
25+
PGPASSWORD: password
26+
PGDATABASE: launchql
27+
TEST_DB: launchql
28+
TEST_GRAPHQL_URL: http://127.0.0.1:3000/graphql
29+
TEST_GRAPHQL_HOST: admin.localhost
30+
31+
services:
32+
pg_db:
33+
image: pyramation/pgvector:13.3-alpine
34+
env:
35+
POSTGRES_USER: postgres
36+
POSTGRES_PASSWORD: password
37+
options: >-
38+
--health-cmd pg_isready
39+
--health-interval 10s
40+
--health-timeout 5s
41+
--health-retries 5
42+
ports:
43+
- 5432:5432
44+
45+
steps:
46+
- name: Configure Git (for tests)
47+
run: |
48+
git config --global user.name "CI Test User"
49+
git config --global user.email "ci@example.com"
50+
51+
- name: checkout
52+
uses: actions/checkout@v4
53+
54+
- name: checkout constructive-db
55+
uses: actions/checkout@v4
56+
with:
57+
repository: constructive-io/constructive-db
58+
path: constructive-db
59+
token: ${{ secrets.GITHUB_TOKEN }}
60+
61+
- name: Setup Node.js
62+
uses: actions/setup-node@v4
63+
with:
64+
node-version: '20'
65+
66+
- name: Setup pnpm
67+
uses: pnpm/action-setup@v2
68+
with:
69+
version: 10
70+
71+
- name: Install dependencies
72+
run: pnpm install
73+
74+
- name: Build packages
75+
run: pnpm run build
76+
77+
- name: Setup jobs database
78+
run: |
79+
PGDATABASE=postgres createdb launchql || true
80+
pnpm --filter pgpm exec pgpm admin-users bootstrap --yes --cwd constructive-db
81+
pnpm --filter pgpm exec pgpm admin-users add --test --yes --cwd constructive-db
82+
pnpm --filter pgpm exec pgpm deploy --yes --database "$PGDATABASE" --package app-svc-local --cwd constructive-db
83+
pnpm --filter pgpm exec pgpm deploy --yes --database "$PGDATABASE" --package metaschema --cwd constructive-db
84+
pnpm --filter pgpm exec pgpm deploy --yes --database "$PGDATABASE" --package pgpm-database-jobs --cwd constructive-db
85+
86+
- name: Resolve database id
87+
run: |
88+
DBID=$(psql -h "$PGHOST" -U "$PGUSER" -d "$PGDATABASE" -Atc "SELECT id FROM metaschema_public.database ORDER BY created_at LIMIT 1;")
89+
if [ -z "$DBID" ]; then
90+
echo "No database id found in metaschema_public.database" >&2
91+
exit 1
92+
fi
93+
echo "TEST_DATABASE_ID=$DBID" >> "$GITHUB_ENV"
94+
echo "DEFAULT_DATABASE_ID=$DBID" >> "$GITHUB_ENV"
95+
96+
- name: Start combined server
97+
env:
98+
NODE_ENV: test
99+
PORT: "3000"
100+
SERVER_HOST: "127.0.0.1"
101+
API_ENABLE_META: "false"
102+
API_EXPOSED_SCHEMAS: "app_jobs,lql_private,lql_public,lql_roles_public,metaschema_modules_public,metaschema_public,services_public"
103+
API_ANON_ROLE: "administrator"
104+
API_ROLE_NAME: "administrator"
105+
API_DEFAULT_DATABASE_ID: ${{ env.DEFAULT_DATABASE_ID }}
106+
CONSTRUCTIVE_GRAPHQL_ENABLED: "true"
107+
CONSTRUCTIVE_JOBS_ENABLED: "true"
108+
CONSTRUCTIVE_FUNCTIONS: "simple-email,send-email-link"
109+
CONSTRUCTIVE_FUNCTION_PORTS: "simple-email:8081,send-email-link:8082"
110+
SIMPLE_EMAIL_DRY_RUN: "true"
111+
SEND_EMAIL_LINK_DRY_RUN: "true"
112+
LOCAL_APP_PORT: "3000"
113+
GRAPHQL_URL: "http://127.0.0.1:3000/graphql"
114+
META_GRAPHQL_URL: "http://127.0.0.1:3000/graphql"
115+
GRAPHQL_HOST_HEADER: "admin.localhost"
116+
META_GRAPHQL_HOST_HEADER: "admin.localhost"
117+
MAILGUN_DOMAIN: "mg.constructive.io"
118+
MAILGUN_FROM: "no-reply@mg.constructive.io"
119+
MAILGUN_REPLY: "info@mg.constructive.io"
120+
MAILGUN_API_KEY: "change-me-mailgun-api-key"
121+
MAILGUN_KEY: "change-me-mailgun-api-key"
122+
JOBS_SUPPORT_ANY: "false"
123+
JOBS_SUPPORTED: "simple-email,send-email-link"
124+
INTERNAL_GATEWAY_DEVELOPMENT_MAP: '{"simple-email":"http://127.0.0.1:8081","send-email-link":"http://127.0.0.1:8082"}'
125+
INTERNAL_JOBS_CALLBACK_PORT: "8080"
126+
JOBS_CALLBACK_BASE_URL: "http://127.0.0.1:8080/callback"
127+
FEATURES_POSTGIS: "false"
128+
run: |
129+
nohup node packages/server/dist/run.js > /tmp/combined-server.log 2>&1 &
130+
echo $! > /tmp/combined-server.pid
131+
132+
- name: Test server jobs e2e
133+
run: pnpm --filter @constructive-io/server test
134+
135+
- name: Stop combined server
136+
if: always()
137+
run: |
138+
if [ -f /tmp/combined-server.pid ]; then
139+
kill "$(cat /tmp/combined-server.pid)" || true
140+
fi
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import supertest from 'supertest';
2+
3+
import { getConnections } from '@constructive-io/graphql-test';
4+
5+
jest.setTimeout(120000);
6+
7+
const delay = (ms: number) =>
8+
new Promise<void>((resolve) => setTimeout(resolve, ms));
9+
10+
type GraphqlClient = {
11+
http: ReturnType<typeof supertest>;
12+
path: string;
13+
host?: string;
14+
};
15+
16+
const getGraphqlClient = (): GraphqlClient => {
17+
const rawUrl =
18+
process.env.TEST_GRAPHQL_URL ||
19+
process.env.GRAPHQL_URL ||
20+
'http://localhost:3000/graphql';
21+
const parsed = new URL(rawUrl);
22+
const origin = `${parsed.protocol}//${parsed.host}`;
23+
const path =
24+
parsed.pathname === '/' ? '/graphql' : `${parsed.pathname}${parsed.search}`;
25+
const host = process.env.TEST_GRAPHQL_HOST || process.env.GRAPHQL_HOST;
26+
27+
return {
28+
http: supertest(origin),
29+
path,
30+
host
31+
};
32+
};
33+
34+
const sendGraphql = async (
35+
client: GraphqlClient,
36+
query: string,
37+
variables?: Record<string, unknown>
38+
) => {
39+
let req = client.http
40+
.post(client.path)
41+
.set('Content-Type', 'application/json');
42+
if (client.host) {
43+
req = req.set('Host', client.host);
44+
}
45+
return req.send({ query, variables });
46+
};
47+
48+
const addJobMutation = `
49+
mutation AddJob($input: AddJobInput!) {
50+
addJob(input: $input) {
51+
job {
52+
id
53+
}
54+
}
55+
}
56+
`;
57+
58+
const jobByIdQuery = `
59+
query JobById($id: BigInt!) {
60+
job(id: $id) {
61+
id
62+
lastError
63+
attempts
64+
}
65+
}
66+
`;
67+
68+
const unwrapGraphqlData = <T>(
69+
response: supertest.Response,
70+
label: string
71+
): T => {
72+
if (response.status !== 200) {
73+
throw new Error(`${label} failed: HTTP ${response.status}`);
74+
}
75+
if (response.body?.errors?.length) {
76+
throw new Error(
77+
`${label} failed: ${response.body.errors
78+
.map((err: { message: string }) => err.message)
79+
.join('; ')}`
80+
);
81+
}
82+
if (!response.body?.data) {
83+
throw new Error(`${label} returned no data`);
84+
}
85+
return response.body.data as T;
86+
};
87+
88+
const getJobById = async (
89+
client: GraphqlClient,
90+
jobId: string | number
91+
) => {
92+
const response = await sendGraphql(client, jobByIdQuery, {
93+
id: String(jobId)
94+
});
95+
const data = unwrapGraphqlData<{ job: { lastError?: string | null; attempts?: number } | null }>(
96+
response,
97+
'Job query'
98+
);
99+
return data.job;
100+
};
101+
102+
const waitForJobCompletion = async (
103+
client: GraphqlClient,
104+
jobId: string | number
105+
) => {
106+
const timeoutMs = 30000;
107+
const started = Date.now();
108+
109+
while (Date.now() - started < timeoutMs) {
110+
const job = await getJobById(client, jobId);
111+
112+
if (!job) return;
113+
114+
if (job.lastError) {
115+
const attempts = job.attempts ?? 0;
116+
throw new Error(`Job ${jobId} failed after ${attempts} attempt(s): ${job.lastError}`);
117+
}
118+
119+
await delay(250);
120+
}
121+
122+
throw new Error(`Job ${jobId} did not complete within ${timeoutMs}ms`);
123+
};
124+
125+
describe('jobs e2e', () => {
126+
let teardown: () => Promise<void>;
127+
let graphqlClient: GraphqlClient;
128+
let databaseId = '';
129+
let pg: { oneOrNone?: <T>(query: string, values?: unknown[]) => Promise<T | null> } | undefined;
130+
131+
beforeAll(async () => {
132+
const targetDb = process.env.TEST_DB || process.env.PGDATABASE;
133+
if (!targetDb) {
134+
throw new Error('TEST_DB or PGDATABASE must point at the jobs database');
135+
}
136+
process.env.TEST_DB = targetDb;
137+
138+
({ teardown, pg } = await getConnections(
139+
{
140+
schemas: ['app_jobs'],
141+
authRole: 'administrator'
142+
}
143+
));
144+
145+
graphqlClient = getGraphqlClient();
146+
databaseId = process.env.TEST_DATABASE_ID ?? '';
147+
if (!databaseId && pg?.oneOrNone) {
148+
const row = await pg.oneOrNone<{ id: string }>(
149+
'SELECT id FROM metaschema_public.database ORDER BY created_at LIMIT 1'
150+
);
151+
databaseId = row?.id ?? '';
152+
}
153+
if (!databaseId) {
154+
throw new Error('TEST_DATABASE_ID is required or metaschema_public.database must contain a row');
155+
}
156+
process.env.TEST_DATABASE_ID = databaseId;
157+
});
158+
159+
afterAll(async () => {
160+
if (teardown) {
161+
await teardown();
162+
}
163+
});
164+
165+
it('creates and processes a simple-email job', async () => {
166+
const jobInput = {
167+
dbId: databaseId,
168+
identifier: 'simple-email',
169+
payload: {
170+
to: 'user@example.com',
171+
subject: 'Jobs e2e',
172+
html: '<p>jobs test</p>'
173+
}
174+
};
175+
176+
const response = await sendGraphql(graphqlClient, addJobMutation, {
177+
input: jobInput
178+
});
179+
180+
expect(response.status).toBe(200);
181+
expect(response.body?.errors).toBeUndefined();
182+
183+
const jobId = response.body?.data?.addJob?.job?.id;
184+
185+
expect(jobId).toBeTruthy();
186+
187+
await waitForJobCompletion(graphqlClient, jobId);
188+
});
189+
190+
it('creates and processes a send-email-link job', async () => {
191+
const jobInput = {
192+
dbId: databaseId,
193+
identifier: 'send-email-link',
194+
payload: {
195+
email_type: 'invite_email',
196+
email: 'user@example.com',
197+
invite_token: 'invite123',
198+
sender_id: '00000000-0000-0000-0000-000000000000'
199+
}
200+
};
201+
202+
const response = await sendGraphql(graphqlClient, addJobMutation, {
203+
input: jobInput
204+
});
205+
206+
expect(response.status).toBe(200);
207+
expect(response.body?.errors).toBeUndefined();
208+
209+
const jobId = response.body?.data?.addJob?.job?.id;
210+
211+
expect(jobId).toBeTruthy();
212+
213+
await waitForJobCompletion(graphqlClient, jobId);
214+
});
215+
});

packages/server/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,12 @@
4949
"@pgpmjs/logger": "workspace:^"
5050
},
5151
"devDependencies": {
52+
"@constructive-io/graphql-test": "workspace:^",
53+
"@types/supertest": "^6.0.3",
5254
"makage": "^0.1.10",
5355
"nodemon": "^3.1.10",
56+
"pgsql-test": "workspace:^",
57+
"supertest": "^7.2.2",
5458
"ts-node": "^10.9.2"
5559
}
5660
}

0 commit comments

Comments
 (0)