Skip to content

Commit 788c479

Browse files
committed
Add unit tests for backend functions
1 parent a8cadfa commit 788c479

4 files changed

Lines changed: 353 additions & 0 deletions

File tree

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2019-Present Datadog, Inc.
4+
5+
import { discoverBackendFunctions } from '@dd/apps-plugin/backend/discovery';
6+
import { getMockLogger, mockLogFn } from '@dd/tests/_jest/helpers/mocks';
7+
import fs from 'fs';
8+
import path from 'path';
9+
10+
const log = getMockLogger();
11+
const backendDir = '/project/backend';
12+
13+
const fileStat = { isDirectory: () => false, isFile: () => true };
14+
const dirStat = { isDirectory: () => true, isFile: () => false };
15+
16+
describe('Backend Functions - discoverBackendFunctions', () => {
17+
let readdirSpy: jest.SpyInstance;
18+
let statSpy: jest.SpyInstance;
19+
20+
beforeEach(() => {
21+
readdirSpy = jest.spyOn(fs, 'readdirSync');
22+
statSpy = jest.spyOn(fs, 'statSync');
23+
});
24+
25+
afterEach(() => {
26+
jest.restoreAllMocks();
27+
});
28+
29+
describe('file discovery', () => {
30+
const cases = [
31+
{
32+
description: 'discover a single .ts file',
33+
entries: ['handler.ts'],
34+
stats: { [path.join(backendDir, 'handler.ts')]: fileStat },
35+
expected: [{ name: 'handler', entryPath: path.join(backendDir, 'handler.ts') }],
36+
},
37+
{
38+
description: 'discover a single .js file',
39+
entries: ['handler.js'],
40+
stats: { [path.join(backendDir, 'handler.js')]: fileStat },
41+
expected: [{ name: 'handler', entryPath: path.join(backendDir, 'handler.js') }],
42+
},
43+
{
44+
description: 'discover a directory with index.ts',
45+
entries: ['myFunc'],
46+
stats: {
47+
[path.join(backendDir, 'myFunc')]: dirStat,
48+
[path.join(backendDir, 'myFunc', 'index.ts')]: fileStat,
49+
},
50+
expected: [
51+
{
52+
name: 'myFunc',
53+
entryPath: path.join(backendDir, 'myFunc', 'index.ts'),
54+
},
55+
],
56+
},
57+
{
58+
description: 'discover multiple functions (mix of files and directories)',
59+
entries: ['handler.ts', 'myFunc'],
60+
stats: {
61+
[path.join(backendDir, 'handler.ts')]: fileStat,
62+
[path.join(backendDir, 'myFunc')]: dirStat,
63+
[path.join(backendDir, 'myFunc', 'index.ts')]: fileStat,
64+
},
65+
expected: [
66+
{ name: 'handler', entryPath: path.join(backendDir, 'handler.ts') },
67+
{
68+
name: 'myFunc',
69+
entryPath: path.join(backendDir, 'myFunc', 'index.ts'),
70+
},
71+
],
72+
},
73+
{
74+
description: 'skip non-matching extensions',
75+
entries: ['config.json', 'styles.css', 'handler.ts'],
76+
stats: {
77+
[path.join(backendDir, 'config.json')]: fileStat,
78+
[path.join(backendDir, 'styles.css')]: fileStat,
79+
[path.join(backendDir, 'handler.ts')]: fileStat,
80+
},
81+
expected: [{ name: 'handler', entryPath: path.join(backendDir, 'handler.ts') }],
82+
},
83+
{
84+
description: 'skip directory with no valid index file',
85+
entries: ['emptyDir'],
86+
stats: {
87+
[path.join(backendDir, 'emptyDir')]: dirStat,
88+
},
89+
expected: [],
90+
},
91+
{
92+
description: 'return empty array for empty directory',
93+
entries: [],
94+
stats: {},
95+
expected: [],
96+
},
97+
];
98+
99+
test.each(cases)('Should $description', ({ entries, stats, expected }) => {
100+
readdirSpy.mockReturnValue(entries);
101+
statSpy.mockImplementation((p: string) => {
102+
const stat = stats[p];
103+
if (!stat) {
104+
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
105+
}
106+
return stat;
107+
});
108+
109+
const result = discoverBackendFunctions(backendDir, log);
110+
expect(result).toEqual(expected);
111+
});
112+
});
113+
114+
describe('error handling', () => {
115+
test('Should return empty array and log debug when directory does not exist', () => {
116+
readdirSpy.mockImplementation(() => {
117+
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
118+
});
119+
120+
const result = discoverBackendFunctions('/nonexistent', log);
121+
expect(result).toEqual([]);
122+
expect(mockLogFn).toHaveBeenCalledWith(
123+
expect.stringContaining('No backend directory found'),
124+
'debug',
125+
);
126+
});
127+
128+
test('Should rethrow non-ENOENT errors', () => {
129+
readdirSpy.mockImplementation(() => {
130+
throw Object.assign(new Error('EACCES'), { code: 'EACCES' });
131+
});
132+
133+
expect(() => discoverBackendFunctions(backendDir, log)).toThrow('EACCES');
134+
});
135+
});
136+
137+
describe('extension priority', () => {
138+
test('Should prefer .ts over .js for directory index', () => {
139+
readdirSpy.mockReturnValue(['myFunc']);
140+
statSpy.mockImplementation((p) => {
141+
if (p === path.join(backendDir, 'myFunc')) {
142+
return dirStat;
143+
}
144+
if (p === path.join(backendDir, 'myFunc', 'index.ts')) {
145+
return fileStat;
146+
}
147+
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
148+
});
149+
150+
const result = discoverBackendFunctions(backendDir, log);
151+
expect(result).toEqual([
152+
{
153+
name: 'myFunc',
154+
entryPath: path.join(backendDir, 'myFunc', 'index.ts'),
155+
},
156+
]);
157+
});
158+
});
159+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2019-Present Datadog, Inc.
4+
5+
import { BACKEND_VIRTUAL_PREFIX, getBackendPlugin } from '@dd/apps-plugin/backend/index';
6+
import { getMockLogger, mockLogFn } from '@dd/tests/_jest/helpers/mocks';
7+
8+
const log = getMockLogger();
9+
10+
const functions = [
11+
{ name: 'myHandler', entryPath: '/src/backend/myHandler.ts' },
12+
{ name: 'otherFunc', entryPath: '/src/backend/otherFunc/index.ts' },
13+
];
14+
15+
describe('Backend Functions - getBackendPlugin', () => {
16+
beforeEach(() => {
17+
jest.restoreAllMocks();
18+
});
19+
20+
describe('plugin shape', () => {
21+
test('Should return a plugin with correct name and enforce', () => {
22+
const plugin = getBackendPlugin(functions, new Map(), log);
23+
expect(plugin.name).toBe('datadog-apps-backend-plugin');
24+
expect(plugin.enforce).toBe('pre');
25+
});
26+
27+
test('Should have rollup and vite properties', () => {
28+
const plugin = getBackendPlugin(functions, new Map(), log);
29+
expect(plugin.rollup).toBeDefined();
30+
expect(plugin.vite).toBeDefined();
31+
});
32+
});
33+
34+
describe('resolveId', () => {
35+
const cases = [
36+
{
37+
description: 'resolve virtual backend module ID',
38+
input: `${BACKEND_VIRTUAL_PREFIX}myHandler`,
39+
expected: `${BACKEND_VIRTUAL_PREFIX}myHandler`,
40+
},
41+
{
42+
description: 'return null for non-backend module',
43+
input: 'some-other-module',
44+
expected: null,
45+
},
46+
{
47+
description: 'return null for empty string',
48+
input: '',
49+
expected: null,
50+
},
51+
];
52+
53+
test.each(cases)('Should $description', ({ input, expected }) => {
54+
const plugin = getBackendPlugin(functions, new Map(), log);
55+
const resolveId = plugin.resolveId as Function;
56+
expect(resolveId(input, undefined, {})).toBe(expected);
57+
});
58+
});
59+
60+
describe('load', () => {
61+
test('Should return virtual entry content for known function', () => {
62+
const plugin = getBackendPlugin(functions, new Map(), log);
63+
const load = plugin.load as Function;
64+
const content = load(`${BACKEND_VIRTUAL_PREFIX}myHandler`);
65+
expect(content).toContain('import { myHandler }');
66+
expect(content).toContain('export async function main($)');
67+
});
68+
69+
test('Should return null and log error for unknown function', () => {
70+
const plugin = getBackendPlugin(functions, new Map(), log);
71+
const load = plugin.load as Function;
72+
const content = load(`${BACKEND_VIRTUAL_PREFIX}unknownFunc`);
73+
expect(content).toBeNull();
74+
expect(mockLogFn).toHaveBeenCalledWith(
75+
expect.stringContaining('Backend function "unknownFunc" not found'),
76+
'error',
77+
);
78+
});
79+
80+
test('Should return null for non-prefixed ID', () => {
81+
const plugin = getBackendPlugin(functions, new Map(), log);
82+
const load = plugin.load as Function;
83+
expect(load('regular-module')).toBeNull();
84+
});
85+
});
86+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2019-Present Datadog, Inc.
4+
5+
import {
6+
ACTION_CATALOG_IMPORT,
7+
SET_EXECUTE_ACTION_SNIPPET,
8+
isActionCatalogInstalled,
9+
} from '@dd/apps-plugin/backend/shared';
10+
11+
describe('Backend Functions - shared', () => {
12+
describe('isActionCatalogInstalled', () => {
13+
test('Should return false when action-catalog is not installed', () => {
14+
// In the test environment, @datadog/action-catalog is not installed.
15+
expect(isActionCatalogInstalled()).toBe(false);
16+
});
17+
});
18+
19+
describe('constants', () => {
20+
test('ACTION_CATALOG_IMPORT should contain action-catalog import', () => {
21+
expect(ACTION_CATALOG_IMPORT).toContain('@datadog/action-catalog/action-execution');
22+
expect(ACTION_CATALOG_IMPORT).toContain('setExecuteActionImplementation');
23+
});
24+
25+
test('SET_EXECUTE_ACTION_SNIPPET should contain the bridge implementation', () => {
26+
expect(SET_EXECUTE_ACTION_SNIPPET).toContain('setExecuteActionImplementation');
27+
expect(SET_EXECUTE_ACTION_SNIPPET).toContain('$.Actions');
28+
});
29+
});
30+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2019-Present Datadog, Inc.
4+
5+
import * as shared from '@dd/apps-plugin/backend/shared';
6+
import { generateVirtualEntryContent } from '@dd/apps-plugin/backend/virtual-entry';
7+
8+
describe('Backend Functions - generateVirtualEntryContent', () => {
9+
beforeEach(() => {
10+
jest.restoreAllMocks();
11+
});
12+
13+
describe('without action-catalog', () => {
14+
beforeEach(() => {
15+
jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(false);
16+
});
17+
18+
test('Should import the function by name from the entry path', () => {
19+
const result = generateVirtualEntryContent('myHandler', '/src/backend/handler.ts');
20+
expect(result).toContain('import { myHandler } from "/src/backend/handler.ts"');
21+
});
22+
23+
test('Should export an async main($) function', () => {
24+
const result = generateVirtualEntryContent('myHandler', '/src/handler.ts');
25+
expect(result).toContain('export async function main($)');
26+
});
27+
28+
test('Should set globalThis.$ = $', () => {
29+
const result = generateVirtualEntryContent('myHandler', '/src/handler.ts');
30+
expect(result).toContain('globalThis.$ = $');
31+
});
32+
33+
test('Should include backendFunctionArgs template expression', () => {
34+
const result = generateVirtualEntryContent('myHandler', '/src/handler.ts');
35+
// eslint-disable-next-line no-template-curly-in-string
36+
expect(result).toContain("JSON.parse('${backendFunctionArgs}'");
37+
});
38+
39+
test('Should call the function with spread args', () => {
40+
const result = generateVirtualEntryContent('myHandler', '/src/handler.ts');
41+
expect(result).toContain('await myHandler(...args)');
42+
});
43+
44+
test('Should include the setExecuteActionImplementation bridge snippet', () => {
45+
const result = generateVirtualEntryContent('myHandler', '/src/handler.ts');
46+
expect(result).toContain('typeof setExecuteActionImplementation');
47+
});
48+
49+
test('Should not include action-catalog import', () => {
50+
const result = generateVirtualEntryContent('myHandler', '/src/handler.ts');
51+
expect(result).not.toContain('@datadog/action-catalog');
52+
});
53+
});
54+
55+
describe('with action-catalog', () => {
56+
beforeEach(() => {
57+
jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true);
58+
});
59+
60+
test('Should include action-catalog import', () => {
61+
const result = generateVirtualEntryContent('myHandler', '/src/handler.ts');
62+
expect(result).toContain(
63+
"import { setExecuteActionImplementation } from '@datadog/action-catalog/action-execution'",
64+
);
65+
});
66+
67+
test('Should still include the bridge snippet', () => {
68+
const result = generateVirtualEntryContent('myHandler', '/src/handler.ts');
69+
expect(result).toContain('typeof setExecuteActionImplementation');
70+
});
71+
});
72+
73+
test('Should escape entry paths with special characters', () => {
74+
jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(false);
75+
const result = generateVirtualEntryContent('handler', '/path/with "quotes"/handler.ts');
76+
expect(result).toContain('from "/path/with \\"quotes\\"/handler.ts"');
77+
});
78+
});

0 commit comments

Comments
 (0)