Skip to content

Commit c14fff3

Browse files
committed
add tests for unit and other tests packages as well
1 parent 0da135c commit c14fff3

17 files changed

Lines changed: 2463 additions & 13 deletions

File tree

.github/workflows/test.yaml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
workflow_dispatch: {}
9+
10+
concurrency:
11+
group: ${{ github.workflow }}-${{ github.ref }}
12+
cancel-in-progress: true
13+
14+
jobs:
15+
unit:
16+
name: Unit tests
17+
runs-on: ubuntu-latest
18+
steps:
19+
- uses: actions/checkout@v4
20+
- uses: actions/setup-node@v4
21+
with:
22+
node-version: '22'
23+
- run: npm install -g pnpm@10.12.2
24+
- run: pnpm install
25+
- run: pnpm test:unit
26+
27+
integration:
28+
name: Integration tests
29+
runs-on: ubuntu-latest
30+
steps:
31+
- uses: actions/checkout@v4
32+
- uses: actions/setup-node@v4
33+
with:
34+
node-version: '22'
35+
- run: npm install -g pnpm@10.12.2
36+
- run: pnpm install
37+
- run: pnpm build
38+
- run: pnpm test:integration
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { createMockContext } from '../../../tests/helpers/mock-context';
2+
import handler from '../handler';
3+
4+
describe('example handler', () => {
5+
it('returns expected shape with payload echoed', async () => {
6+
const result = await handler({ hello: 'world' }, createMockContext());
7+
expect(result).toMatchObject({ fn: 'example-fn', body: { hello: 'world' } });
8+
});
9+
10+
it('includes message in response', async () => {
11+
const result = await handler({}, createMockContext());
12+
expect(result).toHaveProperty('message');
13+
});
14+
15+
it('throws when params.throw is true', async () => {
16+
await expect(
17+
handler({ throw: true }, createMockContext())
18+
).rejects.toThrow('THROWN_ERROR');
19+
});
20+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* GraphQL integration tests for send-email-link handler.
3+
*
4+
* These tests use @constructive-io/graphql-test to run against
5+
* a real PostGraphile + Postgres instance. They require:
6+
*
7+
* 1. SQL seed files with test sites, domains, themes, modules, users
8+
* 2. The PostGraphile URL to pass as GRAPHQL_URL
9+
* 3. A FunctionContext constructed with real GraphQL clients
10+
*
11+
* Install dependencies before running:
12+
* pnpm add -D @constructive-io/graphql-test pgsql-test
13+
*/
14+
15+
describe('send-email-link handler (GraphQL integration)', () => {
16+
it.todo('invite_email fetches inviter and sends email with correct URL');
17+
it.todo('forgot_password constructs correct reset URL with tokens');
18+
it.todo('email_verification constructs correct verify URL');
19+
it.todo('dry-run mode logs but does not send email');
20+
it.todo('uses site theme primary color in email template');
21+
it.todo('handles localhost domains with correct protocol');
22+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { createMockContext } from '../../../tests/helpers/mock-context';
2+
3+
describe('send-email-link handler (validation)', () => {
4+
let handler: any;
5+
6+
beforeEach(() => {
7+
jest.resetModules();
8+
handler = require('../handler').default;
9+
});
10+
11+
it('returns error when databaseId is missing', async () => {
12+
const ctx = createMockContext({ databaseId: undefined });
13+
const result = await handler(
14+
{ email_type: 'invite_email', email: 'a@b.com' },
15+
ctx
16+
);
17+
expect(result).toEqual({
18+
error: 'Missing X-Database-Id header or DEFAULT_DATABASE_ID'
19+
});
20+
});
21+
22+
it('throws for missing email_type', async () => {
23+
await expect(
24+
handler({ email: 'a@b.com' }, createMockContext())
25+
).rejects.toThrow('Missing required field: email_type');
26+
});
27+
28+
it('throws for missing email', async () => {
29+
await expect(
30+
handler({ email_type: 'invite_email' }, createMockContext())
31+
).rejects.toThrow('Missing required field: email');
32+
});
33+
34+
it('throws for invite_email missing invite_token/sender_id', async () => {
35+
await expect(
36+
handler(
37+
{ email_type: 'invite_email', email: 'a@b.com' },
38+
createMockContext()
39+
)
40+
).rejects.toThrow('Missing required field: invite_token_or_sender_id');
41+
});
42+
43+
it('throws for forgot_password missing user_id/reset_token', async () => {
44+
await expect(
45+
handler(
46+
{ email_type: 'forgot_password', email: 'a@b.com' },
47+
createMockContext()
48+
)
49+
).rejects.toThrow('Missing required field: user_id_or_reset_token');
50+
});
51+
52+
it('throws for email_verification missing email_id/verification_token', async () => {
53+
await expect(
54+
handler(
55+
{ email_type: 'email_verification', email: 'a@b.com' },
56+
createMockContext()
57+
)
58+
).rejects.toThrow(
59+
'Missing required field: email_id_or_verification_token'
60+
);
61+
});
62+
});
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { createMockContext } from '../../../tests/helpers/mock-context';
2+
3+
const loadHandler = () => {
4+
const mod = require('../handler');
5+
return mod.default ?? mod;
6+
};
7+
8+
describe('simple-email handler', () => {
9+
beforeEach(() => {
10+
jest.resetModules();
11+
process.env.SIMPLE_EMAIL_DRY_RUN = 'false';
12+
process.env.EMAIL_SEND_USE_SMTP = 'false';
13+
});
14+
15+
afterEach(() => {
16+
delete process.env.SIMPLE_EMAIL_DRY_RUN;
17+
delete process.env.EMAIL_SEND_USE_SMTP;
18+
delete process.env.MAILGUN_FROM;
19+
delete process.env.SMTP_FROM;
20+
});
21+
22+
describe('validation', () => {
23+
it('throws on missing "to"', async () => {
24+
const handler = loadHandler();
25+
await expect(
26+
handler({ subject: 'test', html: '<p>hi</p>' }, createMockContext())
27+
).rejects.toThrow("Missing required field 'to'");
28+
});
29+
30+
it('throws on missing "subject"', async () => {
31+
const handler = loadHandler();
32+
await expect(
33+
handler({ to: 'a@b.com', html: '<p>hi</p>' }, createMockContext())
34+
).rejects.toThrow("Missing required field 'subject'");
35+
});
36+
37+
it('throws when neither html nor text provided', async () => {
38+
const handler = loadHandler();
39+
await expect(
40+
handler({ to: 'a@b.com', subject: 'hi' }, createMockContext())
41+
).rejects.toThrow("Either 'html' or 'text' must be provided");
42+
});
43+
44+
it('accepts text-only email (no html)', async () => {
45+
const handler = loadHandler();
46+
const result = await handler(
47+
{ to: 'a@b.com', subject: 'Hi', text: 'hello' },
48+
createMockContext()
49+
);
50+
expect(result).toEqual({ complete: true });
51+
});
52+
});
53+
54+
describe('sending', () => {
55+
it('returns { complete: true } on valid payload', async () => {
56+
const handler = loadHandler();
57+
const result = await handler(
58+
{ to: 'a@b.com', subject: 'Hi', html: '<p>hi</p>' },
59+
createMockContext()
60+
);
61+
expect(result).toEqual({ complete: true });
62+
});
63+
64+
it('calls postmaster.send by default (not SMTP)', async () => {
65+
const handler = loadHandler();
66+
await handler(
67+
{ to: 'a@b.com', subject: 'Hi', html: '<p>hi</p>' },
68+
createMockContext()
69+
);
70+
const postmaster = require('@constructive-io/postmaster');
71+
expect(postmaster.send).toHaveBeenCalledWith(
72+
expect.objectContaining({ to: 'a@b.com', subject: 'Hi' })
73+
);
74+
});
75+
76+
it('uses SMTP when EMAIL_SEND_USE_SMTP=true', async () => {
77+
process.env.EMAIL_SEND_USE_SMTP = 'true';
78+
const handler = loadHandler();
79+
await handler(
80+
{ to: 'a@b.com', subject: 'Hi', html: '<p>hi</p>' },
81+
createMockContext()
82+
);
83+
const smtp = require('simple-smtp-server');
84+
expect(smtp.send).toHaveBeenCalled();
85+
});
86+
87+
it('uses MAILGUN_FROM as fallback when from not in payload', async () => {
88+
process.env.MAILGUN_FROM = 'noreply@example.com';
89+
const handler = loadHandler();
90+
await handler(
91+
{ to: 'a@b.com', subject: 'Hi', html: '<p>hi</p>' },
92+
createMockContext()
93+
);
94+
const postmaster = require('@constructive-io/postmaster');
95+
expect(postmaster.send).toHaveBeenCalledWith(
96+
expect.objectContaining({ from: 'noreply@example.com' })
97+
);
98+
});
99+
100+
it('prefers payload "from" over env fallback', async () => {
101+
process.env.MAILGUN_FROM = 'env@example.com';
102+
const handler = loadHandler();
103+
await handler(
104+
{
105+
to: 'a@b.com',
106+
subject: 'Hi',
107+
html: '<p>hi</p>',
108+
from: 'payload@example.com'
109+
},
110+
createMockContext()
111+
);
112+
const postmaster = require('@constructive-io/postmaster');
113+
expect(postmaster.send).toHaveBeenCalledWith(
114+
expect.objectContaining({ from: 'payload@example.com' })
115+
);
116+
});
117+
});
118+
119+
describe('dry-run mode', () => {
120+
beforeEach(() => {
121+
process.env.SIMPLE_EMAIL_DRY_RUN = 'true';
122+
});
123+
124+
it('returns { complete: true } without sending', async () => {
125+
const handler = loadHandler();
126+
const result = await handler(
127+
{ to: 'a@b.com', subject: 'Hi', html: '<p>hi</p>' },
128+
createMockContext()
129+
);
130+
expect(result).toEqual({ complete: true });
131+
const postmaster = require('@constructive-io/postmaster');
132+
const smtp = require('simple-smtp-server');
133+
expect(postmaster.send).not.toHaveBeenCalled();
134+
expect(smtp.send).not.toHaveBeenCalled();
135+
});
136+
});
137+
});

jest.config.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { Config } from 'jest';
2+
3+
const config: Config = {
4+
preset: 'ts-jest',
5+
testEnvironment: 'node',
6+
transform: {
7+
'^.+\\.tsx?$': [
8+
'ts-jest',
9+
{
10+
tsconfig: 'tsconfig.json',
11+
diagnostics: false
12+
}
13+
]
14+
},
15+
transformIgnorePatterns: ['/node_modules/'],
16+
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
17+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
18+
modulePathIgnorePatterns: ['dist/', 'generated/'],
19+
moduleNameMapper: {
20+
'^@pgpmjs/env$': '<rootDir>/tests/__mocks__/@pgpmjs/env',
21+
'^@pgpmjs/logger$': '<rootDir>/tests/__mocks__/@pgpmjs/logger',
22+
'^@constructive-io/postmaster$':
23+
'<rootDir>/tests/__mocks__/@constructive-io/postmaster',
24+
'^simple-smtp-server$': '<rootDir>/tests/__mocks__/simple-smtp-server',
25+
'^@launchql/mjml$': '<rootDir>/tests/__mocks__/@launchql/mjml',
26+
'^graphql-tag$': '<rootDir>/tests/__mocks__/graphql-tag'
27+
},
28+
testTimeout: 30000
29+
};
30+
31+
export default config;

package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,23 @@
2222
"clean": "pnpm -r run clean",
2323
"build": "pnpm -r run build",
2424
"docker:build": "node --experimental-strip-types scripts/docker-build.ts --all",
25-
"lint": "pnpm -r --if-present run lint"
25+
"lint": "pnpm -r --if-present run lint",
26+
"test": "jest",
27+
"test:unit": "jest --testPathPatterns='functions/.+/__tests__'",
28+
"test:integration": "jest --testPathPatterns='tests/integration'"
2629
},
2730
"devDependencies": {
2831
"@eslint/js": "^9.39.2",
32+
"@types/jest": "^30.0.0",
2933
"@types/node": "^22.10.4",
3034
"eslint": "^9.39.2",
3135
"eslint-config-prettier": "^10.1.8",
3236
"eslint-plugin-simple-import-sort": "^12.1.0",
3337
"eslint-plugin-unused-imports": "^4.0.0",
3438
"globals": "^16.5.0",
39+
"jest": "^30.2.0",
3540
"prettier": "^3.7.4",
41+
"ts-jest": "^29.4.0",
3642
"typescript": "^5.1.6",
3743
"typescript-eslint": "^8.33.0"
3844
},

0 commit comments

Comments
 (0)