Skip to content

Commit 7676b7c

Browse files
committed
Add backend tests
1 parent b578fc9 commit 7676b7c

File tree

17 files changed

+5604
-320
lines changed

17 files changed

+5604
-320
lines changed

.github/workflows/backend.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ jobs:
2828
- name: Install Dependencies
2929
working-directory: backend
3030
run: npm install
31+
- name: Run Tests
32+
working-directory: backend
33+
run: npm test
3134
- name: Build
3235
working-directory: backend
3336
run: ./serverless package --package /tmp/artifacts --stage prod

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ jspm_packages
77
.serverless
88
.webpack
99
backend/artifacts
10-
frontend/next-env.d.ts
10+
backend/coverage
11+
frontend/next-env.d.ts

backend/.babelrc

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,26 @@
66
{
77
"modules": false,
88
"targets": {
9-
"node": "20",
10-
},
9+
"node": "20"
10+
}
1111
}
12-
],
12+
]
1313
],
1414
"plugins": [
15-
"@babel/plugin-transform-class-properties",
16-
]
17-
}
15+
"@babel/plugin-transform-class-properties"
16+
],
17+
"env": {
18+
"test": {
19+
"presets": [
20+
[
21+
"@babel/preset-env",
22+
{
23+
"targets": {
24+
"node": "current"
25+
}
26+
}
27+
]
28+
]
29+
}
30+
}
31+
}

backend/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,10 @@ Deploy one function: $ ./serverless deploy function -f [functionName] [--stage p
1717
Invoke a remote function: $ ./serverless invoke -f [functionName] -l [--stage prod]
1818
Display remote logs: $ ./serverless logs -f [functionName] -t [--stage prod]
1919
Delete remote function: $ ./serverless remove
20+
21+
22+
Testing
23+
-------------
24+
Run tests: $ npm test
25+
Run tests in watch mode: $ npm run test:watch
26+
Run tests with coverage: $ npm run test:coverage
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
const mockSend = jest.fn().mockResolvedValue([{ statusCode: 202 }]);
2+
const mockSetApiKey = jest.fn();
3+
4+
module.exports = {
5+
setApiKey: mockSetApiKey,
6+
send: mockSend,
7+
__mockSend: mockSend,
8+
__mockSetApiKey: mockSetApiKey,
9+
};

backend/__mocks__/aws-sdk.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
const mockGetParameters = jest.fn((params, callback) => {
2+
// Default implementation that can be overridden in tests
3+
callback(null, { Parameters: [] });
4+
});
5+
6+
class SSM {
7+
constructor() {
8+
this.getParameters = mockGetParameters;
9+
}
10+
}
11+
12+
// Reset helper
13+
const resetMock = () => {
14+
mockGetParameters.mockClear();
15+
mockGetParameters.mockImplementation((params, callback) => {
16+
callback(null, { Parameters: [] });
17+
});
18+
};
19+
20+
module.exports = {
21+
SSM,
22+
__mockGetParameters: mockGetParameters,
23+
__resetMock: resetMock,
24+
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
const mockGet = jest.fn();
2+
const mockPost = jest.fn();
3+
const mockPut = jest.fn();
4+
5+
class MockMailchimp {
6+
constructor(apiToken) {
7+
this.apiToken = apiToken;
8+
this.get = mockGet;
9+
this.post = mockPost;
10+
this.put = mockPut;
11+
}
12+
}
13+
14+
MockMailchimp.__mockGet = mockGet;
15+
MockMailchimp.__mockPost = mockPost;
16+
MockMailchimp.__mockPut = mockPut;
17+
18+
module.exports = MockMailchimp;

backend/cached_value.test.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"use strict";
2+
3+
import CachedValue from './cached_value';
4+
5+
describe('CachedValue', () => {
6+
test('creator is called only once on first access', async () => {
7+
const creator = jest.fn().mockResolvedValue('test-value');
8+
const cached = new CachedValue(creator);
9+
10+
const result = await cached.get();
11+
12+
expect(result).toBe('test-value');
13+
expect(creator).toHaveBeenCalledTimes(1);
14+
});
15+
16+
test('value is cached for subsequent accesses', async () => {
17+
const creator = jest.fn().mockResolvedValue('cached-value');
18+
const cached = new CachedValue(creator);
19+
20+
const result1 = await cached.get();
21+
const result2 = await cached.get();
22+
const result3 = await cached.get();
23+
24+
expect(result1).toBe('cached-value');
25+
expect(result2).toBe('cached-value');
26+
expect(result3).toBe('cached-value');
27+
expect(creator).toHaveBeenCalledTimes(1);
28+
});
29+
30+
test('concurrent access calls creator only once', async () => {
31+
let callCount = 0;
32+
const creator = jest.fn(async () => {
33+
callCount++;
34+
// Simulate async work
35+
await new Promise(resolve => setTimeout(resolve, 10));
36+
return `value-${callCount}`;
37+
});
38+
const cached = new CachedValue(creator);
39+
40+
// Call get() multiple times concurrently
41+
const results = await Promise.all([
42+
cached.get(),
43+
cached.get(),
44+
cached.get(),
45+
]);
46+
47+
expect(creator).toHaveBeenCalledTimes(1);
48+
expect(results).toEqual(['value-1', 'value-1', 'value-1']);
49+
});
50+
51+
test('creator returning different types works correctly', async () => {
52+
const objectValue = { foo: 'bar', count: 42 };
53+
const creator = jest.fn().mockResolvedValue(objectValue);
54+
const cached = new CachedValue(creator);
55+
56+
const result = await cached.get();
57+
58+
expect(result).toEqual(objectValue);
59+
expect(result).toBe(objectValue); // Same reference
60+
});
61+
62+
test('creator error propagates correctly', async () => {
63+
const error = new Error('Creator failed');
64+
const creator = jest.fn().mockRejectedValue(error);
65+
const cached = new CachedValue(creator);
66+
67+
await expect(cached.get()).rejects.toThrow('Creator failed');
68+
});
69+
});

backend/contact.test.js

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"use strict";
2+
3+
jest.mock('./email', () => ({
4+
email_myself: jest.fn().mockResolvedValue(undefined),
5+
}));
6+
7+
import { send } from './contact';
8+
import { email_myself } from './email';
9+
10+
describe('contact send', () => {
11+
const validToken = 'fd0kAn1zns';
12+
13+
beforeEach(() => {
14+
email_myself.mockClear();
15+
});
16+
17+
test('sends contact email with sender email in subject', async () => {
18+
const event = {
19+
body: JSON.stringify({
20+
token: validToken,
21+
email: 'visitor@example.com',
22+
message: 'Hello, I have a question.',
23+
}),
24+
};
25+
26+
const result = await send(event, {});
27+
28+
expect(result.statusCode).toBe(200);
29+
expect(JSON.parse(result.body)).toEqual({ success: true });
30+
expect(email_myself).toHaveBeenCalledWith(
31+
'CryFS Contact Form',
32+
'CryFS Contact Form (from visitor@example.com)',
33+
'Hello, I have a question.',
34+
'visitor@example.com'
35+
);
36+
});
37+
38+
test('shows "(from unknown)" when email is empty string', async () => {
39+
const event = {
40+
body: JSON.stringify({
41+
token: validToken,
42+
email: '',
43+
message: 'Anonymous message',
44+
}),
45+
};
46+
47+
const result = await send(event, {});
48+
49+
expect(result.statusCode).toBe(200);
50+
expect(email_myself).toHaveBeenCalledWith(
51+
'CryFS Contact Form',
52+
'CryFS Contact Form (from unknown)',
53+
'Anonymous message',
54+
''
55+
);
56+
});
57+
58+
test('handles undefined email', async () => {
59+
const event = {
60+
body: JSON.stringify({
61+
token: validToken,
62+
message: 'Message without email',
63+
}),
64+
};
65+
66+
const result = await send(event, {});
67+
68+
expect(result.statusCode).toBe(200);
69+
// When email is undefined, it's passed as undefined to do_send
70+
expect(email_myself).toHaveBeenCalledWith(
71+
'CryFS Contact Form',
72+
expect.stringContaining('CryFS Contact Form'),
73+
'Message without email',
74+
undefined
75+
);
76+
});
77+
78+
test('returns 200 on success', async () => {
79+
const event = {
80+
body: JSON.stringify({
81+
token: validToken,
82+
email: 'test@test.com',
83+
message: 'Test message',
84+
}),
85+
};
86+
87+
const result = await send(event, {});
88+
89+
expect(result.statusCode).toBe(200);
90+
expect(JSON.parse(result.body)).toEqual({ success: true });
91+
});
92+
93+
test('includes CORS headers in response', async () => {
94+
const event = {
95+
body: JSON.stringify({
96+
token: validToken,
97+
email: 'test@test.com',
98+
message: 'Test message',
99+
}),
100+
};
101+
102+
const result = await send(event, {});
103+
104+
expect(result.headers).toEqual({
105+
'Access-Control-Allow-Origin': 'https://www.cryfs.org',
106+
'Access-Control-Allow-Credentials': false,
107+
'Vary': 'Origin',
108+
});
109+
});
110+
111+
test('rejects invalid token', async () => {
112+
const event = {
113+
body: JSON.stringify({
114+
token: 'invalid-token',
115+
email: 'test@test.com',
116+
message: 'Test message',
117+
}),
118+
};
119+
120+
const result = await send(event, {});
121+
122+
expect(result.statusCode).toBe(400);
123+
expect(JSON.parse(result.body)).toEqual({
124+
success: false,
125+
error: 'Wrong token',
126+
});
127+
});
128+
});

backend/email.test.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"use strict";
2+
3+
jest.mock('@sendgrid/mail');
4+
jest.mock('./secret', () => jest.fn().mockResolvedValue('test-sendgrid-key'));
5+
6+
describe('email_myself', () => {
7+
let sendgrid;
8+
let email_myself;
9+
10+
beforeEach(() => {
11+
jest.resetModules();
12+
sendgrid = require('@sendgrid/mail');
13+
sendgrid.__mockSend.mockClear();
14+
sendgrid.__mockSetApiKey.mockClear();
15+
email_myself = require('./email').email_myself;
16+
});
17+
18+
test('sends email with correct payload', async () => {
19+
await email_myself('Test Sender', 'Test Subject', 'Test message body');
20+
21+
expect(sendgrid.setApiKey).toHaveBeenCalledWith('test-sendgrid-key');
22+
expect(sendgrid.send).toHaveBeenCalledWith({
23+
to: 'messmer@cryfs.org',
24+
from: {
25+
email: 'messmer@cryfs.org',
26+
name: 'Test Sender',
27+
},
28+
subject: 'Test Subject',
29+
text: 'Test message body',
30+
});
31+
});
32+
33+
test('includes reply_to when provided', async () => {
34+
await email_myself('Sender', 'Subject', 'Message', 'reply@example.com');
35+
36+
expect(sendgrid.send).toHaveBeenCalledWith({
37+
to: 'messmer@cryfs.org',
38+
from: {
39+
email: 'messmer@cryfs.org',
40+
name: 'Sender',
41+
},
42+
subject: 'Subject',
43+
text: 'Message',
44+
reply_to: 'reply@example.com',
45+
});
46+
});
47+
48+
test('excludes reply_to when undefined', async () => {
49+
await email_myself('Sender', 'Subject', 'Message', undefined);
50+
51+
const callArgs = sendgrid.send.mock.calls[0][0];
52+
expect(callArgs).not.toHaveProperty('reply_to');
53+
});
54+
55+
test('excludes reply_to when empty string', async () => {
56+
await email_myself('Sender', 'Subject', 'Message', '');
57+
58+
const callArgs = sendgrid.send.mock.calls[0][0];
59+
expect(callArgs).not.toHaveProperty('reply_to');
60+
});
61+
62+
test('caches SendGrid instance across calls', async () => {
63+
// Within this single test, make multiple calls
64+
await email_myself('Sender1', 'Subject1', 'Message1');
65+
await email_myself('Sender2', 'Subject2', 'Message2');
66+
await email_myself('Sender3', 'Subject3', 'Message3');
67+
68+
// setApiKey should only be called once due to CachedValue
69+
expect(sendgrid.setApiKey).toHaveBeenCalledTimes(1);
70+
expect(sendgrid.send).toHaveBeenCalledTimes(3);
71+
});
72+
});

0 commit comments

Comments
 (0)